feat:移除了弹窗,服务器添加sls
This commit is contained in:
150
packages/ui/src/components/base/Accordion.vue
Normal file
150
packages/ui/src/components/base/Accordion.vue
Normal file
@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<div v-bind="$attrs">
|
||||
<div v-if="divider && (!!slots.title || !!slots.button)" class="flex items-center gap-4 mb-4">
|
||||
<button
|
||||
:class="
|
||||
buttonClass ??
|
||||
'group flex items-center gap-1 bg-transparent m-0 p-0 border-none cursor-pointer'
|
||||
"
|
||||
@click="() => (forceOpen ? undefined : toggledOpen ? close() : open())"
|
||||
>
|
||||
<slot name="button" :open="isOpen">
|
||||
<div
|
||||
class="flex items-center gap-1 whitespace-nowrap transition-colors text-primary group-hover:text-contrast"
|
||||
>
|
||||
<slot name="title" :open="isOpen" />
|
||||
<DropdownIcon
|
||||
v-if="!forceOpen"
|
||||
class="size-5 transition-transform duration-300 shrink-0 text-secondary group-hover:text-primary"
|
||||
:class="{ 'rotate-180': isOpen }"
|
||||
/>
|
||||
</div>
|
||||
</slot>
|
||||
</button>
|
||||
<hr class="h-px w-full border-none bg-divider" aria-hidden="true" />
|
||||
</div>
|
||||
<button
|
||||
v-else-if="!!slots.title || !!slots.button"
|
||||
:class="buttonClass ?? 'flex flex-col gap-2 bg-transparent m-0 p-0 border-none'"
|
||||
@click="() => (forceOpen ? undefined : toggledOpen ? close() : open())"
|
||||
>
|
||||
<slot name="button" :open="isOpen">
|
||||
<div class="flex items-center gap-1 w-full text-contrast">
|
||||
<slot name="title" :open="isOpen" />
|
||||
<DropdownIcon
|
||||
v-if="!forceOpen"
|
||||
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, 'overflow-visible': overflowVisible && showOverflow }"
|
||||
@transitionend="onTransitionEnd"
|
||||
>
|
||||
<div>
|
||||
<div :class="contentClass ? contentClass : ''" :inert="!isOpen">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DropdownIcon } from '@modrinth/assets'
|
||||
import { computed, ref, useSlots, watch } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
openByDefault?: boolean
|
||||
type?: 'standard' | 'outlined' | 'transparent'
|
||||
buttonClass?: string
|
||||
contentClass?: string
|
||||
titleWrapperClass?: string
|
||||
forceOpen?: boolean
|
||||
overflowVisible?: boolean
|
||||
divider?: boolean
|
||||
}>(),
|
||||
{
|
||||
type: 'standard',
|
||||
openByDefault: false,
|
||||
buttonClass: null,
|
||||
contentClass: null,
|
||||
titleWrapperClass: null,
|
||||
forceOpen: false,
|
||||
overflowVisible: false,
|
||||
divider: 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()
|
||||
|
||||
watch(
|
||||
() => props.openByDefault,
|
||||
(newValue) => {
|
||||
if (newValue !== toggledOpen.value) {
|
||||
toggledOpen.value = newValue
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function open() {
|
||||
toggledOpen.value = true
|
||||
emit('onOpen')
|
||||
}
|
||||
function close() {
|
||||
showOverflow.value = false
|
||||
toggledOpen.value = false
|
||||
emit('onClose')
|
||||
}
|
||||
function onTransitionEnd() {
|
||||
if (isOpen.value) {
|
||||
showOverflow.value = true
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open,
|
||||
close,
|
||||
isOpen: toggledOpen,
|
||||
})
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.accordion-content {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 0.3s cubic-bezier(0, 0.6, 0.3, 1);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
.accordion-content {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.accordion-content.open {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.accordion-content > div {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.accordion-content.overflow-visible > div {
|
||||
overflow: visible;
|
||||
}
|
||||
</style>
|
||||
203
packages/ui/src/components/base/Admonition.vue
Normal file
203
packages/ui/src/components/base/Admonition.vue
Normal file
@ -0,0 +1,203 @@
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'relative grid grid-cols-[1.5rem_minmax(0,1fr)_auto] items-start gap-x-2 rounded-2xl border border-solid p-4 text-contrast',
|
||||
progress != null ? 'overflow-hidden pb-5' : '',
|
||||
typeClasses[type],
|
||||
]"
|
||||
>
|
||||
<slot name="icon" :icon-class="['h-6 w-6 flex-none', iconClasses[type]]">
|
||||
<component :is="getSeverityIcon(type)" :class="['h-6 w-6 flex-none', iconClasses[type]]" />
|
||||
</slot>
|
||||
<div class="col-start-2 flex min-w-0 flex-1 flex-col gap-2">
|
||||
<div
|
||||
v-if="header || $slots.header || normalizedTimestamp"
|
||||
class="flex flex-wrap items-center gap-2 text-lg font-semibold leading-6"
|
||||
>
|
||||
<slot name="header">{{ header }}</slot>
|
||||
<span
|
||||
v-if="normalizedTimestamp"
|
||||
v-tooltip="timestampTooltip"
|
||||
class="flex items-center gap-1.5 text-base font-medium leading-normal text-secondary"
|
||||
>
|
||||
<ClockIcon class="size-4" />
|
||||
{{ relativeTimeLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="font-normal text-contrast/85 leading-tight [overflow-wrap:anywhere]">
|
||||
<slot>{{ body }}</slot>
|
||||
</div>
|
||||
<div v-if="showActionsUnderneath || $slots.actions" class="mt-2">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="$slots['top-right-actions'] || dismissible"
|
||||
class="col-start-3 row-start-1 flex shrink-0 items-center gap-2 self-start"
|
||||
>
|
||||
<slot name="top-right-actions" />
|
||||
<ButtonStyled
|
||||
v-if="dismissible"
|
||||
circular
|
||||
type="transparent"
|
||||
:color="buttonColors[type]"
|
||||
hover-color-fill="background"
|
||||
>
|
||||
<button type="button" aria-label="Dismiss" @click="$emit('dismiss')">
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div
|
||||
v-if="progress != null"
|
||||
class="absolute inset-x-0 bottom-0 h-1 overflow-hidden"
|
||||
:class="progressTrackClasses[type]"
|
||||
role="progressbar"
|
||||
:aria-valuenow="waiting ? undefined : Math.round(normalizedProgress * 100)"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-r-full transition-[width] duration-200 ease-in-out"
|
||||
:class="[
|
||||
progressFillClasses[progressColor ?? type],
|
||||
{ 'admonition-progress--waiting': waiting },
|
||||
]"
|
||||
:style="waiting ? undefined : { width: `${normalizedProgress * 100}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ClockIcon, XIcon } from '@modrinth/assets'
|
||||
import { useNow } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useFormatDateTime, useRelativeTime } from '../../composables'
|
||||
import { getSeverityIcon } from '../../utils'
|
||||
import ButtonStyled from './ButtonStyled.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
type?: 'info' | 'warning' | 'critical' | 'success' | 'moderation' | 'circle-warning'
|
||||
header?: string
|
||||
body?: string
|
||||
showActionsUnderneath?: boolean
|
||||
dismissible?: boolean
|
||||
progress?: number
|
||||
progressColor?: 'info' | 'warning' | 'critical' | 'success' | 'blue' | 'green' | 'red'
|
||||
waiting?: boolean
|
||||
/** Accepts a Date, an ISO string, or a millisecond Unix timestamp. */
|
||||
timestamp?: Date | string | number
|
||||
}>(),
|
||||
{
|
||||
type: 'info',
|
||||
header: '',
|
||||
body: '',
|
||||
showActionsUnderneath: false,
|
||||
dismissible: false,
|
||||
progress: undefined,
|
||||
progressColor: undefined,
|
||||
waiting: false,
|
||||
timestamp: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
defineEmits<{
|
||||
dismiss: []
|
||||
}>()
|
||||
|
||||
const relativeTime = useRelativeTime()
|
||||
const formatDateTime = useFormatDateTime({
|
||||
dateStyle: 'long',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
const now = useNow({ interval: 1000 })
|
||||
|
||||
const normalizedProgress = computed(() => Math.min(Math.max(props.progress ?? 0, 0), 1))
|
||||
|
||||
const normalizedTimestamp = computed(() => {
|
||||
const t = props.timestamp
|
||||
if (t == null) return null
|
||||
if (t instanceof Date) return t.toISOString()
|
||||
if (typeof t === 'number') return new Date(t).toISOString()
|
||||
return t
|
||||
})
|
||||
|
||||
const relativeTimeLabel = computed(() => {
|
||||
void now.value
|
||||
const t = normalizedTimestamp.value
|
||||
return t ? relativeTime(t) : ''
|
||||
})
|
||||
|
||||
const timestampTooltip = computed(() => {
|
||||
const t = normalizedTimestamp.value
|
||||
return t ? formatDateTime(t) : ''
|
||||
})
|
||||
|
||||
const typeClasses = {
|
||||
info: 'border-brand-blue bg-bg-blue',
|
||||
warning: 'border-brand-orange bg-bg-orange',
|
||||
'circle-warning': 'border-brand-orange bg-bg-orange',
|
||||
critical: 'border-brand-red bg-bg-red',
|
||||
success: 'border-brand-green bg-bg-green',
|
||||
moderation: 'border-brand-orange bg-bg-orange',
|
||||
}
|
||||
|
||||
const iconClasses = {
|
||||
info: 'text-brand-blue',
|
||||
warning: 'text-brand-orange',
|
||||
'circle-warning': 'text-brand-orange',
|
||||
critical: 'text-brand-red',
|
||||
success: 'text-brand-green',
|
||||
moderation: 'text-brand-orange',
|
||||
}
|
||||
|
||||
const buttonColors = {
|
||||
info: 'blue',
|
||||
warning: 'orange',
|
||||
'circle-warning': 'orange',
|
||||
critical: 'red',
|
||||
success: 'green',
|
||||
moderation: 'orange',
|
||||
} as const
|
||||
|
||||
const progressTrackClasses = {
|
||||
info: 'bg-brand-blue/20',
|
||||
warning: 'bg-brand-orange/20',
|
||||
'circle-warning': 'bg-brand-orange/20',
|
||||
critical: 'bg-brand-red/20',
|
||||
success: 'bg-brand-green/20',
|
||||
moderation: 'bg-brand-orange/20',
|
||||
}
|
||||
|
||||
const progressFillClasses = {
|
||||
info: 'bg-brand-blue',
|
||||
warning: 'bg-brand-orange',
|
||||
'circle-warning': 'bg-brand-orange',
|
||||
critical: 'bg-brand-red',
|
||||
success: 'bg-brand-green',
|
||||
blue: 'bg-brand-blue',
|
||||
green: 'bg-brand-green',
|
||||
red: 'bg-brand-red',
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admonition-progress--waiting {
|
||||
animation: admonition-progress-waiting 1s linear infinite;
|
||||
position: relative;
|
||||
width: 20%;
|
||||
}
|
||||
|
||||
@keyframes admonition-progress-waiting {
|
||||
0% {
|
||||
left: -20%;
|
||||
}
|
||||
|
||||
100% {
|
||||
left: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
143
packages/ui/src/components/base/AppearingProgressBar.vue
Normal file
143
packages/ui/src/components/base/AppearingProgressBar.vue
Normal file
@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-20"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-20"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div v-if="isVisible" class="w-full">
|
||||
<div class="mb-2 flex justify-between text-sm">
|
||||
<Transition name="phrase-fade" mode="out-in">
|
||||
<span :key="currentPhrase" class="text-md font-semibold">{{ currentPhrase }}</span>
|
||||
</Transition>
|
||||
<div class="flex flex-col items-end">
|
||||
<span class="text-secondary">{{ Math.round(progress) }}%</span>
|
||||
<span class="text-xs text-secondary"
|
||||
>{{ formatBytes(currentValue) }} / {{ formatBytes(maxValue) }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-2 w-full rounded-full bg-divider">
|
||||
<div
|
||||
class="h-2 animate-pulse bg-brand rounded-full transition-all duration-300 ease-out"
|
||||
:style="{ width: `${progress}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { useFormatBytes } from '#ui/composables'
|
||||
|
||||
interface Props {
|
||||
maxValue: number
|
||||
currentValue: number
|
||||
tips?: string[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
tips: () => [
|
||||
'Removing Herobrine...',
|
||||
'Feeding parrots...',
|
||||
'Teaching villagers new trades...',
|
||||
'Convincing creepers to be friendly...',
|
||||
'Polishing diamonds...',
|
||||
'Training wolves to fetch...',
|
||||
'Building pixel art...',
|
||||
'Explaining redstone to beginners...',
|
||||
'Collecting all the cats...',
|
||||
'Negotiating with endermen...',
|
||||
'Planting suspicious stew ingredients...',
|
||||
'Calibrating TNT blast radius...',
|
||||
'Teaching chickens to fly...',
|
||||
'Sorting inventory alphabetically...',
|
||||
'Convincing iron golems to smile...',
|
||||
],
|
||||
})
|
||||
|
||||
const formatBytes = useFormatBytes()
|
||||
|
||||
const currentPhrase = ref('')
|
||||
const usedPhrases = ref(new Set<number>())
|
||||
let phraseInterval: NodeJS.Timeout | null = null
|
||||
|
||||
const progress = computed(() => {
|
||||
if (props.maxValue === 0) return 0
|
||||
return Math.min((props.currentValue / props.maxValue) * 100, 100)
|
||||
})
|
||||
|
||||
const isVisible = computed(() => props.maxValue > 0 && props.currentValue >= 0)
|
||||
|
||||
function getNextPhrase() {
|
||||
if (usedPhrases.value.size >= props.tips.length) {
|
||||
const currentPhraseIndex = props.tips.indexOf(currentPhrase.value)
|
||||
usedPhrases.value.clear()
|
||||
if (currentPhraseIndex !== -1) {
|
||||
usedPhrases.value.add(currentPhraseIndex)
|
||||
}
|
||||
}
|
||||
const availableIndices = props.tips
|
||||
.map((_, index) => index)
|
||||
.filter((index) => !usedPhrases.value.has(index))
|
||||
|
||||
const randomIndex = availableIndices[Math.floor(Math.random() * availableIndices.length)]
|
||||
usedPhrases.value.add(randomIndex)
|
||||
|
||||
return props.tips[randomIndex]
|
||||
}
|
||||
|
||||
function startPhraseRotation() {
|
||||
if (phraseInterval) {
|
||||
clearInterval(phraseInterval)
|
||||
}
|
||||
|
||||
currentPhrase.value = getNextPhrase()
|
||||
phraseInterval = setInterval(() => {
|
||||
currentPhrase.value = getNextPhrase()
|
||||
}, 4500)
|
||||
}
|
||||
|
||||
function stopPhraseRotation() {
|
||||
if (phraseInterval) {
|
||||
clearInterval(phraseInterval)
|
||||
phraseInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(isVisible, (newVisible) => {
|
||||
if (newVisible) {
|
||||
startPhraseRotation()
|
||||
} else {
|
||||
stopPhraseRotation()
|
||||
usedPhrases.value.clear()
|
||||
}
|
||||
})
|
||||
|
||||
watch(progress, (newProgress) => {
|
||||
if (newProgress >= 100) {
|
||||
stopPhraseRotation()
|
||||
currentPhrase.value = 'Installing modpack...'
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPhraseRotation()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.phrase-fade-enter-active,
|
||||
.phrase-fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.phrase-fade-enter-from,
|
||||
.phrase-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
153
packages/ui/src/components/base/AutoBrandIcon.vue
Normal file
153
packages/ui/src/components/base/AutoBrandIcon.vue
Normal file
@ -0,0 +1,153 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
AppleIcon,
|
||||
BlueskyIcon,
|
||||
BuyMeACoffeeIcon,
|
||||
CurseForgeIcon,
|
||||
DiscordIcon,
|
||||
FacebookIcon,
|
||||
GithubIcon,
|
||||
InstagramIcon,
|
||||
KoFiIcon,
|
||||
MastodonIcon,
|
||||
ModrinthIcon,
|
||||
OpenCollectiveIcon,
|
||||
PatreonIcon,
|
||||
PayPalIcon,
|
||||
RedditIcon,
|
||||
ReelsIcon,
|
||||
SnapchatIcon,
|
||||
ThreadsIcon,
|
||||
TikTokIcon,
|
||||
TumblrIcon,
|
||||
TwitchIcon,
|
||||
TwitterIcon,
|
||||
WindowsIcon,
|
||||
YouTubeGaming,
|
||||
YouTubeIcon,
|
||||
YouTubeShortsIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
keyword: string
|
||||
}>()
|
||||
|
||||
const services = [
|
||||
{
|
||||
icon: AppleIcon,
|
||||
keywords: ['apple'],
|
||||
},
|
||||
{
|
||||
icon: BlueskyIcon,
|
||||
keywords: ['bluesky', 'bsky', 'blue sky'],
|
||||
},
|
||||
{
|
||||
icon: BuyMeACoffeeIcon,
|
||||
keywords: ['buymeacoffee', 'bmac', 'buy me a coffee'],
|
||||
},
|
||||
{
|
||||
icon: DiscordIcon,
|
||||
keywords: ['discord'],
|
||||
},
|
||||
{
|
||||
icon: FacebookIcon,
|
||||
keywords: ['facebook', 'fb', 'face book'],
|
||||
},
|
||||
{
|
||||
icon: GithubIcon,
|
||||
keywords: ['github', 'gh', 'git hub'],
|
||||
},
|
||||
{
|
||||
icon: ThreadsIcon,
|
||||
keywords: ['threads'],
|
||||
},
|
||||
{
|
||||
icon: InstagramIcon,
|
||||
keywords: ['instagram', 'ig', 'insta'],
|
||||
},
|
||||
{
|
||||
icon: KoFiIcon,
|
||||
keywords: ['ko-fi', 'kofi', 'ko fi'],
|
||||
},
|
||||
{
|
||||
icon: MastodonIcon,
|
||||
keywords: ['mastodon'],
|
||||
},
|
||||
{
|
||||
icon: OpenCollectiveIcon,
|
||||
keywords: ['opencollective', 'open collective'],
|
||||
},
|
||||
{
|
||||
icon: PatreonIcon,
|
||||
keywords: ['patreon'],
|
||||
},
|
||||
{
|
||||
icon: PayPalIcon,
|
||||
keywords: ['paypal', 'pay pal'],
|
||||
},
|
||||
{
|
||||
icon: RedditIcon,
|
||||
keywords: ['reddit'],
|
||||
},
|
||||
{
|
||||
icon: ReelsIcon,
|
||||
keywords: ['reels', 'instagram reels', 'facebook reels'],
|
||||
},
|
||||
{
|
||||
icon: SnapchatIcon,
|
||||
keywords: ['snapchat'],
|
||||
},
|
||||
{
|
||||
icon: TikTokIcon,
|
||||
keywords: ['tiktok', 'tik', 'tok'],
|
||||
},
|
||||
{
|
||||
icon: TumblrIcon,
|
||||
keywords: ['tumblr'],
|
||||
},
|
||||
{
|
||||
icon: TwitchIcon,
|
||||
keywords: ['twitch', 'twitch.tv'],
|
||||
},
|
||||
{
|
||||
icon: WindowsIcon,
|
||||
keywords: ['windows', 'microsoft'],
|
||||
},
|
||||
{
|
||||
icon: YouTubeIcon,
|
||||
keywords: ['youtube', 'yt'],
|
||||
},
|
||||
{
|
||||
icon: YouTubeShortsIcon,
|
||||
keywords: ['shorts', 'youtube shorts'],
|
||||
},
|
||||
{
|
||||
icon: YouTubeGaming,
|
||||
keywords: ['youtube gaming'],
|
||||
},
|
||||
{
|
||||
icon: CurseForgeIcon,
|
||||
keywords: ['curseforge', 'cf', 'curse', 'curse forge'],
|
||||
},
|
||||
{
|
||||
icon: ModrinthIcon,
|
||||
keywords: ['modrinth', 'mod rinth', 'modrith', 'mr'],
|
||||
},
|
||||
{
|
||||
icon: TwitterIcon,
|
||||
keywords: ['twitter', 'x.com', 'x'],
|
||||
},
|
||||
]
|
||||
|
||||
const selectedService = computed(() =>
|
||||
services.find((service) =>
|
||||
service.keywords.some((keyword) => props.keyword.toLowerCase().includes(keyword)),
|
||||
),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="selectedService?.icon" v-if="selectedService" />
|
||||
<slot v-else />
|
||||
</template>
|
||||
50
packages/ui/src/components/base/AutoLink.vue
Normal file
50
packages/ui/src/components/base/AutoLink.vue
Normal file
@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<router-link
|
||||
v-if="
|
||||
(typeof to === 'object' && (to?.path || to?.query)) ||
|
||||
(typeof to === 'string' && to?.startsWith('/'))
|
||||
"
|
||||
:to="to"
|
||||
v-bind="$attrs"
|
||||
:class="linkClass"
|
||||
>
|
||||
<slot />
|
||||
</router-link>
|
||||
<a
|
||||
v-else-if="typeof to === 'string' && to?.startsWith('http')"
|
||||
:href="to"
|
||||
v-bind="$attrs"
|
||||
:class="linkClass"
|
||||
>
|
||||
<slot />
|
||||
</a>
|
||||
<button
|
||||
v-else-if="typeof to === 'function'"
|
||||
v-bind="$attrs"
|
||||
class="inline bg-transparent border-none p-0 m-0 cursor-pointer"
|
||||
:class="linkClass"
|
||||
@click="to()"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
<span v-else v-bind="$attrs">
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
to: any
|
||||
linkClass?: string
|
||||
}>(),
|
||||
{
|
||||
linkClass: '',
|
||||
},
|
||||
)
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
</script>
|
||||
194
packages/ui/src/components/base/Avatar.vue
Normal file
194
packages/ui/src/components/base/Avatar.vue
Normal file
@ -0,0 +1,194 @@
|
||||
<template>
|
||||
<img
|
||||
v-if="src && !failed"
|
||||
ref="img"
|
||||
class="avatar shrink-0"
|
||||
:style="`--_size: ${cssSize}`"
|
||||
:class="{
|
||||
circle: circle,
|
||||
'no-shadow': noShadow,
|
||||
raised: raised,
|
||||
pixelated: pixelated || autoPixelated,
|
||||
unframed: unframed || autoUnframed,
|
||||
}"
|
||||
:src="src"
|
||||
:alt="alt"
|
||||
:loading="loading"
|
||||
@load="updatePixelated"
|
||||
@error="onError"
|
||||
/>
|
||||
<svg
|
||||
v-else
|
||||
class="avatar shrink-0"
|
||||
:style="`--_size: ${cssSize}${tint ? `;--_tint:oklch(50% 75% ${tint})` : ''}`"
|
||||
:class="{
|
||||
tint: tint,
|
||||
circle: circle,
|
||||
'no-shadow': noShadow,
|
||||
raised: raised,
|
||||
unframed: unframed || autoUnframed,
|
||||
}"
|
||||
xml:space="preserve"
|
||||
fill-rule="evenodd"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="1.5"
|
||||
clip-rule="evenodd"
|
||||
viewBox="0 0 104 104"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path fill="none" d="M0 0h103.4v103.4H0z" />
|
||||
<path
|
||||
fill="none"
|
||||
stroke="#9a9a9a"
|
||||
stroke-width="5"
|
||||
d="M51.7 92.5V51.7L16.4 31.3l35.3 20.4L87 31.3 51.7 11 16.4 31.3v40.8l35.3 20.4L87 72V31.3L51.7 11"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, useTemplateRef, watch } from 'vue'
|
||||
|
||||
const autoPixelated = ref(false)
|
||||
const autoUnframed = ref(false)
|
||||
const img = useTemplateRef<HTMLImageElement>('img')
|
||||
const failed = ref(false)
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
src?: string | null
|
||||
alt?: string
|
||||
size?: string
|
||||
circle?: boolean
|
||||
noShadow?: boolean
|
||||
loading?: 'eager' | 'lazy'
|
||||
raised?: boolean
|
||||
tintBy?: string | null
|
||||
pixelated?: boolean
|
||||
unframed?: boolean
|
||||
unframedNaturalWidth?: number
|
||||
}>(),
|
||||
{
|
||||
src: null,
|
||||
alt: '',
|
||||
size: '2rem',
|
||||
circle: false,
|
||||
noShadow: false,
|
||||
loading: 'eager',
|
||||
raised: false,
|
||||
tintBy: null,
|
||||
pixelated: false,
|
||||
unframed: false,
|
||||
unframedNaturalWidth: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const LEGACY_PRESETS: Record<string, string> = {
|
||||
xxs: '1.25rem',
|
||||
xs: '2.5rem',
|
||||
sm: '3rem',
|
||||
md: '6rem',
|
||||
lg: '9rem',
|
||||
}
|
||||
|
||||
const cssSize = computed(() => LEGACY_PRESETS[props.size] ?? props.size)
|
||||
|
||||
watch(
|
||||
() => props.src,
|
||||
() => {
|
||||
failed.value = false
|
||||
autoUnframed.value = false
|
||||
},
|
||||
)
|
||||
|
||||
function onError(e) {
|
||||
console.log('Avatar image failed to load:', props.src, e)
|
||||
failed.value = true
|
||||
}
|
||||
|
||||
function updatePixelated() {
|
||||
autoUnframed.value = Boolean(
|
||||
img.value &&
|
||||
props.unframedNaturalWidth !== undefined &&
|
||||
img.value.naturalWidth === props.unframedNaturalWidth,
|
||||
)
|
||||
|
||||
if (img.value && img.value.naturalWidth && img.value.naturalWidth < 32) {
|
||||
autoPixelated.value = true
|
||||
} else {
|
||||
autoPixelated.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const tint = computed(() => {
|
||||
if (props.tintBy) {
|
||||
return hash(props.tintBy) % 360
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
function hash(str: string): number {
|
||||
let hash = 0
|
||||
for (let i = 0, len = str.length; i < len; i++) {
|
||||
const chr = str.charCodeAt(i)
|
||||
hash = (hash << 5) - hash + chr
|
||||
hash |= 0
|
||||
}
|
||||
return hash
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.avatar {
|
||||
--_size: 2rem;
|
||||
|
||||
border: 1px solid var(--surface-5);
|
||||
background-color: var(--color-button-bg);
|
||||
object-fit: contain;
|
||||
border-radius: calc(16 / 96 * var(--_override-size, var(--_size)));
|
||||
position: relative;
|
||||
height: var(--_override-size, var(--_size));
|
||||
width: var(--_override-size, var(--_size));
|
||||
min-height: var(--_override-size, var(--_size));
|
||||
min-width: var(--_override-size, var(--_size));
|
||||
|
||||
&.circle {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&:not(.no-shadow) {
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
&.no-shadow {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
&.pixelated {
|
||||
image-rendering: pixelated;
|
||||
backface-visibility: hidden;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
&.unframed {
|
||||
border-color: transparent;
|
||||
background-color: transparent;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
|
||||
&:not(.no-shadow) {
|
||||
filter: drop-shadow(var(--shadow-card-filter));
|
||||
}
|
||||
}
|
||||
|
||||
&.raised {
|
||||
background-color: var(--color-raised-bg);
|
||||
}
|
||||
|
||||
&.tint {
|
||||
background-color: color-mix(in oklch, var(--color-button-bg) 100%, var(--_tint) 5%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
268
packages/ui/src/components/base/Badge.vue
Normal file
268
packages/ui/src/components/base/Badge.vue
Normal file
@ -0,0 +1,268 @@
|
||||
<template>
|
||||
<span :class="'version-badge ' + color + ' type--' + type">
|
||||
<template v-if="color"> <span class="circle" /> {{ capitalizeString(type) }}</template>
|
||||
|
||||
<!-- User roles -->
|
||||
<template v-else-if="type === 'admin'">
|
||||
<ModrinthIcon aria-hidden="true" /> {{ formatMessage(messages.modrinthTeamLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'moderator'">
|
||||
<ScaleIcon aria-hidden="true" /> {{ formatMessage(messages.moderatorLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'creator'">
|
||||
<BoxIcon aria-hidden="true" /> {{ formatMessage(messages.creatorLabel) }}
|
||||
</template>
|
||||
|
||||
<!-- Project statuses -->
|
||||
<template v-else-if="type === 'approved'">
|
||||
<GlobeIcon aria-hidden="true" /> {{ formatMessage(messages.listedLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'approved-general'">
|
||||
<CheckIcon aria-hidden="true" /> {{ formatMessage(messages.approvedLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'unlisted'">
|
||||
<LinkIcon aria-hidden="true" /> {{ formatMessage(messages.unlistedLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'withheld'">
|
||||
<LinkIcon aria-hidden="true" /> {{ formatMessage(messages.withheldLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'private'">
|
||||
<LockIcon aria-hidden="true" /> {{ formatMessage(messages.privateLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'scheduled'">
|
||||
<CalendarIcon aria-hidden="true" /> {{ formatMessage(messages.scheduledLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'draft'">
|
||||
<FileTextIcon aria-hidden="true" /> {{ formatMessage(messages.draftLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'archived'">
|
||||
<ArchiveIcon aria-hidden="true" /> {{ formatMessage(messages.archivedLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'rejected'">
|
||||
<XIcon aria-hidden="true" /> {{ formatMessage(messages.rejectedLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'processing'">
|
||||
<UpdatedIcon aria-hidden="true" /> {{ formatMessage(messages.underReviewLabel) }}
|
||||
</template>
|
||||
|
||||
<!-- Team members -->
|
||||
<template v-else-if="type === 'accepted'">
|
||||
<CheckIcon aria-hidden="true" /> {{ formatMessage(messages.acceptedLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'pending'">
|
||||
<UpdatedIcon aria-hidden="true" /> {{ formatMessage(messages.pendingLabel) }}
|
||||
</template>
|
||||
|
||||
<!-- Transaction statuses (pending, processing reused) -->
|
||||
<template v-else-if="type === 'processed'">
|
||||
<CheckIcon aria-hidden="true" /> {{ formatMessage(messages.processedLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'failed'">
|
||||
<XIcon aria-hidden="true" /> {{ formatMessage(messages.failedLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'returned'">
|
||||
<XIcon aria-hidden="true" /> {{ formatMessage(messages.returnedLabel) }}
|
||||
</template>
|
||||
|
||||
<!-- Report status -->
|
||||
<template v-else-if="type === 'closed'">
|
||||
<XIcon aria-hidden="true" /> {{ formatMessage(messages.closedLabel) }}
|
||||
</template>
|
||||
|
||||
<!-- Technical review verdicts -->
|
||||
<template v-else-if="type === 'safe'">
|
||||
<ShieldCheckIcon aria-hidden="true" /> {{ formatMessage(messages.safeLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'unsafe'">
|
||||
<BugIcon aria-hidden="true" /> {{ formatMessage(messages.unsafeLabel) }}
|
||||
</template>
|
||||
|
||||
<!-- Other -->
|
||||
<template v-else> <span class="circle" /> {{ capitalizeString(type) }} </template>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ArchiveIcon,
|
||||
BoxIcon,
|
||||
BugIcon,
|
||||
CalendarIcon,
|
||||
CheckIcon,
|
||||
FileTextIcon,
|
||||
GlobeIcon,
|
||||
LinkIcon,
|
||||
LockIcon,
|
||||
ModrinthIcon,
|
||||
ScaleIcon,
|
||||
ShieldCheckIcon,
|
||||
UpdatedIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { capitalizeString } from '@modrinth/utils'
|
||||
|
||||
import { defineMessages, useVIntl } from '../../composables/i18n'
|
||||
|
||||
const messages = defineMessages({
|
||||
acceptedLabel: {
|
||||
id: 'omorphia.component.badge.label.accepted',
|
||||
defaultMessage: 'Accepted',
|
||||
},
|
||||
approvedLabel: {
|
||||
id: 'omorphia.component.badge.label.approved',
|
||||
defaultMessage: 'Approved',
|
||||
},
|
||||
archivedLabel: {
|
||||
id: 'omorphia.component.badge.label.archived',
|
||||
defaultMessage: 'Archived',
|
||||
},
|
||||
closedLabel: {
|
||||
id: 'omorphia.component.badge.label.closed',
|
||||
defaultMessage: 'Closed',
|
||||
},
|
||||
creatorLabel: {
|
||||
id: 'omorphia.component.badge.label.creator',
|
||||
defaultMessage: 'Creator',
|
||||
},
|
||||
draftLabel: {
|
||||
id: 'omorphia.component.badge.label.draft',
|
||||
defaultMessage: 'Draft',
|
||||
},
|
||||
failedLabel: {
|
||||
id: 'omorphia.component.badge.label.failed',
|
||||
defaultMessage: 'Failed',
|
||||
},
|
||||
listedLabel: {
|
||||
id: 'omorphia.component.badge.label.listed',
|
||||
defaultMessage: 'Public',
|
||||
},
|
||||
moderatorLabel: {
|
||||
id: 'omorphia.component.badge.label.moderator',
|
||||
defaultMessage: 'Moderator',
|
||||
},
|
||||
modrinthTeamLabel: {
|
||||
id: 'omorphia.component.badge.label.modrinth-team',
|
||||
defaultMessage: 'Modrinth Team',
|
||||
},
|
||||
pendingLabel: {
|
||||
id: 'omorphia.component.badge.label.pending',
|
||||
defaultMessage: 'Pending',
|
||||
},
|
||||
privateLabel: {
|
||||
id: 'omorphia.component.badge.label.private',
|
||||
defaultMessage: 'Private',
|
||||
},
|
||||
processedLabel: {
|
||||
id: 'omorphia.component.badge.label.processed',
|
||||
defaultMessage: 'Processed',
|
||||
},
|
||||
rejectedLabel: {
|
||||
id: 'omorphia.component.badge.label.rejected',
|
||||
defaultMessage: 'Rejected',
|
||||
},
|
||||
returnedLabel: {
|
||||
id: 'omorphia.component.badge.label.returned',
|
||||
defaultMessage: 'Returned',
|
||||
},
|
||||
safeLabel: {
|
||||
id: 'omorphia.component.badge.label.safe',
|
||||
defaultMessage: 'Pass',
|
||||
},
|
||||
scheduledLabel: {
|
||||
id: 'omorphia.component.badge.label.scheduled',
|
||||
defaultMessage: 'Scheduled',
|
||||
},
|
||||
underReviewLabel: {
|
||||
id: 'omorphia.component.badge.label.under-review',
|
||||
defaultMessage: 'Under review',
|
||||
},
|
||||
unlistedLabel: {
|
||||
id: 'omorphia.component.badge.label.unlisted',
|
||||
defaultMessage: 'Unlisted',
|
||||
},
|
||||
unsafeLabel: {
|
||||
id: 'omorphia.component.badge.label.unsafe',
|
||||
defaultMessage: 'Fail',
|
||||
},
|
||||
withheldLabel: {
|
||||
id: 'omorphia.component.badge.label.withheld',
|
||||
defaultMessage: 'Unlisted by staff',
|
||||
},
|
||||
})
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
defineProps<{
|
||||
type: string
|
||||
color?: string
|
||||
}>()
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.version-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: bold;
|
||||
width: fit-content;
|
||||
--badge-color: var(--color-gray);
|
||||
color: var(--badge-color);
|
||||
|
||||
.circle {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
margin-right: 0.25rem;
|
||||
background-color: var(--badge-color);
|
||||
}
|
||||
|
||||
svg {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
&.type--closed,
|
||||
&.type--withheld,
|
||||
&.type--rejected,
|
||||
&.type--returned,
|
||||
&.type--failed,
|
||||
&.type--unsafe,
|
||||
&.red {
|
||||
--badge-color: var(--color-red);
|
||||
}
|
||||
|
||||
&.type--pending,
|
||||
&.type--moderator,
|
||||
&.type--processing,
|
||||
&.type--scheduled,
|
||||
&.orange {
|
||||
--badge-color: var(--color-orange);
|
||||
}
|
||||
|
||||
&.type--accepted,
|
||||
&.type--admin,
|
||||
&.type--processed,
|
||||
&.type--approved-general,
|
||||
&.type--safe,
|
||||
&.green {
|
||||
--badge-color: var(--color-green);
|
||||
}
|
||||
|
||||
&.type--creator,
|
||||
&.type--approved,
|
||||
&.blue {
|
||||
--badge-color: var(--color-blue);
|
||||
}
|
||||
|
||||
&.type--unlisted,
|
||||
&.purple {
|
||||
--badge-color: var(--color-purple);
|
||||
}
|
||||
|
||||
&.type--private,
|
||||
&.gray {
|
||||
--badge-color: var(--color-gray);
|
||||
}
|
||||
|
||||
&::first-letter {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
307
packages/ui/src/components/base/BaseTerminal.vue
Normal file
307
packages/ui/src/components/base/BaseTerminal.vue
Normal file
@ -0,0 +1,307 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex h-full w-full flex-col bg-surface-2 overflow-hidden rounded-[20px] border border-solid border-surface-4"
|
||||
>
|
||||
<div ref="wrapperRef" class="relative min-h-0 flex-1 overflow-hidden pb-2 pt-1">
|
||||
<div ref="containerRef" class="size-full" />
|
||||
<Transition name="terminal-loading-fade">
|
||||
<div
|
||||
v-if="loading"
|
||||
class="pointer-events-none absolute inset-0 z-20 animate-bpulse bg-surface-3"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Transition>
|
||||
<div v-if="!isAtBottom" class="absolute bottom-4 right-4 z-10">
|
||||
<ButtonStyled circular type="highlight" size="large">
|
||||
<button class="!shadow-2xl" aria-label="Scroll to bottom" @click="scrollToBottom">
|
||||
<ChevronDownIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="showInput"
|
||||
ref="inputRef"
|
||||
class="border-t border-solid border-b-0 border-x-0 border-surface-4 bg-surface-3 p-4"
|
||||
>
|
||||
<StyledInput
|
||||
v-model="commandInput"
|
||||
v-tooltip="disableInput ? disableInputTooltip : undefined"
|
||||
:icon="TerminalSquareIcon"
|
||||
:placeholder="disableInput ? disabledInputPlaceholder : 'Send a command'"
|
||||
:disabled="disableInput"
|
||||
wrapper-class="w-full"
|
||||
input-class="!h-10"
|
||||
@keydown.enter="submitCommand"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChevronDownIcon, TerminalSquareIcon } from '@modrinth/assets'
|
||||
import type { Terminal } from '@xterm/xterm'
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import StyledInput from '#ui/components/base/StyledInput.vue'
|
||||
import { useTerminal } from '#ui/composables/terminal'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
scrollback?: number
|
||||
showInput?: boolean
|
||||
disableInput?: boolean
|
||||
disableInputTooltip?: string
|
||||
disabledInputPlaceholder?: string
|
||||
fullscreen?: boolean
|
||||
emptyStateType?: 'server' | 'instance'
|
||||
loading?: boolean
|
||||
}>(),
|
||||
{
|
||||
scrollback: Infinity,
|
||||
showInput: false,
|
||||
disableInput: false,
|
||||
disableInputTooltip: undefined,
|
||||
disabledInputPlaceholder: 'Server is not running',
|
||||
fullscreen: false,
|
||||
emptyStateType: undefined,
|
||||
loading: false,
|
||||
},
|
||||
)
|
||||
|
||||
const EMPTY_STATE_BUBBLES: Record<string, string[]> = {
|
||||
server: [
|
||||
' ______________________________________________________',
|
||||
' / 欢迎使用 Axolotl 服务器! \\',
|
||||
'| 点击启动按钮即可启动服务器! |',
|
||||
' \\______________________________________________________/',
|
||||
],
|
||||
instance: ['请点击右上角 开始游戏 按钮', '即可接收实时日志'],
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
command: [command: string]
|
||||
ready: [terminal: Terminal]
|
||||
}>()
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const wrapperRef = ref<HTMLElement | null>(null)
|
||||
const inputRef = ref<HTMLElement | null>(null)
|
||||
const commandInput = ref('')
|
||||
|
||||
const snappedHeight = ref<number | null>(null)
|
||||
|
||||
const showingEmptyState = ref(false)
|
||||
|
||||
const {
|
||||
terminal,
|
||||
searchAddon,
|
||||
isAtBottom,
|
||||
write,
|
||||
writeln,
|
||||
clear,
|
||||
reset,
|
||||
fit: rawFit,
|
||||
scrollToBottom,
|
||||
} = useTerminal({
|
||||
container: containerRef,
|
||||
scrollback: props.scrollback,
|
||||
onReady: (term) => {
|
||||
nextTick(() => {
|
||||
snapToRows()
|
||||
})
|
||||
emit('ready', term)
|
||||
},
|
||||
})
|
||||
|
||||
function writeEmptyState() {
|
||||
if (!terminal.value || !props.emptyStateType) return
|
||||
terminal.value.reset()
|
||||
const bubble = EMPTY_STATE_BUBBLES[props.emptyStateType]
|
||||
if (bubble) {
|
||||
for (const line of bubble) {
|
||||
terminal.value.writeln(line)
|
||||
}
|
||||
}
|
||||
showingEmptyState.value = true
|
||||
}
|
||||
|
||||
function clearEmptyState() {
|
||||
if (!showingEmptyState.value) return
|
||||
terminal.value?.reset()
|
||||
showingEmptyState.value = false
|
||||
}
|
||||
|
||||
function getWrapperMargins() {
|
||||
if (!wrapperRef.value) return 0
|
||||
const style = getComputedStyle(wrapperRef.value)
|
||||
return parseFloat(style.marginTop) + parseFloat(style.marginBottom)
|
||||
}
|
||||
|
||||
function snapToRows() {
|
||||
if (!props.fullscreen) {
|
||||
snappedHeight.value = null
|
||||
return
|
||||
}
|
||||
const screen = containerRef.value?.querySelector('.xterm-screen') as HTMLElement | null
|
||||
if (!screen) {
|
||||
snappedHeight.value = null
|
||||
return
|
||||
}
|
||||
const inputH = inputRef.value?.offsetHeight ?? 0
|
||||
const borderW = 2
|
||||
snappedHeight.value = screen.offsetHeight + getWrapperMargins() + inputH + borderW
|
||||
}
|
||||
|
||||
let resizeDebounce: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function handleWindowResize() {
|
||||
if (!props.fullscreen) return
|
||||
if (resizeDebounce) clearTimeout(resizeDebounce)
|
||||
snappedHeight.value = null
|
||||
resizeDebounce = setTimeout(() => {
|
||||
rawFit()
|
||||
nextTick(() => snapToRows())
|
||||
}, 50)
|
||||
}
|
||||
|
||||
function handleDocumentPointerDown(event: PointerEvent) {
|
||||
if (!terminal.value?.hasSelection()) return
|
||||
const target = event.target as Node | null
|
||||
if (target && containerRef.value?.contains(target)) return
|
||||
terminal.value.clearSelection()
|
||||
}
|
||||
|
||||
function handleDocumentKeyDown(event: KeyboardEvent) {
|
||||
if (!event.metaKey || event.key.toLowerCase() !== 'a') return
|
||||
const target = event.target as Node | null
|
||||
const active = document.activeElement
|
||||
if (
|
||||
!(target && containerRef.value?.contains(target)) &&
|
||||
!(active && containerRef.value?.contains(active))
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
terminal.value?.selectAll()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', handleWindowResize)
|
||||
document.addEventListener('pointerdown', handleDocumentPointerDown)
|
||||
document.addEventListener('keydown', handleDocumentKeyDown, true)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', handleWindowResize)
|
||||
document.removeEventListener('pointerdown', handleDocumentPointerDown)
|
||||
document.removeEventListener('keydown', handleDocumentKeyDown, true)
|
||||
if (resizeDebounce) clearTimeout(resizeDebounce)
|
||||
})
|
||||
|
||||
function fit() {
|
||||
rawFit()
|
||||
snapToRows()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.fullscreen,
|
||||
() => {
|
||||
if (props.fullscreen) {
|
||||
nextTick(() => {
|
||||
rawFit()
|
||||
nextTick(() => snapToRows())
|
||||
})
|
||||
} else {
|
||||
snappedHeight.value = null
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const submitCommand = () => {
|
||||
if (props.disableInput) return
|
||||
const cmd = commandInput.value.trim()
|
||||
if (!cmd) return
|
||||
emit('command', cmd)
|
||||
commandInput.value = ''
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
write,
|
||||
writeln,
|
||||
clear,
|
||||
reset,
|
||||
fit,
|
||||
scrollToBottom,
|
||||
terminal,
|
||||
searchAddon,
|
||||
isAtBottom,
|
||||
commandInput,
|
||||
showingEmptyState,
|
||||
writeEmptyState,
|
||||
clearEmptyState,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@keyframes bpulse {
|
||||
50% {
|
||||
filter: brightness(75%);
|
||||
}
|
||||
}
|
||||
.animate-bpulse {
|
||||
animation: bpulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
.xterm {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.xterm-viewport {
|
||||
background-color: var(--surface-2) !important;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen {
|
||||
width: 100%;
|
||||
margin-left: 8px;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.xterm .xterm-rows {
|
||||
position: relative;
|
||||
z-index: 7;
|
||||
}
|
||||
|
||||
.xterm .xterm-decoration-container {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.xterm .xterm-decoration-container > div {
|
||||
box-sizing: content-box !important;
|
||||
margin-left: -12px !important;
|
||||
padding-left: 12px !important;
|
||||
padding-right: 12px !important;
|
||||
}
|
||||
|
||||
.xterm-scrollable-element > .scrollbar.vertical {
|
||||
width: 8px !important;
|
||||
}
|
||||
|
||||
.xterm-scrollable-element > .scrollbar.vertical > div {
|
||||
width: 6px !important;
|
||||
border-radius: 8px !important;
|
||||
contain: layout style !important;
|
||||
}
|
||||
|
||||
.terminal-loading-fade-enter-active,
|
||||
.terminal-loading-fade-leave-active {
|
||||
transition: opacity 250ms ease-in-out;
|
||||
}
|
||||
|
||||
.terminal-loading-fade-enter-from,
|
||||
.terminal-loading-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
58
packages/ui/src/components/base/BigOptionButton.vue
Normal file
58
packages/ui/src/components/base/BigOptionButton.vue
Normal file
@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<button
|
||||
class="group flex w-full hover:cursor-pointer gap-3 rounded-[20px] p-3 text-left transition-all hover:brightness-110 active:scale-[0.98] border-none"
|
||||
:class="['items-center', selected ? 'bg-brand-highlight' : 'bg-surface-4']"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<div
|
||||
v-if="!noIconBox"
|
||||
class="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl"
|
||||
:class="[
|
||||
noIconBorder ? '' : 'border border-solid',
|
||||
noIconBorder ? '' : selected ? 'border-brand' : 'border-surface-5',
|
||||
]"
|
||||
>
|
||||
<component
|
||||
:is="icon"
|
||||
class="size-8 text-secondary"
|
||||
:class="selected ? '!stroke-brand' : ''"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex size-12 shrink-0 items-center justify-center">
|
||||
<component
|
||||
:is="icon"
|
||||
class="size-7 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-left text-sm font-medium text-primary">{{ description }}</span>
|
||||
<span v-if="note" class="text-left text-xs text-tertiary">{{ note }}</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
|
||||
note?: string
|
||||
selected?: boolean
|
||||
noIconBox?: boolean
|
||||
noIconBorder?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'click'): void
|
||||
}>()
|
||||
</script>
|
||||
5
packages/ui/src/components/base/BulletDivider.vue
Normal file
5
packages/ui/src/components/base/BulletDivider.vue
Normal file
@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div
|
||||
class="min-w-1.5 min-h-1.5 max-h-1.5 max-w-1.5 mx-0.5 rounded-full bg-surface-5 inline-block my-auto align-middle"
|
||||
></div>
|
||||
</template>
|
||||
143
packages/ui/src/components/base/Button.vue
Normal file
143
packages/ui/src/components/base/Button.vue
Normal file
@ -0,0 +1,143 @@
|
||||
<script setup>
|
||||
import ExternalIcon from '@modrinth/assets/icons/external.svg?component'
|
||||
import UnknownIcon from '@modrinth/assets/icons/unknown.svg?component'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
link: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
external: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
download: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
action: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
},
|
||||
iconOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
large: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
outline: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
transparent: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hoverFilled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hoverFilledOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const accentedButton = computed(() =>
|
||||
['danger', 'primary', 'red', 'orange', 'green', 'blue', 'purple', 'gray'].includes(props.color),
|
||||
)
|
||||
|
||||
const classes = computed(() => {
|
||||
const color = props.color
|
||||
return {
|
||||
'icon-only': props.iconOnly,
|
||||
'btn-large': props.large,
|
||||
'btn-danger': color === 'danger',
|
||||
'btn-primary': color === 'primary',
|
||||
'btn-secondary': color === 'secondary',
|
||||
'btn-highlight': color === 'highlight',
|
||||
'btn-red': color === 'red',
|
||||
'btn-orange': color === 'orange',
|
||||
'btn-green': color === 'green',
|
||||
'btn-blue': color === 'blue',
|
||||
'btn-purple': color === 'purple',
|
||||
'btn-gray': color === 'gray',
|
||||
'btn-transparent': props.transparent,
|
||||
'btn-hover-filled': props.hoverFilled,
|
||||
'btn-hover-filled-only': props.hoverFilledOnly,
|
||||
'btn-outline': props.outline,
|
||||
'color-accent-contrast': accentedButton,
|
||||
disabled: props.disabled,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-link
|
||||
v-if="link && link.startsWith('/')"
|
||||
class="btn"
|
||||
:class="classes"
|
||||
:to="disabled ? '' : link"
|
||||
:target="external ? '_blank' : '_self'"
|
||||
@click="
|
||||
(event) => {
|
||||
if (disabled) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (action) {
|
||||
action(event)
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
<ExternalIcon v-if="external && !iconOnly" class="external-icon" />
|
||||
<UnknownIcon v-if="!$slots.default" />
|
||||
</router-link>
|
||||
<a
|
||||
v-else-if="link"
|
||||
class="btn"
|
||||
:class="classes"
|
||||
:href="disabled ? undefined : link"
|
||||
:download="download || undefined"
|
||||
:target="external ? '_blank' : '_self'"
|
||||
@click="
|
||||
(event) => {
|
||||
if (disabled) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (action) {
|
||||
action(event)
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
<ExternalIcon v-if="external && !iconOnly" class="external-icon" />
|
||||
<UnknownIcon v-if="!$slots.default" />
|
||||
</a>
|
||||
<button v-else class="btn" :class="classes" :disabled="disabled" @click="action">
|
||||
<slot />
|
||||
<UnknownIcon v-if="!$slots.default" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:where(button) {
|
||||
background: none;
|
||||
color: var(--color-base);
|
||||
}
|
||||
</style>
|
||||
393
packages/ui/src/components/base/ButtonStyled.vue
Normal file
393
packages/ui/src/components/base/ButtonStyled.vue
Normal file
@ -0,0 +1,393 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
color?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple' | 'medal-promo'
|
||||
size?: 'standard' | 'large' | 'small'
|
||||
circular?: boolean
|
||||
type?: 'standard' | 'outlined' | 'transparent' | 'highlight' | 'highlight-colored-text' | 'chip'
|
||||
colorFill?: 'auto' | 'background' | 'text' | 'none'
|
||||
hoverColorFill?: 'auto' | 'background' | 'text' | 'none'
|
||||
highlightedStyle?: 'main-nav-primary' | 'main-nav-secondary'
|
||||
highlighted?: boolean
|
||||
}>(),
|
||||
{
|
||||
color: 'standard',
|
||||
size: 'standard',
|
||||
circular: false,
|
||||
type: 'standard',
|
||||
colorFill: 'auto',
|
||||
hoverColorFill: 'auto',
|
||||
highlightedStyle: 'main-nav-primary',
|
||||
highlighted: false,
|
||||
},
|
||||
)
|
||||
|
||||
const highlightedColorVar = computed(() => {
|
||||
switch (props.color) {
|
||||
case 'brand':
|
||||
return 'var(--color-brand-highlight)'
|
||||
case 'red':
|
||||
return 'var(--color-red-highlight)'
|
||||
case 'orange':
|
||||
return 'var(--color-orange-highlight)'
|
||||
case 'green':
|
||||
return 'var(--color-green-highlight)'
|
||||
case 'medal-promo':
|
||||
case 'blue':
|
||||
return 'var(--color-blue-highlight)'
|
||||
case 'purple':
|
||||
return 'var(--color-purple-highlight)'
|
||||
case 'standard':
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const colorVar = computed(() => {
|
||||
switch (props.color) {
|
||||
case 'brand':
|
||||
return 'var(--color-brand)'
|
||||
case 'red':
|
||||
return 'var(--color-red)'
|
||||
case 'orange':
|
||||
return 'var(--color-orange)'
|
||||
case 'green':
|
||||
return 'var(--color-green)'
|
||||
case 'blue':
|
||||
return 'var(--color-blue)'
|
||||
case 'purple':
|
||||
return 'var(--color-purple)'
|
||||
case 'medal-promo':
|
||||
return 'var(--medal-promotion-text-orange)'
|
||||
case 'standard':
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const height = computed(() => {
|
||||
if (props.size === 'large') {
|
||||
return '3rem'
|
||||
} else if (props.size === 'small') {
|
||||
return '1.5rem'
|
||||
}
|
||||
return '2.25rem'
|
||||
})
|
||||
|
||||
const width = computed(() => {
|
||||
if (props.size === 'large') {
|
||||
return props.circular ? '3rem' : 'auto'
|
||||
} else if (props.size === 'small') {
|
||||
return props.circular ? '1.5rem' : 'auto'
|
||||
}
|
||||
return props.circular ? '2.25rem' : 'auto'
|
||||
})
|
||||
|
||||
const paddingX = computed(() => {
|
||||
let padding = props.circular ? '0.5rem' : '0.75rem'
|
||||
if (props.size === 'large') {
|
||||
padding = props.circular ? '0.75rem' : '1rem'
|
||||
} else if (props.size === 'small') {
|
||||
padding = props.circular ? '0.125rem' : '0.5rem'
|
||||
}
|
||||
return `calc(${padding} - 0.125rem)`
|
||||
})
|
||||
|
||||
const paddingY = computed(() => {
|
||||
if (props.size === 'large') {
|
||||
return '0.75rem'
|
||||
}
|
||||
return '0.5rem'
|
||||
})
|
||||
|
||||
const gap = computed(() => {
|
||||
if (props.size === 'large') {
|
||||
return '0.5rem'
|
||||
} else if (props.size === 'small') {
|
||||
return '0.25rem'
|
||||
}
|
||||
return '0.375rem'
|
||||
})
|
||||
|
||||
const fontWeight = computed(() => {
|
||||
if (props.size === 'large') {
|
||||
return '800'
|
||||
}
|
||||
return '600'
|
||||
})
|
||||
|
||||
const radius = computed(() => {
|
||||
if (props.circular) {
|
||||
return '99999px'
|
||||
}
|
||||
|
||||
if (props.size === 'large') {
|
||||
return '1rem'
|
||||
} else if (props.size === 'small') {
|
||||
return '0.5rem'
|
||||
}
|
||||
return '0.75rem'
|
||||
})
|
||||
|
||||
const iconSize = computed(() => {
|
||||
if (props.size === 'large') {
|
||||
return '1.5rem'
|
||||
} else if (props.size === 'small') {
|
||||
return '1rem'
|
||||
}
|
||||
return '1.25rem'
|
||||
})
|
||||
|
||||
function setColorFill(
|
||||
colors: { bg: string; text: string },
|
||||
fill: 'background' | 'text' | 'none',
|
||||
): { bg: string; text: string } {
|
||||
if (colorVar.value) {
|
||||
if (fill === 'background') {
|
||||
if (props.type === 'highlight' && highlightedColorVar.value) {
|
||||
colors.bg = highlightedColorVar.value
|
||||
colors.text = 'var(--color-contrast)'
|
||||
} else if (props.type === 'highlight-colored-text' && highlightedColorVar.value) {
|
||||
colors.bg = highlightedColorVar.value
|
||||
colors.text = colorVar.value
|
||||
} else {
|
||||
colors.bg = colorVar.value
|
||||
colors.text = 'var(--color-accent-contrast)'
|
||||
}
|
||||
} else if (fill === 'text') {
|
||||
colors.text = colorVar.value
|
||||
}
|
||||
}
|
||||
return colors
|
||||
}
|
||||
|
||||
const colorVariables = computed(() => {
|
||||
const defaultShadow =
|
||||
props.type === 'standard' || props.type === 'highlight' || props.highlighted
|
||||
? 'var(--shadow-button)'
|
||||
: 'none'
|
||||
|
||||
if (props.highlighted) {
|
||||
const colors = {
|
||||
bg:
|
||||
props.highlightedStyle === 'main-nav-primary'
|
||||
? 'var(--color-button-bg-selected)'
|
||||
: 'var(--color-button-bg)',
|
||||
text:
|
||||
props.highlightedStyle === 'main-nav-primary'
|
||||
? 'var(--color-button-text-selected)'
|
||||
: 'var(--color-contrast)',
|
||||
icon:
|
||||
props.type === 'chip'
|
||||
? 'var(--color-contrast)'
|
||||
: props.highlightedStyle === 'main-nav-primary'
|
||||
? 'var(--color-button-text-selected)'
|
||||
: 'var(--color-contrast)',
|
||||
}
|
||||
const hoverColors = JSON.parse(JSON.stringify(colors))
|
||||
const boxShadow =
|
||||
props.type === 'chip' && colorVar.value ? `0 0 0 1px ${colorVar.value}` : defaultShadow
|
||||
return `--_bg: ${colors.bg}; --_text: ${colors.text}; --_icon: ${colors.icon}; --_hover-bg: ${hoverColors.bg}; --_hover-text: ${hoverColors.text}; --_hover-icon: ${hoverColors.icon}; --_box-shadow: ${boxShadow};`
|
||||
}
|
||||
|
||||
let colors = {
|
||||
bg: 'var(--color-button-bg)',
|
||||
text: 'var(--color-base)',
|
||||
}
|
||||
let hoverColors = JSON.parse(JSON.stringify(colors))
|
||||
|
||||
if (props.type === 'outlined') {
|
||||
hoverColors.bg = 'transparent'
|
||||
}
|
||||
|
||||
if (props.type === 'outlined' || props.type === 'transparent') {
|
||||
colors.bg = 'transparent'
|
||||
colors = setColorFill(colors, props.colorFill === 'auto' ? 'text' : props.colorFill)
|
||||
hoverColors = setColorFill(
|
||||
hoverColors,
|
||||
props.hoverColorFill === 'auto' ? 'text' : props.hoverColorFill,
|
||||
)
|
||||
} else if (props.type === 'chip') {
|
||||
// Chip type uses highlight-colored-text styling when colored
|
||||
if (colorVar.value && highlightedColorVar.value) {
|
||||
colors.bg = highlightedColorVar.value
|
||||
colors.text = colorVar.value
|
||||
hoverColors.bg = highlightedColorVar.value
|
||||
hoverColors.text = colorVar.value
|
||||
}
|
||||
} else {
|
||||
colors = setColorFill(colors, props.colorFill === 'auto' ? 'background' : props.colorFill)
|
||||
hoverColors = setColorFill(
|
||||
hoverColors,
|
||||
props.hoverColorFill === 'auto' ? 'background' : props.hoverColorFill,
|
||||
)
|
||||
}
|
||||
|
||||
const boxShadow =
|
||||
props.type === 'chip' && colorVar.value ? `0 0 0 1px ${colorVar.value}` : defaultShadow
|
||||
return `--_bg: ${colors.bg}; --_text: ${colors.text}; --_hover-bg: ${hoverColors.bg}; --_hover-text: ${hoverColors.text}; --_box-shadow: ${boxShadow};`
|
||||
})
|
||||
|
||||
const fontSize = computed(() => {
|
||||
if (props.size === 'small') {
|
||||
return 'text-sm'
|
||||
}
|
||||
return 'text-base'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="btn-wrapper contents"
|
||||
:class="[
|
||||
{ outline: type === 'outlined', transparent: type === 'transparent', chip: type === 'chip' },
|
||||
fontSize,
|
||||
]"
|
||||
:style="`${colorVariables}--_height:${height};--_width:${width};--_radius: ${radius};--_padding-x:${paddingX};--_padding-y:${paddingY};--_gap:${gap};--_font-weight:${fontWeight};--_icon-size:${iconSize};--_outline-color:${color === 'standard' && type === 'outlined' ? 'var(--surface-5)' : 'currentColor'}`"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* Searches up to 4 children deep for valid button */
|
||||
.btn-wrapper :deep(:is(button, a, .button-like):first-child),
|
||||
.btn-wrapper :slotted(:is(button, a, .button-like):first-child),
|
||||
.btn-wrapper :slotted(*) > :is(button, a, .button-like):first-child,
|
||||
.btn-wrapper :slotted(*) > *:first-child > :is(button, a, .button-like):first-child,
|
||||
.btn-wrapper
|
||||
:slotted(*)
|
||||
> *:first-child
|
||||
> *:first-child
|
||||
> :is(button, a, .button-like):first-child {
|
||||
@apply flex touch-manipulation cursor-pointer flex-row items-center justify-center border-solid border border-transparent bg-[--_bg] text-[--_text] h-[--_height] min-w-[--_width] rounded-[--_radius] px-[--_padding-x] py-[--_padding-y] gap-[--_gap] font-[--_font-weight] whitespace-nowrap;
|
||||
box-shadow: var(--_box-shadow, inset 0 0 0 transparent);
|
||||
transition:
|
||||
scale 0.125s ease-in-out,
|
||||
background-color 0.25s ease-in-out,
|
||||
color 0.25s ease-in-out,
|
||||
filter 0.25s ease-in-out;
|
||||
|
||||
svg:first-child {
|
||||
color: var(--_icon, var(--_text));
|
||||
transition: color 0.25s ease-in-out;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&[disabled]:not([disabled='false']),
|
||||
&[disabled='true'],
|
||||
&.disabled,
|
||||
&.looks-disabled {
|
||||
@apply opacity-50;
|
||||
}
|
||||
|
||||
&[disabled]:not([disabled='false']),
|
||||
&[disabled='true'],
|
||||
&.disabled {
|
||||
@apply cursor-not-allowed;
|
||||
}
|
||||
|
||||
&:not([disabled]:not([disabled='false'])):not([disabled='true']):not(.disabled) {
|
||||
@apply hover:brightness-[--hover-brightness] focus-visible:brightness-[--hover-brightness] hover:bg-[--_hover-bg] hover:text-[--_hover-text] focus-visible:bg-[--_hover-bg] focus-visible:text-[--_hover-text];
|
||||
|
||||
&:hover svg:first-child,
|
||||
&:focus-visible svg:first-child {
|
||||
color: var(--_hover-icon, var(--_hover-text));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-wrapper:not(.chip) :deep(:is(button, a, .button-like):first-child),
|
||||
.btn-wrapper:not(.chip) :slotted(:is(button, a, .button-like):first-child),
|
||||
.btn-wrapper:not(.chip) :slotted(*) > :is(button, a, .button-like):first-child,
|
||||
.btn-wrapper:not(.chip) :slotted(*) > *:first-child > :is(button, a, .button-like):first-child,
|
||||
.btn-wrapper:not(.chip)
|
||||
:slotted(*)
|
||||
> *:first-child
|
||||
> *:first-child
|
||||
> :is(button, a, .button-like):first-child {
|
||||
&:not([disabled]:not([disabled='false'])):not([disabled='true']):not(.disabled) {
|
||||
@apply active:scale-95;
|
||||
}
|
||||
}
|
||||
|
||||
.disable-advanced-rendering {
|
||||
.btn-wrapper:not(.outline):not(.transparent) :deep(:is(button, a, .button-like):first-child),
|
||||
.btn-wrapper:not(.outline):not(.transparent) :slotted(:is(button, a, .button-like):first-child),
|
||||
.btn-wrapper:not(.outline):not(.transparent)
|
||||
:slotted(*)
|
||||
> :is(button, a, .button-like):first-child,
|
||||
.btn-wrapper:not(.outline):not(.transparent)
|
||||
:slotted(*)
|
||||
> *:first-child
|
||||
> :is(button, a, .button-like):first-child,
|
||||
.btn-wrapper
|
||||
:slotted(*)
|
||||
> *:first-child
|
||||
> *:first-child
|
||||
> :is(button, a, .button-like):first-child {
|
||||
@apply border border-[rgba(0,0,0,0.2)];
|
||||
}
|
||||
}
|
||||
|
||||
.btn-wrapper.outline :deep(:is(button, a, .button-like):first-child),
|
||||
.btn-wrapper.outline :slotted(:is(button, a, .button-like):first-child),
|
||||
.btn-wrapper.outline :slotted(*) > :is(button, a, .button-like):first-child,
|
||||
.btn-wrapper.outline :slotted(*) > *:first-child > :is(button, a, .button-like):first-child,
|
||||
.btn-wrapper.outline
|
||||
:slotted(*)
|
||||
> *:first-child
|
||||
> *:first-child
|
||||
> :is(button, a, .button-like):first-child {
|
||||
@apply border-[--_outline-color,currentColor];
|
||||
}
|
||||
|
||||
/*noinspection CssUnresolvedCustomProperty*/
|
||||
.btn-wrapper :deep(:is(button, a, .button-like):first-child) > svg,
|
||||
.btn-wrapper :slotted(:is(button, a, .button-like):first-child) > svg,
|
||||
.btn-wrapper :slotted(*) > :is(button, a, .button-like):first-child > svg,
|
||||
.btn-wrapper :slotted(*) > *:first-child > :is(button, a, .button-like):first-child > svg,
|
||||
.btn-wrapper
|
||||
:slotted(*)
|
||||
> *:first-child
|
||||
> *:first-child
|
||||
> :is(button, a, .button-like):first-child
|
||||
> svg {
|
||||
display: block;
|
||||
width: var(--_icon-size, 1rem);
|
||||
height: var(--_icon-size, 1rem);
|
||||
min-width: var(--_icon-size, 1rem);
|
||||
min-height: var(--_icon-size, 1rem);
|
||||
}
|
||||
|
||||
.joined-buttons {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
|
||||
> .btn-wrapper:not(:first-child) {
|
||||
:deep(:is(button, a, .button-like):first-child),
|
||||
:slotted(:is(button, a, .button-like):first-child),
|
||||
:slotted(*) > :is(button, a, .button-like):first-child,
|
||||
:slotted(*) > *:first-child > :is(button, a, .button-like):first-child,
|
||||
:slotted(*) > *:first-child > *:first-child > :is(button, a, .button-like):first-child {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
> :not(:last-child) {
|
||||
:deep(:is(button, a, .button-like):first-child),
|
||||
:slotted(:is(button, a, .button-like):first-child),
|
||||
:slotted(*) > :is(button, a, .button-like):first-child,
|
||||
:slotted(*) > *:first-child > :is(button, a, .button-like):first-child,
|
||||
:slotted(*) > *:first-child > *:first-child > :is(button, a, .button-like):first-child {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* guys, I know this is nuts, I know */
|
||||
</style>
|
||||
57
packages/ui/src/components/base/Card.vue
Normal file
57
packages/ui/src/components/base/Card.vue
Normal file
@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { DropdownIcon } from '@modrinth/assets'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
import ButtonStyled from './ButtonStyled.vue'
|
||||
|
||||
const props = defineProps({
|
||||
collapsible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
defaultCollapsed: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
noAutoBody: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const state = reactive({
|
||||
collapsed: props.defaultCollapsed,
|
||||
})
|
||||
|
||||
function toggleCollapsed() {
|
||||
state.collapsed = !state.collapsed
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<div v-if="!!$slots.header || collapsible" class="header flex">
|
||||
<slot name="header"></slot>
|
||||
<div v-if="collapsible" class="btn-group ml-auto">
|
||||
<ButtonStyled circular>
|
||||
<button @click="toggleCollapsed">
|
||||
<DropdownIcon :style="{ transform: `rotate(${state.collapsed ? 0 : 180}deg)` }" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<slot v-if="!state.collapsed" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.header {
|
||||
:deep(h1, h2, h3, h4) {
|
||||
margin-block: 0;
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: var(--gap-lg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
74
packages/ui/src/components/base/Checkbox.vue
Normal file
74
packages/ui/src/components/base/Checkbox.vue
Normal file
@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="group bg-transparent border-none p-0 m-0 flex items-center text-left gap-3 checkbox-outer outline-offset-4 text-contrast"
|
||||
:disabled="disabled"
|
||||
:class="
|
||||
disabled
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
: 'cursor-pointer hover:brightness-[--hover-brightness] focus-visible:brightness-[--hover-brightness]'
|
||||
"
|
||||
:aria-label="description || label || undefined"
|
||||
:aria-checked="indeterminate ? 'mixed' : modelValue"
|
||||
role="checkbox"
|
||||
@click="toggle"
|
||||
>
|
||||
<span
|
||||
class="w-5 h-5 aspect-square rounded-md flex shrink-0 items-center justify-center border-[1px] border-solid"
|
||||
:class="{
|
||||
'bg-brand border-button-border text-brand-inverted': modelValue,
|
||||
'bg-surface-2 border-divider-dark text-primary': !modelValue,
|
||||
'checkbox-shadow group-active:scale-95': !disabled,
|
||||
}"
|
||||
>
|
||||
<MinusIcon v-if="indeterminate" aria-hidden="true" stroke-width="3" />
|
||||
<CheckIcon v-else-if="modelValue" aria-hidden="true" stroke-width="3" />
|
||||
</span>
|
||||
<!-- aria-hidden is set so screenreaders only use the <button>'s aria-label -->
|
||||
<span v-if="label" :class="labelClass" aria-hidden="true">
|
||||
{{ label }}
|
||||
</span>
|
||||
<slot v-else />
|
||||
</button>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, MinusIcon } from '@modrinth/assets'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [modelValue: boolean, event?: MouseEvent]
|
||||
}>()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label?: string
|
||||
labelClass?: HTMLAttributes['class']
|
||||
disabled?: boolean
|
||||
description?: string
|
||||
modelValue: boolean
|
||||
clickEvent?: () => void
|
||||
indeterminate?: boolean
|
||||
}>(),
|
||||
{
|
||||
label: '',
|
||||
labelClass: '',
|
||||
disabled: false,
|
||||
description: '',
|
||||
modelValue: false,
|
||||
clickEvent: () => {},
|
||||
indeterminate: false,
|
||||
},
|
||||
)
|
||||
|
||||
function toggle(event: MouseEvent) {
|
||||
if (!props.disabled) {
|
||||
emit('update:modelValue', !props.modelValue, event)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.checkbox-shadow {
|
||||
box-shadow: 1px 1px 2px 0 rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
</style>
|
||||
101
packages/ui/src/components/base/Chips.vue
Normal file
101
packages/ui/src/components/base/Chips.vue
Normal file
@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div class="chips flex gap-2 flex-wrap" role="radiogroup" :aria-label="ariaLabel">
|
||||
<Button
|
||||
v-for="item in items"
|
||||
:key="formatLabel(item)"
|
||||
v-tooltip="isDisabled(item) ? getDisabledTooltip(item) : undefined"
|
||||
role="radio"
|
||||
:aria-checked="selected === item"
|
||||
:disabled="isDisabled(item)"
|
||||
class="btn !brightness-100 hover:!brightness-125"
|
||||
:class="{
|
||||
selected: selected === item,
|
||||
capitalize: capitalize,
|
||||
'!px-2.5 !py-1.5': size === 'small',
|
||||
}"
|
||||
@click="toggleItem(item)"
|
||||
>
|
||||
<CheckIcon v-if="selected === item && !hideCheckmarkIcon" />
|
||||
<span>{{ formatLabel(item) }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T">
|
||||
import { CheckIcon } from '@modrinth/assets'
|
||||
|
||||
import Button from './Button.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items: T[]
|
||||
formatLabel?: (item: T) => string
|
||||
neverEmpty?: boolean
|
||||
capitalize?: boolean
|
||||
size?: 'standard' | 'small'
|
||||
ariaLabel?: string
|
||||
disabledItems?: T[]
|
||||
disabledTooltip?: string | ((item: T) => string | undefined)
|
||||
hideCheckmarkIcon?: boolean
|
||||
}>(),
|
||||
{
|
||||
neverEmpty: true,
|
||||
// Intentional any type, as this default should only be used for primitives (string or number)
|
||||
formatLabel: (item) => item.toString(),
|
||||
capitalize: true,
|
||||
size: 'standard',
|
||||
},
|
||||
)
|
||||
|
||||
const selected = defineModel<T | null>()
|
||||
|
||||
// If one always has to be selected, default to the first one
|
||||
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 getDisabledTooltip(item: T): string | undefined {
|
||||
return typeof props.disabledTooltip === 'function'
|
||||
? props.disabledTooltip(item)
|
||||
: props.disabledTooltip
|
||||
}
|
||||
|
||||
function toggleItem(item: T) {
|
||||
if (isDisabled(item)) return
|
||||
if (selected.value === item && !props.neverEmpty) {
|
||||
selected.value = null
|
||||
} else {
|
||||
selected.value = item
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.chips {
|
||||
.btn {
|
||||
border: 1px solid transparent;
|
||||
&.capitalize {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 0.25rem solid var(--color-focus-ring);
|
||||
}
|
||||
}
|
||||
|
||||
.selected {
|
||||
color: var(--color-brand);
|
||||
background-color: var(--color-brand-highlight);
|
||||
border: 1px solid var(--color-brand);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
122
packages/ui/src/components/base/Collapsible.vue
Normal file
122
packages/ui/src/components/base/Collapsible.vue
Normal file
@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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;
|
||||
grid-template-rows: 0fr;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.accordion-content.open {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.accordion-content > div {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.accordion-content.overflow-visible > div {
|
||||
overflow: visible;
|
||||
}
|
||||
</style>
|
||||
196
packages/ui/src/components/base/CollapsibleAdmonition.vue
Normal file
196
packages/ui/src/components/base/CollapsibleAdmonition.vue
Normal file
@ -0,0 +1,196 @@
|
||||
<template>
|
||||
<Transition name="collapsible-admonition">
|
||||
<div
|
||||
v-if="!dismissed"
|
||||
:data-type="type"
|
||||
class="collapsible-admonition flex flex-col rounded-2xl border border-solid text-contrast overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="flex w-full cursor-pointer items-center gap-6 p-4"
|
||||
:class="headerBgClasses[type]"
|
||||
@click="expanded = !expanded"
|
||||
>
|
||||
<div class="flex flex-1 items-center gap-3">
|
||||
<TriangleAlertIcon :class="['h-5 w-5 flex-none', iconClasses[type]]" />
|
||||
<span class="text-base font-semibold text-contrast">
|
||||
<slot name="header">{{ header }}</slot>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<ButtonStyled circular type="highlight-colored-text" :color="buttonColors[type]">
|
||||
<button aria-label="Toggle" @click.stop="expanded = !expanded">
|
||||
<ChevronDownIcon
|
||||
class="h-4 w-4 transition-transform duration-300"
|
||||
:class="expanded && 'rotate-180'"
|
||||
/>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-if="dismissible"
|
||||
circular
|
||||
type="highlight-colored-text"
|
||||
:color="buttonColors[type]"
|
||||
>
|
||||
<button aria-label="Dismiss" @click.stop="handleDismiss">
|
||||
<XIcon class="h-4 w-4" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid transition-[grid-template-rows] duration-300 ease-in-out"
|
||||
:class="expanded ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'"
|
||||
>
|
||||
<div class="overflow-hidden">
|
||||
<slot>
|
||||
<div
|
||||
v-for="(item, index) in items"
|
||||
:key="index"
|
||||
class="collapsible-admonition__item collapsible-admonition__item--bordered flex flex-col gap-1 p-4"
|
||||
>
|
||||
<p class="m-0 text-base font-semibold text-contrast">
|
||||
{{ item.title }}
|
||||
</p>
|
||||
<div
|
||||
v-for="(desc, di) in item.descriptions"
|
||||
:key="di"
|
||||
class="flex items-start gap-1.5"
|
||||
>
|
||||
<LightBulbIcon :class="['mt-0.5 h-5 w-5 flex-none', iconClasses[type]]" />
|
||||
<span class="text-base text-contrast/85">{{ desc }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChevronDownIcon, LightBulbIcon, TriangleAlertIcon, XIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import ButtonStyled from './ButtonStyled.vue'
|
||||
|
||||
export interface CollapsibleAdmonitionItem {
|
||||
title: string
|
||||
descriptions?: string[]
|
||||
}
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
type?: 'info' | 'warning' | 'critical' | 'success'
|
||||
header?: string
|
||||
items?: CollapsibleAdmonitionItem[]
|
||||
dismissible?: boolean
|
||||
}>(),
|
||||
{
|
||||
type: 'critical',
|
||||
header: '',
|
||||
items: () => [],
|
||||
dismissible: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
dismiss: []
|
||||
}>()
|
||||
|
||||
const expanded = defineModel<boolean>({ default: false })
|
||||
const dismissed = ref(false)
|
||||
|
||||
function handleDismiss() {
|
||||
dismissed.value = true
|
||||
emit('dismiss')
|
||||
}
|
||||
|
||||
const headerBgClasses = {
|
||||
info: 'bg-bg-blue',
|
||||
warning: 'bg-bg-orange',
|
||||
critical: 'bg-bg-red',
|
||||
success: 'bg-bg-green',
|
||||
}
|
||||
|
||||
const iconClasses = {
|
||||
info: 'text-brand-blue',
|
||||
warning: 'text-brand-orange',
|
||||
critical: 'text-brand-red',
|
||||
success: 'text-brand-green',
|
||||
}
|
||||
|
||||
const buttonColors: Record<string, 'blue' | 'orange' | 'red' | 'green'> = {
|
||||
info: 'blue',
|
||||
warning: 'orange',
|
||||
critical: 'red',
|
||||
success: 'green',
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.collapsible-admonition[data-type='critical'] {
|
||||
border-color: rgba(255, 73, 110, 0.6);
|
||||
}
|
||||
|
||||
.collapsible-admonition[data-type='critical'] .collapsible-admonition__item {
|
||||
background: rgba(255, 73, 110, 0.1);
|
||||
}
|
||||
|
||||
.collapsible-admonition[data-type='critical'] .collapsible-admonition__item--bordered {
|
||||
border-top: 1px solid rgba(255, 73, 110, 0.6);
|
||||
}
|
||||
|
||||
.collapsible-admonition[data-type='info'] {
|
||||
border-color: rgba(47, 158, 255, 0.6);
|
||||
}
|
||||
|
||||
.collapsible-admonition[data-type='info'] .collapsible-admonition__item {
|
||||
background: rgba(47, 158, 255, 0.1);
|
||||
}
|
||||
|
||||
.collapsible-admonition[data-type='info'] .collapsible-admonition__item--bordered {
|
||||
border-top: 1px solid rgba(47, 158, 255, 0.6);
|
||||
}
|
||||
|
||||
.collapsible-admonition[data-type='warning'] {
|
||||
border-color: rgba(255, 163, 71, 0.6);
|
||||
}
|
||||
|
||||
.collapsible-admonition[data-type='warning'] .collapsible-admonition__item {
|
||||
background: rgba(255, 163, 71, 0.1);
|
||||
}
|
||||
|
||||
.collapsible-admonition[data-type='warning'] .collapsible-admonition__item--bordered {
|
||||
border-top: 1px solid rgba(255, 163, 71, 0.6);
|
||||
}
|
||||
|
||||
.collapsible-admonition[data-type='success'] {
|
||||
border-color: rgba(27, 217, 106, 0.6);
|
||||
}
|
||||
|
||||
.collapsible-admonition[data-type='success'] .collapsible-admonition__item {
|
||||
background: rgba(27, 217, 106, 0.1);
|
||||
}
|
||||
|
||||
.collapsible-admonition[data-type='success'] .collapsible-admonition__item--bordered {
|
||||
border-top: 1px solid rgba(27, 217, 106, 0.6);
|
||||
}
|
||||
|
||||
.collapsible-admonition-enter-active,
|
||||
.collapsible-admonition-leave-active {
|
||||
transition:
|
||||
opacity 300ms ease-in-out,
|
||||
transform 300ms ease-in-out;
|
||||
}
|
||||
|
||||
.collapsible-admonition-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
.collapsible-admonition-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
</style>
|
||||
80
packages/ui/src/components/base/CollapsibleRegion.vue
Normal file
80
packages/ui/src/components/base/CollapsibleRegion.vue
Normal file
@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<div class="relative overflow-hidden">
|
||||
<div
|
||||
class="collapsible-region-content"
|
||||
:class="{ open: !collapsed }"
|
||||
:style="{ '--collapsed-height': collapsedHeight }"
|
||||
>
|
||||
<div :class="{ 'pointer-events-none select-none pb-16': collapsed }">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="collapsed"
|
||||
class="pointer-events-none absolute inset-0 bg-gradient-to-b from-transparent"
|
||||
:class="gradientTo"
|
||||
/>
|
||||
|
||||
<div class="absolute bottom-4 left-1/2 z-20 -translate-x-1/2">
|
||||
<ButtonStyled circular type="transparent">
|
||||
<button class="flex items-center gap-1 text-xs" @click="collapsed = !collapsed">
|
||||
<ExpandIcon v-if="collapsed" />
|
||||
<CollapseIcon v-else />
|
||||
{{ collapsed ? expandText : collapseText }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CollapseIcon, ExpandIcon } from '@modrinth/assets'
|
||||
|
||||
import ButtonStyled from './ButtonStyled.vue'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
expandText?: string
|
||||
collapseText?: string
|
||||
collapsedHeight?: string
|
||||
gradientTo?: string
|
||||
}>(),
|
||||
{
|
||||
expandText: 'Expand',
|
||||
collapseText: 'Collapse',
|
||||
collapsedHeight: '8rem',
|
||||
gradientTo: 'to-surface-2',
|
||||
},
|
||||
)
|
||||
|
||||
const collapsed = defineModel<boolean>('collapsed', { default: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.collapsible-region-content {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 0.3s linear;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
.collapsible-region-content {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.collapsible-region-content.open {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.collapsible-region-content > div {
|
||||
overflow: hidden;
|
||||
min-height: var(--collapsed-height);
|
||||
transition: min-height 0.3s linear;
|
||||
}
|
||||
|
||||
.collapsible-region-content.open > div {
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
1007
packages/ui/src/components/base/Combobox.vue
Normal file
1007
packages/ui/src/components/base/Combobox.vue
Normal file
File diff suppressed because it is too large
Load Diff
45
packages/ui/src/components/base/ContentPageHeader.vue
Normal file
45
packages/ui/src/components/base/ContentPageHeader.vue
Normal file
@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-2 border-0 border-b border-solid border-divider pb-4">
|
||||
<div class="flex flex-wrap items-start gap-4 max-md:flex-col">
|
||||
<div class="flex min-w-0 flex-1 gap-4">
|
||||
<slot name="icon" />
|
||||
<div class="flex min-w-0 flex-col gap-2 justify-center">
|
||||
<div class="flex flex-col gap-1.5 justify-center">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h1 class="m-0 text-2xl font-semibold leading-none text-contrast">
|
||||
<slot name="title" />
|
||||
</h1>
|
||||
<slot name="title-suffix" />
|
||||
</div>
|
||||
<p
|
||||
v-if="$slots.summary"
|
||||
class="m-0 max-w-[44rem] empty:hidden"
|
||||
:class="[disableLineClamp ? '' : 'line-clamp-2']"
|
||||
>
|
||||
<slot name="summary" />
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="$slots.stats" class="flex flex-wrap gap-3 empty:hidden max-md:hidden">
|
||||
<slot name="stats" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 items-center">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="$slots.stats" class="flex justify-between md:hidden">
|
||||
<div class="flex flex-wrap gap-3 empty:hidden">
|
||||
<slot name="stats" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
disableLineClamp?: boolean
|
||||
}
|
||||
|
||||
const { disableLineClamp } = defineProps<Props>()
|
||||
</script>
|
||||
33
packages/ui/src/components/base/CopyCode.vue
Normal file
33
packages/ui/src/components/base/CopyCode.vue
Normal file
@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<button
|
||||
class="!m-0 inline-flex w-fit select-text items-center gap-2 rounded-[10px] bg-[var(--color-button-bg)] px-2 py-1 font-mono text-sm text-primary transition-[opacity,filter,transform,outline] duration-200 ease-in-out hover:brightness-[1.25] active:scale-95 active:brightness-[0.8] motion-reduce:transition-none [&>svg]:h-[1em] [&>svg]:w-[1em]"
|
||||
:title="formatMessage(copiedMessage)"
|
||||
@click="copyText"
|
||||
>
|
||||
<span>{{ text }}</span>
|
||||
<CheckIcon v-if="copied" />
|
||||
<ClipboardCopyIcon v-else />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, ClipboardCopyIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { defineMessage, useVIntl } from '../../composables/i18n'
|
||||
|
||||
const copiedMessage = defineMessage({
|
||||
id: 'omorphia.component.copy.action.copy',
|
||||
defaultMessage: 'Copy code to clipboard',
|
||||
})
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const props = defineProps<{ text: string }>()
|
||||
|
||||
const copied = ref(false)
|
||||
|
||||
async function copyText() {
|
||||
await navigator.clipboard.writeText(props.text)
|
||||
copied.value = true
|
||||
}
|
||||
</script>
|
||||
1931
packages/ui/src/components/base/DatePicker.vue
Normal file
1931
packages/ui/src/components/base/DatePicker.vue
Normal file
File diff suppressed because it is too large
Load Diff
34
packages/ui/src/components/base/DoubleIcon.vue
Normal file
34
packages/ui/src/components/base/DoubleIcon.vue
Normal file
@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div class="double-icon">
|
||||
<slot name="primary" />
|
||||
<div class="secondary">
|
||||
<slot name="secondary" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.double-icon {
|
||||
position: relative;
|
||||
height: fit-content;
|
||||
line-height: 0;
|
||||
|
||||
.secondary {
|
||||
position: absolute;
|
||||
bottom: -4px;
|
||||
right: -4px;
|
||||
background-color: var(--color-bg);
|
||||
padding: var(--spacing-card-xs);
|
||||
border-radius: 50%;
|
||||
aspect-ratio: 1 / 1;
|
||||
width: fit-content;
|
||||
height: fit-content;
|
||||
line-height: 0;
|
||||
|
||||
svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
139
packages/ui/src/components/base/DropArea.vue
Normal file
139
packages/ui/src/components/base/DropArea.vue
Normal file
@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
ref="dropAreaRef"
|
||||
class="drop-area"
|
||||
@drop.stop.prevent="handleDrop"
|
||||
@dragenter.prevent="allowDrag"
|
||||
@dragover.prevent="allowDrag"
|
||||
@dragleave.prevent="hideDropArea"
|
||||
/>
|
||||
</Teleport>
|
||||
<slot />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import { injectNotificationManager } from '../../providers'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
accept?: string
|
||||
}>(),
|
||||
{
|
||||
accept: '*',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits(['change'])
|
||||
|
||||
const dropAreaRef = ref<HTMLDivElement>()
|
||||
|
||||
const hideDropArea = () => {
|
||||
if (dropAreaRef.value) {
|
||||
dropAreaRef.value.style.visibility = 'hidden'
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
hideDropArea()
|
||||
|
||||
const files = event.dataTransfer?.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
const file = files[0]
|
||||
|
||||
if (!matchesAccept({ getAsFile: () => file } as DataTransferItem, props.accept)) {
|
||||
addNotification({
|
||||
title: 'Invalid file',
|
||||
text: `The file "${file.name}" is not a valid file type for this project.`,
|
||||
type: 'error',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
emit('change', files)
|
||||
}
|
||||
|
||||
function matchesAccept(file: DataTransferItem, accept?: string): boolean {
|
||||
if (!accept || accept.trim() === '') return true
|
||||
|
||||
const fileType = file.type // e.g. "image/png"
|
||||
const fileName = file.getAsFile()?.name.toLowerCase() ?? ''
|
||||
|
||||
return accept
|
||||
.split(',')
|
||||
.map((t) => t.trim().toLowerCase())
|
||||
.some((token) => {
|
||||
// .png, .jpg
|
||||
if (token.startsWith('.')) {
|
||||
return fileName.endsWith(token)
|
||||
}
|
||||
|
||||
// image/*
|
||||
if (token.endsWith('/*')) {
|
||||
const base = token.slice(0, -1) // "image/"
|
||||
return fileType.startsWith(base)
|
||||
}
|
||||
|
||||
// image/png
|
||||
return fileType === token
|
||||
})
|
||||
}
|
||||
|
||||
const allowDrag = (event: DragEvent) => {
|
||||
const item = event.dataTransfer?.items?.[0]
|
||||
if (!item || item.kind !== 'file') return
|
||||
|
||||
event.preventDefault()
|
||||
event.dataTransfer!.dropEffect = 'copy'
|
||||
|
||||
if (dropAreaRef.value) {
|
||||
dropAreaRef.value.style.visibility = 'visible'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('dragenter', allowDrag)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('dragenter', allowDrag)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.drop-area {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 10;
|
||||
visibility: hidden;
|
||||
background-color: hsla(0, 0%, 0%, 0.5);
|
||||
transition:
|
||||
visibility 0.2s ease-in-out,
|
||||
background-color 0.1s ease-in-out;
|
||||
display: flex;
|
||||
&::before {
|
||||
--indent: 4rem;
|
||||
content: ' ';
|
||||
position: relative;
|
||||
top: var(--indent);
|
||||
left: var(--indent);
|
||||
width: calc(100% - (2 * var(--indent)));
|
||||
height: calc(100% - (2 * var(--indent)));
|
||||
border-radius: 1rem;
|
||||
border: 0.25rem dashed var(--color-button-bg);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1895
packages/ui/src/components/base/DropdownFilterBar.vue
Normal file
1895
packages/ui/src/components/base/DropdownFilterBar.vue
Normal file
File diff suppressed because it is too large
Load Diff
434
packages/ui/src/components/base/DropdownSelect.vue
Normal file
434
packages/ui/src/components/base/DropdownSelect.vue
Normal file
@ -0,0 +1,434 @@
|
||||
<template>
|
||||
<div
|
||||
ref="dropdown"
|
||||
tabindex="0"
|
||||
role="combobox"
|
||||
:aria-expanded="dropdownVisible"
|
||||
class="animated-dropdown"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@focusout="onBlur"
|
||||
@mousedown.prevent
|
||||
@keydown.enter.prevent="toggleDropdown"
|
||||
@keydown.up.prevent="focusPreviousOption"
|
||||
@keydown.down.prevent="focusNextOptionOrOpen"
|
||||
>
|
||||
<div
|
||||
class="selected"
|
||||
:class="{
|
||||
disabled: disabled,
|
||||
'render-down': dropdownVisible && !effectiveRenderUp && !disabled,
|
||||
'render-up': dropdownVisible && effectiveRenderUp && !disabled,
|
||||
}"
|
||||
@click="toggleDropdown"
|
||||
>
|
||||
<div class="min-w-0 overflow-hidden">
|
||||
<slot :selected="selectedOption">
|
||||
<span>
|
||||
{{ selectedOption }}
|
||||
</span>
|
||||
</slot>
|
||||
</div>
|
||||
<DropdownIcon class="arrow" :class="{ rotate: dropdownVisible }" />
|
||||
</div>
|
||||
<div class="options-wrapper" :class="{ down: !effectiveRenderUp, up: effectiveRenderUp }">
|
||||
<transition name="options">
|
||||
<div
|
||||
v-show="dropdownVisible"
|
||||
class="options"
|
||||
role="listbox"
|
||||
:class="{ down: !effectiveRenderUp, up: effectiveRenderUp }"
|
||||
:style="automaticMenuStyle"
|
||||
>
|
||||
<div
|
||||
v-for="(option, index) in options"
|
||||
:key="index"
|
||||
ref="optionElements"
|
||||
tabindex="-1"
|
||||
role="option"
|
||||
:class="{ 'selected-option': selectedValue === option }"
|
||||
:aria-selected="selectedValue === option"
|
||||
class="option"
|
||||
@click="selectOption(option, index)"
|
||||
@keydown.space.prevent="selectOption(option, index)"
|
||||
>
|
||||
<input
|
||||
:id="`${name}-${index}`"
|
||||
v-model="radioValue"
|
||||
type="radio"
|
||||
:value="option"
|
||||
:name="name"
|
||||
/>
|
||||
<label :for="`${name}-${index}`">{{ getOptionLabel(option) }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { DropdownIcon } from '@modrinth/assets'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { dropdownPlacement } from './dropdown-placement'
|
||||
|
||||
const OPTION_HEIGHT_REM = 3
|
||||
const DEFAULT_MAX_MENU_HEIGHT_REM = 18.75
|
||||
const SAFE_GAP_REM = 0.5
|
||||
|
||||
const props = defineProps({
|
||||
options: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
defaultValue: {
|
||||
type: [String, Number, Object],
|
||||
default: null,
|
||||
},
|
||||
placeholder: {
|
||||
type: [String, Number],
|
||||
default: null,
|
||||
},
|
||||
modelValue: {
|
||||
type: [String, Number, Object],
|
||||
default: null,
|
||||
},
|
||||
renderUp: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
autoPlacement: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
displayName: {
|
||||
type: Function,
|
||||
default: undefined,
|
||||
},
|
||||
maxVisibleOptions: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
})
|
||||
|
||||
function getOptionLabel(option) {
|
||||
return props.displayName?.(option) ?? option
|
||||
}
|
||||
|
||||
const emit = defineEmits(['input', 'change', 'update:modelValue'])
|
||||
|
||||
const dropdownVisible = ref(false)
|
||||
const selectedValue = ref(props.modelValue || props.defaultValue)
|
||||
const focusedOptionIndex = ref(null)
|
||||
const dropdown = ref(null)
|
||||
const optionElements = ref(null)
|
||||
const automaticRenderUp = ref(false)
|
||||
const automaticAvailableHeight = ref(null)
|
||||
const effectiveRenderUp = computed(() =>
|
||||
props.autoPlacement ? automaticRenderUp.value : props.renderUp,
|
||||
)
|
||||
const automaticMenuStyle = computed(() =>
|
||||
props.autoPlacement && automaticAvailableHeight.value !== null
|
||||
? { maxHeight: `${automaticAvailableHeight.value}px` }
|
||||
: undefined,
|
||||
)
|
||||
|
||||
function cssPixels(property, fallback = 0) {
|
||||
const value = Number.parseFloat(
|
||||
getComputedStyle(document.documentElement).getPropertyValue(property),
|
||||
)
|
||||
return Number.isFinite(value) ? value : fallback
|
||||
}
|
||||
|
||||
function updatePlacement() {
|
||||
if (!props.autoPlacement || !dropdown.value) return
|
||||
const rect = dropdown.value.getBoundingClientRect()
|
||||
const rootFontSize = cssPixels('font-size', 16)
|
||||
const maxMenuHeight =
|
||||
(props.maxVisibleOptions ?? DEFAULT_MAX_MENU_HEIGHT_REM / OPTION_HEIGHT_REM) *
|
||||
OPTION_HEIGHT_REM *
|
||||
rootFontSize
|
||||
const expectedMenuHeight = Math.min(
|
||||
props.options.length * OPTION_HEIGHT_REM * rootFontSize,
|
||||
maxMenuHeight,
|
||||
)
|
||||
const placement = dropdownPlacement({
|
||||
viewportHeight: window.innerHeight,
|
||||
controlTop: rect.top,
|
||||
controlBottom: rect.bottom,
|
||||
floatingActionBarClearance: cssPixels('--floating-action-bar-clearance'),
|
||||
safeGap: SAFE_GAP_REM * rootFontSize,
|
||||
expectedMenuHeight,
|
||||
})
|
||||
automaticRenderUp.value = placement.renderUp
|
||||
automaticAvailableHeight.value = Math.min(expectedMenuHeight, placement.availableHeight)
|
||||
}
|
||||
|
||||
function schedulePlacementUpdate() {
|
||||
if (!dropdownVisible.value) return
|
||||
void nextTick(updatePlacement)
|
||||
}
|
||||
|
||||
const selectedOption = computed(() => {
|
||||
return getOptionLabel(selectedValue.value) ?? props.placeholder ?? 'Select an option'
|
||||
})
|
||||
|
||||
const radioValue = computed({
|
||||
get() {
|
||||
return props.modelValue || selectedValue.value
|
||||
},
|
||||
set(newValue) {
|
||||
emit('update:modelValue', newValue)
|
||||
selectedValue.value = newValue
|
||||
},
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
selectedValue.value = newValue
|
||||
},
|
||||
)
|
||||
|
||||
const toggleDropdown = () => {
|
||||
if (!props.disabled) {
|
||||
if (!dropdownVisible.value) updatePlacement()
|
||||
dropdownVisible.value = !dropdownVisible.value
|
||||
dropdown.value.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const selectOption = (option, index) => {
|
||||
radioValue.value = option
|
||||
emit('change', { option, index })
|
||||
dropdownVisible.value = false
|
||||
}
|
||||
|
||||
const onFocus = () => {
|
||||
if (!props.disabled) {
|
||||
updatePlacement()
|
||||
focusedOptionIndex.value = props.options.findIndex((option) => option === selectedValue.value)
|
||||
dropdownVisible.value = true
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!props.autoPlacement) return
|
||||
window.addEventListener('resize', schedulePlacementUpdate)
|
||||
window.addEventListener('scroll', schedulePlacementUpdate, true)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (!props.autoPlacement) return
|
||||
window.removeEventListener('resize', schedulePlacementUpdate)
|
||||
window.removeEventListener('scroll', schedulePlacementUpdate, true)
|
||||
})
|
||||
|
||||
watch(() => props.options.length, schedulePlacementUpdate)
|
||||
|
||||
const onBlur = (event) => {
|
||||
if (!isChildOfDropdown(event.relatedTarget)) {
|
||||
dropdownVisible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const focusPreviousOption = () => {
|
||||
if (!props.disabled) {
|
||||
if (!dropdownVisible.value) {
|
||||
toggleDropdown()
|
||||
}
|
||||
focusedOptionIndex.value =
|
||||
(focusedOptionIndex.value + props.options.length - 1) % props.options.length
|
||||
optionElements.value[focusedOptionIndex.value].focus()
|
||||
}
|
||||
}
|
||||
|
||||
const focusNextOptionOrOpen = () => {
|
||||
if (!props.disabled) {
|
||||
if (!dropdownVisible.value) {
|
||||
toggleDropdown()
|
||||
}
|
||||
focusedOptionIndex.value = (focusedOptionIndex.value + 1) % props.options.length
|
||||
optionElements.value[focusedOptionIndex.value].focus()
|
||||
}
|
||||
}
|
||||
|
||||
const isChildOfDropdown = (element) => {
|
||||
let currentNode = element
|
||||
while (currentNode) {
|
||||
if (currentNode === dropdown.value) {
|
||||
return true
|
||||
}
|
||||
currentNode = currentNode.parentNode
|
||||
}
|
||||
return false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.animated-dropdown {
|
||||
width: 20rem;
|
||||
max-width: 100%;
|
||||
height: 40px;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.selected {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--gap-sm) var(--gap-lg);
|
||||
background-color: var(--color-button-bg);
|
||||
gap: var(--gap-md);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow:
|
||||
var(--shadow-inset-sm),
|
||||
0 0 0 0 transparent;
|
||||
|
||||
transition: 0.05s;
|
||||
|
||||
span {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&:not(.render-down):not(.render-up) {
|
||||
transition-delay: 0.2s;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
cursor: not-allowed;
|
||||
filter: grayscale(50%);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&.render-up {
|
||||
border-radius: 0 0 var(--radius-md) var(--radius-md);
|
||||
}
|
||||
|
||||
&.render-down {
|
||||
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
filter: brightness(1.25);
|
||||
transition: filter 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
&.rotate {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.options {
|
||||
z-index: 10;
|
||||
max-height: v-bind('maxVisibleOptions ? `calc(${maxVisibleOptions} * 3rem)` : "18.75rem"');
|
||||
overflow-y: auto;
|
||||
box-shadow:
|
||||
var(--shadow-inset-sm),
|
||||
0 0 0 0 transparent;
|
||||
|
||||
.option {
|
||||
background-color: var(--color-button-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--gap-md);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
> label {
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
filter: brightness(0.85);
|
||||
transition: filter 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
filter: brightness(0.85);
|
||||
transition: filter 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
&.selected-option {
|
||||
background-color: var(--color-brand);
|
||||
color: var(--color-accent-contrast);
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
input {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.options-enter-active,
|
||||
.options-leave-active {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.options-enter-from,
|
||||
.options-leave-to {
|
||||
// this is not 100% due to a safari bug
|
||||
&.up {
|
||||
transform: translateY(99.999%);
|
||||
}
|
||||
|
||||
&.down {
|
||||
transform: translateY(-99.999%);
|
||||
}
|
||||
}
|
||||
|
||||
.options-enter-to,
|
||||
.options-leave-from {
|
||||
&.up {
|
||||
transform: translateY(0%);
|
||||
}
|
||||
}
|
||||
|
||||
.options-wrapper {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
z-index: 9;
|
||||
|
||||
&.up {
|
||||
top: 0;
|
||||
transform: translateY(-99.999%);
|
||||
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||
}
|
||||
|
||||
&.down {
|
||||
border-radius: 0 0 var(--radius-md) var(--radius-md);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
134
packages/ui/src/components/base/DropzoneFileInput.vue
Normal file
134
packages/ui/src/components/base/DropzoneFileInput.vue
Normal file
@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full cursor-pointer border-none bg-transparent p-0 text-left"
|
||||
:class="[props.disabled ? 'cursor-not-allowed opacity-50' : '']"
|
||||
:disabled="props.disabled"
|
||||
@click="handleClick"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'flex flex-col items-center justify-center border-2 border-dashed bg-surface-4 text-contrast transition-colors',
|
||||
size === 'small' ? 'p-5' : size === 'medium' ? 'p-10' : 'p-12',
|
||||
size === 'small' ? 'gap-2' : 'gap-4',
|
||||
size === 'small' ? 'rounded-2xl' : 'rounded-3xl',
|
||||
'border-surface-5',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
v-if="!noIconBox"
|
||||
:class="[
|
||||
'grid place-content-center text-brand border-brand border-solid border bg-highlight-green',
|
||||
size === 'small' ? 'w-10 h-10' : 'h-14 w-14',
|
||||
size === 'small' ? 'rounded-xl' : 'rounded-2xl',
|
||||
]"
|
||||
>
|
||||
<FolderUpIcon
|
||||
aria-hidden="true"
|
||||
:class="['text-brand', size === 'small' ? 'w-6 h-6' : 'w-8 h-8']"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="grid place-content-center">
|
||||
<FolderUpIcon
|
||||
aria-hidden="true"
|
||||
:class="['text-secondary', size === 'small' ? 'w-6 h-6' : 'w-8 h-8']"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center gap-1 text-contrast text-center">
|
||||
<div class="text-contrast font-medium text-pretty">{{ primaryPrompt }}</div>
|
||||
<span v-if="secondaryPrompt" class="text-primary text-sm text-pretty">{{
|
||||
secondaryPrompt
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FolderUpIcon } from '@modrinth/assets'
|
||||
|
||||
import { useDebugLogger } from '#ui/composables/debug-logger'
|
||||
|
||||
const debug = useDebugLogger('DropzoneFileInput')
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'change', paths: string[]): void
|
||||
}>()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
primaryPrompt?: string | null
|
||||
secondaryPrompt?: string | null
|
||||
multiple?: boolean
|
||||
accept?: string
|
||||
disabled?: boolean
|
||||
size?: 'small' | 'medium' | 'large'
|
||||
directory?: boolean
|
||||
noIconBox?: boolean
|
||||
}>(),
|
||||
{
|
||||
primaryPrompt: 'Drop files here or click to upload',
|
||||
secondaryPrompt: 'Only supported file types will be accepted',
|
||||
size: 'large',
|
||||
directory: false,
|
||||
noIconBox: false,
|
||||
},
|
||||
)
|
||||
|
||||
async function handleClick() {
|
||||
debug('handleClick called, disabled:', props.disabled)
|
||||
if (props.disabled) {
|
||||
debug('disabled, returning')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
debug('importing @tauri-apps/plugin-dialog')
|
||||
const { open } = await import('@tauri-apps/plugin-dialog')
|
||||
debug('open function imported')
|
||||
|
||||
if (props.directory) {
|
||||
debug('directory mode')
|
||||
const result = await open({ directory: true, multiple: false })
|
||||
debug('open result:', result)
|
||||
const path = typeof result === 'string' ? result : (result?.path ?? null)
|
||||
debug('extracted path:', path)
|
||||
if (path) {
|
||||
debug('emitting change with path:', [path])
|
||||
emit('change', [path])
|
||||
} else {
|
||||
debug('no path selected')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const filters = props.accept
|
||||
? [
|
||||
{
|
||||
name: props.accept || 'Files',
|
||||
extensions: props.accept.split(',').map((ext) => ext.trim().replace(/^\./, '')),
|
||||
},
|
||||
]
|
||||
: undefined
|
||||
debug('filters:', filters)
|
||||
|
||||
const result = await open({ multiple: props.multiple ?? false, filters })
|
||||
debug('open result:', result)
|
||||
const paths = Array.isArray(result) ? result : [result]
|
||||
const pickedPaths = paths
|
||||
.map((entry) => (typeof entry === 'string' ? entry : entry?.path))
|
||||
.filter((p): p is string => !!p)
|
||||
debug('pickedPaths:', pickedPaths)
|
||||
|
||||
if (pickedPaths.length > 0) {
|
||||
debug('emitting change with pickedPaths:', pickedPaths)
|
||||
emit('change', pickedPaths)
|
||||
} else {
|
||||
debug('no valid paths selected')
|
||||
}
|
||||
} catch (err) {
|
||||
debug('error in handleClick:', err)
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
</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>
|
||||
115
packages/ui/src/components/base/EnvironmentIndicator.vue
Normal file
115
packages/ui/src/components/base/EnvironmentIndicator.vue
Normal file
@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<span v-if="typeOnly" class="environment">
|
||||
<InfoIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.typeLabel, { type: type }) }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="
|
||||
!['resourcepack', 'shader'].includes(type) &&
|
||||
!(type === 'plugin' && search) &&
|
||||
!categories.includes('datapack')
|
||||
"
|
||||
class="environment"
|
||||
>
|
||||
<template v-if="clientSide === 'optional' && serverSide === 'optional'">
|
||||
<GlobeIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.clientOrServerLabel) }}
|
||||
</template>
|
||||
<template v-else-if="clientSide === 'required' && serverSide === 'required'">
|
||||
<GlobeIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.clientAndServerLabel) }}
|
||||
</template>
|
||||
<template
|
||||
v-else-if="
|
||||
(clientSide === 'optional' || clientSide === 'required') &&
|
||||
(serverSide === 'optional' || serverSide === 'unsupported')
|
||||
"
|
||||
>
|
||||
<ClientIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.clientLabel) }}
|
||||
</template>
|
||||
<template
|
||||
v-else-if="
|
||||
(serverSide === 'optional' || serverSide === 'required') &&
|
||||
(clientSide === 'optional' || clientSide === 'unsupported')
|
||||
"
|
||||
>
|
||||
<ServerIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.serverLabel) }}
|
||||
</template>
|
||||
<template v-else-if="serverSide === 'unsupported' && clientSide === 'unsupported'">
|
||||
<GlobeIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.unsupportedLabel) }}
|
||||
</template>
|
||||
<template v-else-if="alwaysShow">
|
||||
<InfoIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.typeLabel, { type: type }) }}
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ClientIcon, GlobeIcon, InfoIcon, ServerIcon } from '@modrinth/assets'
|
||||
|
||||
import { defineMessages, useVIntl } from '../../composables/i18n'
|
||||
|
||||
const messages = defineMessages({
|
||||
clientLabel: {
|
||||
id: 'omorphia.component.environment-indicator.label.client',
|
||||
defaultMessage: 'Client',
|
||||
},
|
||||
clientAndServerLabel: {
|
||||
id: 'omorphia.component.environment-indicator.label.client-and-server',
|
||||
defaultMessage: 'Client and server',
|
||||
},
|
||||
clientOrServerLabel: {
|
||||
id: 'omorphia.component.environment-indicator.label.client-or-server',
|
||||
defaultMessage: 'Client or server',
|
||||
},
|
||||
serverLabel: {
|
||||
id: 'omorphia.component.environment-indicator.label.server',
|
||||
defaultMessage: 'Server',
|
||||
},
|
||||
typeLabel: {
|
||||
id: 'omorphia.component.environment-indicator.label.type',
|
||||
defaultMessage: 'A {type}',
|
||||
},
|
||||
unsupportedLabel: {
|
||||
id: 'omorphia.component.environment-indicator.label.unsupported',
|
||||
defaultMessage: 'Unsupported',
|
||||
},
|
||||
})
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
type: string
|
||||
serverSide?: string
|
||||
clientSide?: string
|
||||
typeOnly?: boolean
|
||||
alwaysShow?: boolean
|
||||
search?: boolean
|
||||
categories?: string[]
|
||||
}>(),
|
||||
{
|
||||
type: 'mod',
|
||||
serverSide: '',
|
||||
clientSide: '',
|
||||
typeOnly: false,
|
||||
alwaysShow: false,
|
||||
search: false,
|
||||
categories: () => [],
|
||||
},
|
||||
)
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.environment {
|
||||
display: flex;
|
||||
color: var(--color-text) !important;
|
||||
font-weight: bold;
|
||||
font-size: 1rem;
|
||||
align-items: center;
|
||||
svg {
|
||||
margin-right: 0.2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
121
packages/ui/src/components/base/ErrorInformationCard.vue
Normal file
121
packages/ui/src/components/base/ErrorInformationCard.vue
Normal file
@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-8 shadow-xl">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
|
||||
<component :is="icon" class="size-12 text-orange" />
|
||||
</div>
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">{{ title }}</h1>
|
||||
</div>
|
||||
<div v-if="!description">
|
||||
<slot name="description" />
|
||||
</div>
|
||||
<p v-else class="text-lg text-secondary">{{ description }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="errorDetails" class="my-4 w-full rounded-lg border border-divider bg-bg-raised">
|
||||
<div class="divide-y divide-divider">
|
||||
<div
|
||||
v-for="detail in errorDetails.filter((detail) => detail.type !== 'hidden')"
|
||||
:key="detail.label"
|
||||
class="px-4 py-3"
|
||||
>
|
||||
<div v-if="detail.type === 'inline'" class="flex items-center justify-between">
|
||||
<span class="font-medium text-secondary">{{ detail.label }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="rounded-lg bg-code-bg px-2 py-1 text-sm text-code-text">
|
||||
{{ detail.value }}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="detail.type === 'block'" class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-medium text-secondary">{{ detail.label }}</span>
|
||||
</div>
|
||||
<div class="w-full overflow-hidden rounded-lg bg-code-bg p-3">
|
||||
<code
|
||||
class="block w-full overflow-x-auto break-words text-sm text-code-text whitespace-pre-wrap"
|
||||
>
|
||||
{{ detail.value }}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex !w-full flex-row gap-4">
|
||||
<ButtonStyled
|
||||
v-if="action"
|
||||
size="large"
|
||||
:color="action.color || 'brand'"
|
||||
:disabled="action.disabled"
|
||||
@click="action.onClick"
|
||||
>
|
||||
<button class="!w-full">
|
||||
<component :is="action.icon" v-if="action.icon && !action.showAltIcon" class="size-4" />
|
||||
<component
|
||||
:is="action.altIcon"
|
||||
v-else-if="action.icon && action.showAltIcon"
|
||||
class="size-4"
|
||||
/>
|
||||
{{ action.label }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<ButtonStyled v-if="errorDetails" size="large" color="standard" @click="copyErrorInformation">
|
||||
<button class="!w-full">
|
||||
<CopyIcon v-if="!infoCopied" class="size-4" />
|
||||
<CheckIcon v-else class="size-4" />
|
||||
Copy Information
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, CopyIcon } from '@modrinth/assets'
|
||||
import type { Component } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import ButtonStyled from './ButtonStyled.vue'
|
||||
|
||||
const infoCopied = ref(false)
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
description?: string
|
||||
icon: Component
|
||||
errorDetails?: {
|
||||
label?: string
|
||||
value?: string
|
||||
type?: 'inline' | 'block' | 'hidden'
|
||||
}[]
|
||||
action?: {
|
||||
label: string
|
||||
onClick: () => void
|
||||
color?: 'brand' | 'standard' | 'red' | 'orange' | 'blue'
|
||||
disabled?: boolean
|
||||
icon?: Component
|
||||
altIcon?: Component
|
||||
showAltIcon?: boolean
|
||||
}
|
||||
}>()
|
||||
|
||||
const copyErrorInformation = async () => {
|
||||
if (!props.errorDetails || props.errorDetails.length === 0) return
|
||||
|
||||
const formattedErrorInfo = props.errorDetails
|
||||
.filter((detail) => detail.label && detail.value)
|
||||
.map((detail) => `${detail.label}: ${detail.value}`)
|
||||
.join('\n\n')
|
||||
|
||||
await navigator.clipboard.writeText(formattedErrorInfo)
|
||||
infoCopied.value = true
|
||||
setTimeout(() => {
|
||||
infoCopied.value = false
|
||||
}, 2000)
|
||||
}
|
||||
</script>
|
||||
104
packages/ui/src/components/base/FileInput.vue
Normal file
104
packages/ui/src/components/base/FileInput.vue
Normal file
@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<label :class="{ 'long-style': longStyle }" @drop.prevent="handleDrop" @dragover.prevent>
|
||||
<slot />
|
||||
{{ prompt }}
|
||||
<input
|
||||
type="file"
|
||||
:multiple="multiple"
|
||||
:accept="accept"
|
||||
:disabled="disabled"
|
||||
@change="handleChange"
|
||||
/>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { fileIsValid } from '@modrinth/utils'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useFormatBytes } from '../../composables'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
prompt?: string
|
||||
multiple?: boolean
|
||||
accept?: string
|
||||
/**
|
||||
* The max file size in bytes
|
||||
*/
|
||||
maxSize?: number | null
|
||||
showIcon?: boolean
|
||||
shouldAlwaysReset?: boolean
|
||||
longStyle?: boolean
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{
|
||||
prompt: 'Select file',
|
||||
multiple: false,
|
||||
showIcon: true,
|
||||
shouldAlwaysReset: false,
|
||||
longStyle: false,
|
||||
disabled: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ change: [files: File[]] }>()
|
||||
|
||||
const formatBytes = useFormatBytes()
|
||||
|
||||
const files = ref<File[]>([])
|
||||
|
||||
function addFiles(incoming: FileList, shouldNotReset = false) {
|
||||
if (!shouldNotReset || props.shouldAlwaysReset) {
|
||||
files.value = Array.from(incoming)
|
||||
}
|
||||
const validationOptions = { maxSize: props.maxSize, alertOnInvalid: true }
|
||||
files.value = files.value.filter((file) => fileIsValid(file, validationOptions, formatBytes))
|
||||
if (files.value.length > 0) {
|
||||
emit('change', files.value)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent) {
|
||||
addFiles(e.dataTransfer!.files)
|
||||
}
|
||||
|
||||
function handleChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
if (!input.files) return
|
||||
addFiles(input.files)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
label {
|
||||
flex-direction: unset;
|
||||
max-height: unset;
|
||||
&:focus-within {
|
||||
outline: 0.25rem solid var(--color-focus-ring);
|
||||
}
|
||||
|
||||
svg {
|
||||
height: 1rem;
|
||||
}
|
||||
input {
|
||||
position: absolute;
|
||||
height: 1px;
|
||||
width: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(1px, 1px, 1px, 1px);
|
||||
}
|
||||
&.long-style {
|
||||
display: flex;
|
||||
padding: 1.5rem 2rem;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
grid-gap: 0.5rem;
|
||||
background-color: var(--color-button-bg);
|
||||
border-radius: var(--radius-sm);
|
||||
border: dashed 2px var(--color-secondary);
|
||||
cursor: pointer;
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
604
packages/ui/src/components/base/FileTreeSelect.vue
Normal file
604
packages/ui/src/components/base/FileTreeSelect.vue
Normal file
@ -0,0 +1,604 @@
|
||||
<template>
|
||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||
<div
|
||||
class="flex w-full min-w-0 flex-col rounded-[20px] border border-solid border-surface-4 shadow-sm overflow-clip"
|
||||
>
|
||||
<div
|
||||
class="flex h-10 w-full min-w-0 select-none flex-row items-center justify-between bg-surface-3 px-3 text-sm font-medium"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<Checkbox
|
||||
:model-value="allVisibleSelected"
|
||||
:indeterminate="someVisibleSelected && !allVisibleSelected"
|
||||
:disabled="visibleSelectableEntries.length === 0"
|
||||
@update:model-value="toggleAllVisible"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-w-0 appearance-none items-center gap-1.5 border-0 bg-transparent p-0 font-semibold hover:text-primary"
|
||||
:class="sortField === 'name' ? 'text-contrast' : 'text-secondary'"
|
||||
@click="handleSort('name')"
|
||||
>
|
||||
<span class="min-w-0 truncate">{{ formatMessage(messages.name) }}</span>
|
||||
<ChevronUpIcon
|
||||
v-if="sortField === 'name' && !sortDesc"
|
||||
class="size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<ChevronDownIcon
|
||||
v-if="sortField === 'name' && sortDesc"
|
||||
class="size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="ml-2 flex shrink-0 items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
class="hidden w-[92px] appearance-none items-center gap-1 border-0 bg-transparent p-0 text-left font-semibold hover:text-primary sm:flex"
|
||||
:class="sortField === 'size' ? 'text-contrast' : 'text-secondary'"
|
||||
@click="handleSort('size')"
|
||||
>
|
||||
<span>{{ formatMessage(messages.size) }}</span>
|
||||
<ChevronUpIcon
|
||||
v-if="sortField === 'size' && !sortDesc"
|
||||
class="size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<ChevronDownIcon
|
||||
v-if="sortField === 'size' && sortDesc"
|
||||
class="size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="hidden w-[132px] appearance-none items-center gap-1 border-0 bg-transparent p-0 text-left font-semibold hover:text-primary sm:flex"
|
||||
:class="sortField === 'modified' ? 'text-contrast' : 'text-secondary'"
|
||||
@click="handleSort('modified')"
|
||||
>
|
||||
<span>{{ formatMessage(messages.modified) }}</span>
|
||||
<ChevronUpIcon
|
||||
v-if="sortField === 'modified' && !sortDesc"
|
||||
class="size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<ChevronDownIcon
|
||||
v-if="sortField === 'modified' && sortDesc"
|
||||
class="size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<span class="size-4 shrink-0" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!isHomePath"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="group flex w-full min-w-0 cursor-pointer select-none items-center gap-2 border-0 border-t border-solid border-surface-4 bg-surface-2 px-3 py-2 text-left hover:bg-surface-2.5 focus:!outline-none"
|
||||
@click="navigateTo(parentPath)"
|
||||
@keydown="(event) => event.key === 'Enter' && navigateTo(parentPath)"
|
||||
>
|
||||
<span class="size-5 shrink-0" aria-hidden="true" />
|
||||
<div class="flex size-4 shrink-0 items-center justify-center text-secondary">
|
||||
<UndoIcon class="size-4 group-hover:text-contrast group-focus:text-contrast" />
|
||||
</div>
|
||||
<span
|
||||
class="min-w-0 flex-1 truncate text-sm font-medium text-primary group-hover:text-contrast group-focus:text-contrast"
|
||||
>
|
||||
{{ formatMessage(messages.parentFolder) }}
|
||||
</span>
|
||||
<div class="ml-2 flex shrink-0 items-center gap-4">
|
||||
<span class="hidden w-[92px] text-left text-sm text-secondary sm:block" />
|
||||
<span class="hidden w-[132px] text-left text-sm text-secondary sm:block" />
|
||||
<span class="size-4 shrink-0" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="entries.length === 0"
|
||||
class="flex items-center gap-2 bg-surface-2 px-3 py-2 text-sm text-secondary"
|
||||
>
|
||||
<FileIcon class="size-4 shrink-0" />
|
||||
<span>{{ formatMessage(messages.emptyFolderTitle) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="(entry, i) in entries"
|
||||
:key="`${entry.type}:${entry.path}`"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="group flex w-full min-w-0 select-none items-center gap-2 border-0 border-t border-solid border-surface-4 px-3 py-2 text-left first:border-t-0 focus:!outline-none"
|
||||
:class="[
|
||||
getRowBackgroundClass(i + (isHomePath ? 0 : 1)),
|
||||
entry.disabled && entry.type === 'file'
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
: 'cursor-pointer hover:bg-surface-2.5',
|
||||
]"
|
||||
@click="selectEntry(entry)"
|
||||
@keydown="(event) => event.key === 'Enter' && selectEntry(entry)"
|
||||
>
|
||||
<Checkbox
|
||||
class="shrink-0"
|
||||
:model-value="entry.checked"
|
||||
:indeterminate="entry.indeterminate"
|
||||
:disabled="entry.disabled"
|
||||
:description="entry.name"
|
||||
@click.stop
|
||||
@update:model-value="toggleEntry(entry, $event)"
|
||||
/>
|
||||
<div class="flex size-4 shrink-0 items-center justify-center text-secondary">
|
||||
<component
|
||||
:is="entry.icon"
|
||||
class="size-4 group-hover:text-contrast group-focus:text-contrast"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
:ref="(element) => setEntryNameRef(entry.path, element)"
|
||||
v-tooltip="truncatedTooltip(entryNameRefs[entry.path], entry.name)"
|
||||
class="min-w-0 flex-1 truncate text-sm font-medium text-primary group-hover:text-contrast group-focus:text-contrast"
|
||||
>
|
||||
{{ entry.name }}
|
||||
</span>
|
||||
<div class="ml-2 flex shrink-0 items-center gap-4">
|
||||
<span class="hidden w-[92px] truncate text-left text-sm text-secondary sm:block">
|
||||
{{ formatSize(entry) }}
|
||||
</span>
|
||||
<span class="hidden w-[132px] truncate text-left text-sm text-secondary sm:block">
|
||||
{{ formatModified(entry) }}
|
||||
</span>
|
||||
<ChevronRightIcon
|
||||
class="size-4 shrink-0 text-secondary group-hover:text-contrast group-focus:text-contrast"
|
||||
:class="{ invisible: entry.type !== 'directory' }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="row in fillerRowCount"
|
||||
:key="`filler:${row}`"
|
||||
class="flex w-full min-w-0 select-none items-center gap-2 border-0 border-t border-solid border-surface-4 px-3 py-2 text-left"
|
||||
:class="getRowBackgroundClass(visibleRowCount + row - 1)"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="size-5 shrink-0" />
|
||||
<span class="size-4 shrink-0" />
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium opacity-0">.</span>
|
||||
<div class="ml-2 flex shrink-0 items-center gap-4">
|
||||
<span class="hidden w-[92px] text-left text-sm sm:block" />
|
||||
<span class="hidden w-[132px] text-left text-sm sm:block" />
|
||||
<span class="size-4 shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
ChevronUpIcon,
|
||||
FileIcon,
|
||||
UndoIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { type Component, type ComponentPublicInstance, computed, ref, watch } from 'vue'
|
||||
|
||||
import { useFormatBytes } from '../../composables/format-bytes'
|
||||
import { useFormatDateTime } from '../../composables/format-date-time'
|
||||
import { defineMessages, useVIntl } from '../../composables/i18n'
|
||||
import { getDirectoryIcon, getFileIcon } from '../../utils/auto-icons'
|
||||
import { truncatedTooltip } from '../../utils/truncate'
|
||||
import Checkbox from './Checkbox.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
name: {
|
||||
id: 'files.table-header.name',
|
||||
defaultMessage: 'Name',
|
||||
},
|
||||
size: {
|
||||
id: 'files.table-header.size',
|
||||
defaultMessage: 'Size',
|
||||
},
|
||||
modified: {
|
||||
id: 'files.table-header.modified',
|
||||
defaultMessage: 'Modified',
|
||||
},
|
||||
itemCount: {
|
||||
id: 'files.row.item-count',
|
||||
defaultMessage: '{count, plural, one {# item} other {# items}}',
|
||||
},
|
||||
parentFolder: {
|
||||
id: 'files.row.parent-folder',
|
||||
defaultMessage: 'Parent folder',
|
||||
},
|
||||
emptyFolderTitle: {
|
||||
id: 'files.layout.empty-folder-title',
|
||||
defaultMessage: 'This folder is empty',
|
||||
},
|
||||
})
|
||||
|
||||
export type FileTreeSelectItem = {
|
||||
path: string
|
||||
type?: 'directory' | 'file'
|
||||
disabled?: boolean
|
||||
size?: number
|
||||
modified?: number
|
||||
count?: number
|
||||
}
|
||||
|
||||
type NormalizedFileTreeSelectItem = FileTreeSelectItem & {
|
||||
name: string
|
||||
normalizedPath: string
|
||||
}
|
||||
|
||||
type FileTreeSelectEntry = {
|
||||
path: string
|
||||
name: string
|
||||
type: 'directory' | 'file'
|
||||
icon: Component
|
||||
checked: boolean
|
||||
indeterminate: boolean
|
||||
disabled: boolean
|
||||
size?: number
|
||||
modified?: number
|
||||
count?: number
|
||||
item?: NormalizedFileTreeSelectItem
|
||||
}
|
||||
|
||||
type FileTreeSelectSortField = 'name' | 'size' | 'modified'
|
||||
|
||||
const formatBytes = useFormatBytes()
|
||||
const formatDateTime = useFormatDateTime({
|
||||
year: '2-digit',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items: FileTreeSelectItem[]
|
||||
modelValue: string[]
|
||||
}>(),
|
||||
{
|
||||
items: () => [],
|
||||
modelValue: () => [],
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string[]): void
|
||||
(e: 'navigate', path: string): void
|
||||
}>()
|
||||
|
||||
const currentPath = ref('')
|
||||
const sortField = ref<FileTreeSelectSortField>('name')
|
||||
const sortDesc = ref(false)
|
||||
const entryNameRefs = ref<Record<string, HTMLElement | null>>({})
|
||||
const isHomePath = computed(() => currentPath.value === '')
|
||||
const parentPath = computed(() => currentPath.value.split('/').slice(0, -1).join('/'))
|
||||
const initialMinimumRowCount = ref(0)
|
||||
|
||||
const normalizedItems = computed(() => {
|
||||
const items = new Map<string, NormalizedFileTreeSelectItem>()
|
||||
for (const item of props.items) {
|
||||
const normalizedPath = normalizePath(item.path)
|
||||
if (!normalizedPath) continue
|
||||
items.set(normalizedPath, {
|
||||
...item,
|
||||
path: item.path,
|
||||
name: getName(normalizedPath),
|
||||
normalizedPath,
|
||||
})
|
||||
}
|
||||
return [...items.values()]
|
||||
})
|
||||
|
||||
const selectedPaths = computed(() => new Set(props.modelValue.map((path) => normalizePath(path))))
|
||||
|
||||
const folderPaths = computed(() => {
|
||||
const paths = new Set<string>()
|
||||
for (const item of normalizedItems.value) {
|
||||
const segments = item.normalizedPath.split('/')
|
||||
for (let i = 1; i < segments.length; i++) {
|
||||
paths.add(segments.slice(0, i).join('/'))
|
||||
}
|
||||
if (item.type === 'directory') {
|
||||
paths.add(item.normalizedPath)
|
||||
}
|
||||
}
|
||||
return paths
|
||||
})
|
||||
|
||||
const entries = computed<FileTreeSelectEntry[]>(() => {
|
||||
const directories = new Map<string, FileTreeSelectEntry>()
|
||||
const files: FileTreeSelectEntry[] = []
|
||||
const currentSegments = currentPath.value ? currentPath.value.split('/') : []
|
||||
|
||||
for (const item of normalizedItems.value) {
|
||||
const segments = item.normalizedPath.split('/')
|
||||
if (!isInCurrentPath(segments, currentSegments)) continue
|
||||
|
||||
const remaining = segments.slice(currentSegments.length)
|
||||
if (remaining.length > 1) {
|
||||
const directoryName = remaining[0]
|
||||
const directoryPath = [...currentSegments, directoryName].join('/')
|
||||
if (!directories.has(directoryPath)) {
|
||||
directories.set(directoryPath, buildDirectoryEntry(directoryPath, directoryName))
|
||||
}
|
||||
} else if (remaining.length === 1) {
|
||||
if (item.type === 'directory') {
|
||||
directories.set(
|
||||
item.normalizedPath,
|
||||
buildDirectoryEntry(item.normalizedPath, item.name, item),
|
||||
)
|
||||
} else {
|
||||
files.push(buildFileEntry(item))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...directories.values(), ...files].sort(compareEntries)
|
||||
})
|
||||
|
||||
const visibleSelectableEntries = computed(() => entries.value.filter((entry) => !entry.disabled))
|
||||
|
||||
const visibleSelectablePaths = computed(() => {
|
||||
const paths = new Set<string>()
|
||||
for (const entry of visibleSelectableEntries.value) {
|
||||
for (const path of getEntrySelectablePaths(entry)) {
|
||||
paths.add(path)
|
||||
}
|
||||
}
|
||||
return [...paths]
|
||||
})
|
||||
|
||||
const allVisibleSelected = computed(
|
||||
() =>
|
||||
visibleSelectablePaths.value.length > 0 &&
|
||||
visibleSelectablePaths.value.every((path) => selectedPaths.value.has(path)),
|
||||
)
|
||||
|
||||
const someVisibleSelected = computed(() =>
|
||||
visibleSelectablePaths.value.some((path) => selectedPaths.value.has(path)),
|
||||
)
|
||||
|
||||
const visibleRowCount = computed(
|
||||
() => entries.value.length + (isHomePath.value ? 0 : 1) + (entries.value.length === 0 ? 1 : 0),
|
||||
)
|
||||
|
||||
const fillerRowCount = computed(() =>
|
||||
Math.max(0, initialMinimumRowCount.value - visibleRowCount.value),
|
||||
)
|
||||
|
||||
watch(
|
||||
normalizedItems,
|
||||
() => {
|
||||
if (currentPath.value && !folderPaths.value.has(currentPath.value)) {
|
||||
currentPath.value = ''
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
visibleRowCount,
|
||||
(rowCount) => {
|
||||
if (isHomePath.value && rowCount > 1 && initialMinimumRowCount.value === 0) {
|
||||
initialMinimumRowCount.value = rowCount
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
isHomePath,
|
||||
() => {
|
||||
if (isHomePath.value && visibleRowCount.value > 1 && initialMinimumRowCount.value === 0) {
|
||||
initialMinimumRowCount.value = visibleRowCount.value
|
||||
}
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
function normalizePath(path: string) {
|
||||
return path.replaceAll('\\', '/').split('/').filter(Boolean).join('/')
|
||||
}
|
||||
|
||||
function getName(path: string) {
|
||||
return path.split('/').pop() ?? path
|
||||
}
|
||||
|
||||
function isInCurrentPath(segments: string[], currentSegments: string[]) {
|
||||
if (segments.length <= currentSegments.length) return false
|
||||
return currentSegments.every((segment, index) => segments[index] === segment)
|
||||
}
|
||||
|
||||
function handleSort(field: FileTreeSelectSortField) {
|
||||
if (sortField.value === field) {
|
||||
sortDesc.value = !sortDesc.value
|
||||
} else {
|
||||
sortField.value = field
|
||||
sortDesc.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getRowBackgroundClass(index: number) {
|
||||
return index % 2 === 0 ? 'bg-surface-2' : 'bg-surface-1.5'
|
||||
}
|
||||
|
||||
function compareEntries(a: FileTreeSelectEntry, b: FileTreeSelectEntry) {
|
||||
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1
|
||||
|
||||
switch (sortField.value) {
|
||||
case 'modified':
|
||||
return sortDesc.value
|
||||
? getModifiedSortValue(a) - getModifiedSortValue(b)
|
||||
: getModifiedSortValue(b) - getModifiedSortValue(a)
|
||||
case 'size':
|
||||
return sortDesc.value
|
||||
? getSizeSortValue(a) - getSizeSortValue(b)
|
||||
: getSizeSortValue(b) - getSizeSortValue(a)
|
||||
default:
|
||||
return sortDesc.value
|
||||
? b.name.localeCompare(a.name, undefined, { numeric: true, sensitivity: 'base' })
|
||||
: a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' })
|
||||
}
|
||||
}
|
||||
|
||||
function getSizeSortValue(entry: FileTreeSelectEntry) {
|
||||
return entry.type === 'directory' ? (entry.count ?? 0) : (entry.size ?? 0)
|
||||
}
|
||||
|
||||
function getModifiedSortValue(entry: FileTreeSelectEntry) {
|
||||
return entry.modified ?? 0
|
||||
}
|
||||
|
||||
function getFolderDescendants(path: string) {
|
||||
return normalizedItems.value.filter((item) => item.normalizedPath.startsWith(`${path}/`))
|
||||
}
|
||||
|
||||
function getFolderChildCount(path: string) {
|
||||
const children = new Set<string>()
|
||||
const prefix = `${path}/`
|
||||
for (const item of normalizedItems.value) {
|
||||
if (!item.normalizedPath.startsWith(prefix)) continue
|
||||
|
||||
const relativePath = item.normalizedPath.slice(prefix.length)
|
||||
const [childName] = relativePath.split('/')
|
||||
if (childName) {
|
||||
children.add(childName)
|
||||
}
|
||||
}
|
||||
return children.size
|
||||
}
|
||||
|
||||
function getLatestModified(items: NormalizedFileTreeSelectItem[]) {
|
||||
const modified = items
|
||||
.map((item) => item.modified)
|
||||
.filter((value): value is number => typeof value === 'number' && Number.isFinite(value))
|
||||
|
||||
if (modified.length === 0) return undefined
|
||||
return Math.max(...modified)
|
||||
}
|
||||
|
||||
function buildDirectoryEntry(
|
||||
path: string,
|
||||
name: string,
|
||||
item?: NormalizedFileTreeSelectItem,
|
||||
): FileTreeSelectEntry {
|
||||
const descendants = getFolderDescendants(path).filter((item) => !item.disabled)
|
||||
const selectedCount = descendants.filter((item) =>
|
||||
selectedPaths.value.has(item.normalizedPath),
|
||||
).length
|
||||
const selected = selectedPaths.value.has(path)
|
||||
|
||||
return {
|
||||
path,
|
||||
name,
|
||||
type: 'directory',
|
||||
icon: getDirectoryIcon(name),
|
||||
checked: selected || (descendants.length > 0 && selectedCount === descendants.length),
|
||||
indeterminate: !selected && selectedCount > 0 && selectedCount < descendants.length,
|
||||
disabled: item?.disabled ?? descendants.length === 0,
|
||||
modified: item?.modified ?? getLatestModified(descendants),
|
||||
count: item?.count ?? getFolderChildCount(path),
|
||||
item,
|
||||
}
|
||||
}
|
||||
|
||||
function buildFileEntry(item: NormalizedFileTreeSelectItem): FileTreeSelectEntry {
|
||||
return {
|
||||
path: item.normalizedPath,
|
||||
name: item.name,
|
||||
type: 'file',
|
||||
icon: getFileIcon(item.name),
|
||||
checked: selectedPaths.value.has(item.normalizedPath),
|
||||
indeterminate: false,
|
||||
disabled: item.disabled ?? false,
|
||||
size: item.size,
|
||||
modified: item.modified,
|
||||
item,
|
||||
}
|
||||
}
|
||||
|
||||
function navigateTo(path: string) {
|
||||
currentPath.value = path
|
||||
emit('navigate', path)
|
||||
}
|
||||
|
||||
function setEntryNameRef(path: string, element: Element | ComponentPublicInstance | null) {
|
||||
if (element instanceof HTMLElement) {
|
||||
entryNameRefs.value[path] = element
|
||||
} else {
|
||||
entryNameRefs.value[path] = null
|
||||
}
|
||||
}
|
||||
|
||||
function selectEntry(entry: FileTreeSelectEntry) {
|
||||
if (entry.type === 'directory') {
|
||||
navigateTo(entry.path)
|
||||
} else {
|
||||
toggleEntry(entry, !entry.checked)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleEntry(entry: FileTreeSelectEntry, selected: boolean) {
|
||||
if (entry.disabled) return
|
||||
|
||||
const nextSelectedPaths = new Set(selectedPaths.value)
|
||||
const paths = getEntrySelectablePaths(entry)
|
||||
|
||||
for (const path of paths) {
|
||||
if (selected) {
|
||||
nextSelectedPaths.add(path)
|
||||
} else {
|
||||
nextSelectedPaths.delete(path)
|
||||
}
|
||||
}
|
||||
|
||||
emit('update:modelValue', [...nextSelectedPaths])
|
||||
}
|
||||
|
||||
function getEntrySelectablePaths(entry: FileTreeSelectEntry) {
|
||||
if (entry.type === 'directory') {
|
||||
return entry.item?.type === 'directory'
|
||||
? [entry.path]
|
||||
: getFolderDescendants(entry.path)
|
||||
.filter((item) => !item.disabled)
|
||||
.map((item) => item.normalizedPath)
|
||||
}
|
||||
|
||||
return [entry.path]
|
||||
}
|
||||
|
||||
function toggleAllVisible(selected: boolean) {
|
||||
const nextSelectedPaths = new Set(selectedPaths.value)
|
||||
for (const path of visibleSelectablePaths.value) {
|
||||
if (selected) {
|
||||
nextSelectedPaths.add(path)
|
||||
} else {
|
||||
nextSelectedPaths.delete(path)
|
||||
}
|
||||
}
|
||||
|
||||
emit('update:modelValue', [...nextSelectedPaths])
|
||||
}
|
||||
|
||||
function formatSize(entry: FileTreeSelectEntry) {
|
||||
if (entry.type === 'directory') {
|
||||
return formatMessage(messages.itemCount, { count: entry.count ?? 0 })
|
||||
}
|
||||
|
||||
if (entry.size === undefined) return ''
|
||||
return formatBytes(entry.size)
|
||||
}
|
||||
|
||||
function formatModified(entry: FileTreeSelectEntry) {
|
||||
if (entry.modified === undefined) return ''
|
||||
return formatDateTime(new Date(entry.modified * 1000))
|
||||
}
|
||||
</script>
|
||||
57
packages/ui/src/components/base/FilterBar.vue
Normal file
57
packages/ui/src/components/base/FilterBar.vue
Normal file
@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="(showAllOptions && options.length > 0) || options.length > 1"
|
||||
class="flex flex-wrap gap-1 items-center"
|
||||
>
|
||||
<FilterIcon class="text-secondary h-5 w-5 mr-1" />
|
||||
<button
|
||||
v-for="filter in options"
|
||||
:key="`filter-${filter.id}`"
|
||||
:class="`px-2 py-1 rounded-full font-semibold leading-none border-none cursor-pointer active:scale-[0.97] duration-100 transition-all ${selectedFilters.includes(filter.id) ? 'bg-brand-highlight text-brand' : 'bg-bg-raised text-secondary'}`"
|
||||
@click="toggleFilter(filter.id)"
|
||||
>
|
||||
{{ formatMessage(filter.message) }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FilterIcon } from '@modrinth/assets'
|
||||
import { watch } from 'vue'
|
||||
|
||||
import { type MessageDescriptor, useVIntl } from '../../composables/i18n'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
export type FilterBarOption = {
|
||||
id: string
|
||||
message: MessageDescriptor
|
||||
}
|
||||
|
||||
const selectedFilters = defineModel<string[]>({ required: true })
|
||||
|
||||
const props = defineProps<{
|
||||
options: FilterBarOption[]
|
||||
showAllOptions?: boolean
|
||||
}>()
|
||||
|
||||
watch(
|
||||
() => props.options,
|
||||
() => {
|
||||
for (let i = 0; i < selectedFilters.value.length; i++) {
|
||||
const option = selectedFilters.value[i]
|
||||
if (!props.options.some((x) => x.id === option)) {
|
||||
selectedFilters.value.splice(i, 1)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function toggleFilter(option: string) {
|
||||
if (selectedFilters.value.includes(option)) {
|
||||
selectedFilters.value.splice(selectedFilters.value.indexOf(option), 1)
|
||||
} else {
|
||||
selectedFilters.value.push(option)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
53
packages/ui/src/components/base/FilterPills.vue
Normal file
53
packages/ui/src/components/base/FilterPills.vue
Normal file
@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<FilterIcon class="size-5 text-secondary" />
|
||||
<button
|
||||
:class="pillClass(modelValue.length === 0)"
|
||||
:aria-pressed="modelValue.length === 0"
|
||||
@click="modelValue = []"
|
||||
>
|
||||
<slot name="all"> All </slot>
|
||||
</button>
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="option.id"
|
||||
:class="pillClass(modelValue.includes(option.id))"
|
||||
:aria-pressed="modelValue.includes(option.id)"
|
||||
@click="toggle(option.id)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FilterIcon } from '@modrinth/assets'
|
||||
|
||||
export interface FilterPillOption {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const modelValue = defineModel<string[]>({ required: true })
|
||||
|
||||
defineProps<{
|
||||
options: FilterPillOption[]
|
||||
}>()
|
||||
|
||||
function pillClass(active: boolean) {
|
||||
return [
|
||||
'cursor-pointer rounded-full border border-solid px-3 py-1.5 text-base font-semibold leading-5 transition-all duration-100 active:scale-[0.97]',
|
||||
active
|
||||
? 'border-brand bg-brand-highlight text-brand'
|
||||
: 'border-surface-5 bg-surface-4 text-primary hover:bg-surface-5',
|
||||
]
|
||||
}
|
||||
|
||||
function toggle(id: string) {
|
||||
if (modelValue.value.includes(id)) {
|
||||
modelValue.value = modelValue.value.filter((f) => f !== id)
|
||||
} else {
|
||||
modelValue.value = [...modelValue.value, id]
|
||||
}
|
||||
}
|
||||
</script>
|
||||
310
packages/ui/src/components/base/FloatingActionBar.vue
Normal file
310
packages/ui/src/components/base/FloatingActionBar.vue
Normal file
@ -0,0 +1,310 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { useModalStack } from '../../composables/modal-stack'
|
||||
import { injectPageContext } from '../../providers'
|
||||
|
||||
const visibleFloatingActionBars = new Map<symbol, number>()
|
||||
|
||||
function updateFloatingActionBarDocumentState() {
|
||||
if (typeof document === 'undefined') return
|
||||
|
||||
document.body.classList.toggle('floating-action-bar-shown', visibleFloatingActionBars.size > 0)
|
||||
const clearance = Math.max(0, ...visibleFloatingActionBars.values())
|
||||
if (clearance > 0) {
|
||||
document.documentElement.style.setProperty('--floating-action-bar-clearance', `${clearance}px`)
|
||||
} else {
|
||||
document.documentElement.style.removeProperty('--floating-action-bar-clearance')
|
||||
}
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
shown: boolean
|
||||
ariaLabel?: string
|
||||
hideWhenModalOpen?: boolean
|
||||
position?: 'bottom' | 'top'
|
||||
allowOverflow?: boolean
|
||||
}>()
|
||||
|
||||
const INTERCOM_BUBBLE_GAP = 8
|
||||
|
||||
const barEl = ref<HTMLElement | null>(null)
|
||||
const toolbarEl = ref<HTMLElement | null>(null)
|
||||
const compact = ref(false)
|
||||
|
||||
const { stackCount } = useModalStack()
|
||||
const pageContext = injectPageContext(null)
|
||||
const shown = computed(() => props.shown && (!props.hideWhenModalOpen || stackCount.value === 0))
|
||||
const floatingActionBarId = Symbol('floating-action-bar')
|
||||
const intercomBubbleClearanceRequestId = Symbol('floating-action-bar')
|
||||
const zIndex = computed(() => 100 + stackCount.value * 10 + 7)
|
||||
const leftOffset = computed(
|
||||
() => pageContext?.floatingActionBarOffsets?.left.value ?? 'var(--left-bar-width, 0px)',
|
||||
)
|
||||
const scrollbarWidth = ref(0)
|
||||
|
||||
const rightOffset = computed(() => {
|
||||
const base = pageContext?.floatingActionBarOffsets?.right.value ?? 'var(--right-bar-width, 0px)'
|
||||
if (stackCount.value > 0) {
|
||||
return `calc(${base} + ${scrollbarWidth.value}px)`
|
||||
}
|
||||
return base
|
||||
})
|
||||
const barStyle = computed(() => ({
|
||||
zIndex: zIndex.value,
|
||||
'--floating-action-bar-left-offset': leftOffset.value,
|
||||
'--floating-action-bar-right-offset': rightOffset.value,
|
||||
}))
|
||||
|
||||
const barClasses = computed(() => ({
|
||||
'bottom-0': !props.position || props.position === 'bottom',
|
||||
'top-12': props.position === 'top',
|
||||
}))
|
||||
|
||||
function checkCompact() {
|
||||
const el = toolbarEl.value
|
||||
if (!el) return
|
||||
|
||||
const clone = el.cloneNode(true) as HTMLElement
|
||||
clone.classList.remove('bar-compact')
|
||||
clone.style.position = 'absolute'
|
||||
clone.style.visibility = 'hidden'
|
||||
clone.style.pointerEvents = 'none'
|
||||
clone.style.width = `${el.offsetWidth}px`
|
||||
|
||||
el.parentElement!.appendChild(clone)
|
||||
const needsCompact = clone.offsetHeight > 70
|
||||
clone.remove()
|
||||
|
||||
compact.value = needsCompact
|
||||
}
|
||||
|
||||
function clearIntercomBubbleClearance() {
|
||||
pageContext?.intercomBubble?.requestVerticalClearance(intercomBubbleClearanceRequestId, null)
|
||||
}
|
||||
|
||||
function updateIntercomBubbleClearance() {
|
||||
const intercomBubble = pageContext?.intercomBubble
|
||||
if (!intercomBubble) return
|
||||
|
||||
if (
|
||||
typeof window === 'undefined' ||
|
||||
!shown.value ||
|
||||
stackCount.value > 0 ||
|
||||
!barEl.value ||
|
||||
!toolbarEl.value
|
||||
) {
|
||||
clearIntercomBubbleClearance()
|
||||
return
|
||||
}
|
||||
|
||||
const barRect = barEl.value.getBoundingClientRect()
|
||||
const toolbarRight = barRect.left + toolbarEl.value.offsetLeft + toolbarEl.value.offsetWidth
|
||||
const bubbleLeft =
|
||||
window.innerWidth - intercomBubble.horizontalPadding.value - intercomBubble.width.value
|
||||
|
||||
if (toolbarRight + INTERCOM_BUBBLE_GAP <= bubbleLeft) {
|
||||
clearIntercomBubbleClearance()
|
||||
return
|
||||
}
|
||||
|
||||
const barStyle = window.getComputedStyle(barEl.value)
|
||||
const bottomOffset = Number.parseFloat(barStyle.bottom) || 0
|
||||
intercomBubble.requestVerticalClearance(
|
||||
intercomBubbleClearanceRequestId,
|
||||
Math.ceil(bottomOffset + barEl.value.offsetHeight + INTERCOM_BUBBLE_GAP),
|
||||
)
|
||||
}
|
||||
|
||||
function getBottomClearance() {
|
||||
if (
|
||||
typeof window === 'undefined' ||
|
||||
!barEl.value ||
|
||||
(props.position !== undefined && props.position !== 'bottom')
|
||||
) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return Math.max(0, Math.ceil(window.innerHeight - barEl.value.getBoundingClientRect().top))
|
||||
}
|
||||
|
||||
function updateFloatingActionBarState(isShown = shown.value) {
|
||||
if (isShown) {
|
||||
visibleFloatingActionBars.set(floatingActionBarId, getBottomClearance())
|
||||
} else {
|
||||
visibleFloatingActionBars.delete(floatingActionBarId)
|
||||
}
|
||||
|
||||
updateFloatingActionBarDocumentState()
|
||||
if (!isShown) {
|
||||
clearIntercomBubbleClearance()
|
||||
}
|
||||
}
|
||||
|
||||
let observer: ResizeObserver | null = null
|
||||
let updateFrame: number | null = null
|
||||
|
||||
function scheduleFloatingActionBarLayoutUpdate() {
|
||||
if (typeof window === 'undefined') return
|
||||
if (updateFrame !== null) {
|
||||
window.cancelAnimationFrame(updateFrame)
|
||||
}
|
||||
|
||||
updateFrame = window.requestAnimationFrame(() => {
|
||||
updateFrame = null
|
||||
updateFloatingActionBarState()
|
||||
updateIntercomBubbleClearance()
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
toolbarEl,
|
||||
(el) => {
|
||||
observer?.disconnect()
|
||||
if (!el) return
|
||||
observer = new ResizeObserver(() => {
|
||||
checkCompact()
|
||||
scheduleFloatingActionBarLayoutUpdate()
|
||||
})
|
||||
observer.observe(el.parentElement!)
|
||||
checkCompact()
|
||||
scheduleFloatingActionBarLayoutUpdate()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
shown,
|
||||
async (isShown) => {
|
||||
await nextTick()
|
||||
updateFloatingActionBarState(isShown)
|
||||
scheduleFloatingActionBarLayoutUpdate()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
[
|
||||
shown,
|
||||
leftOffset,
|
||||
rightOffset,
|
||||
stackCount,
|
||||
() => pageContext?.intercomBubble?.horizontalPadding.value,
|
||||
() => pageContext?.intercomBubble?.width.value,
|
||||
],
|
||||
() => scheduleFloatingActionBarLayoutUpdate(),
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function handleResize() {
|
||||
if (stackCount.value === 0) {
|
||||
scrollbarWidth.value = window.innerWidth - document.documentElement.clientWidth
|
||||
}
|
||||
scheduleFloatingActionBarLayoutUpdate()
|
||||
}
|
||||
|
||||
function handleTransitionEnd(event: TransitionEvent) {
|
||||
if (event.target === barEl.value && event.propertyName === 'bottom') {
|
||||
scheduleFloatingActionBarLayoutUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
scrollbarWidth.value = window.innerWidth - document.documentElement.clientWidth
|
||||
window.addEventListener('resize', handleResize)
|
||||
scheduleFloatingActionBarLayoutUpdate()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
observer?.disconnect()
|
||||
window.removeEventListener('resize', handleResize)
|
||||
if (updateFrame !== null) {
|
||||
window.cancelAnimationFrame(updateFrame)
|
||||
}
|
||||
clearIntercomBubbleClearance()
|
||||
visibleFloatingActionBars.delete(floatingActionBarId)
|
||||
updateFloatingActionBarDocumentState()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="floating-action-bar" appear>
|
||||
<div
|
||||
v-if="shown"
|
||||
ref="barEl"
|
||||
class="floating-action-bar pointer-events-none drop-shadow-2xl fixed p-4"
|
||||
:class="barClasses"
|
||||
:style="barStyle"
|
||||
aria-live="polite"
|
||||
@transitionend.self="handleTransitionEnd"
|
||||
>
|
||||
<div
|
||||
ref="toolbarEl"
|
||||
role="toolbar"
|
||||
:aria-label="ariaLabel"
|
||||
class="pointer-events-auto relative flex items-center gap-1.5 rounded-[20px] bg-surface-3 border border-surface-5 border-solid mx-auto md:max-w-[60vw] px-3 py-2.5 shadow-[0px_1px_3px_0px_rgba(0,0,0,0.3),0px_6px_10px_0px_rgba(0,0,0,0.15)]"
|
||||
:class="{
|
||||
'overflow-visible': allowOverflow,
|
||||
'overflow-clip': !allowOverflow,
|
||||
'bar-compact': compact,
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.floating-action-bar {
|
||||
left: var(--floating-action-bar-left-offset, var(--left-bar-width, 0px));
|
||||
right: var(--floating-action-bar-right-offset, var(--right-bar-width, 0px));
|
||||
transition:
|
||||
bottom 0.25s ease-in-out,
|
||||
top 0.25s ease-in-out;
|
||||
}
|
||||
|
||||
.floating-action-bar-enter-active {
|
||||
transition:
|
||||
transform 0.25s cubic-bezier(0.15, 1.4, 0.64, 0.96),
|
||||
opacity 0.25s cubic-bezier(0.15, 1.4, 0.64, 0.96);
|
||||
}
|
||||
|
||||
.floating-action-bar-leave-active {
|
||||
transition:
|
||||
transform 0.25s ease,
|
||||
opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.floating-action-bar-enter-from {
|
||||
transform: scale(0.5) translateY(-10rem);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.floating-action-bar-leave-to {
|
||||
transform: scale(0.96) translateY(-0.25rem);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (any-hover: none) and (max-width: 640px) {
|
||||
.floating-action-bar.bottom-0 {
|
||||
bottom: var(--size-mobile-navbar-height);
|
||||
}
|
||||
|
||||
.expanded-mobile-nav .floating-action-bar.bottom-0 {
|
||||
bottom: var(--size-mobile-navbar-height-expanded);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.bar-compact .bar-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bar-compact .cq-show-icon {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
314
packages/ui/src/components/base/FloatingPanel.vue
Normal file
314
packages/ui/src/components/base/FloatingPanel.vue
Normal file
@ -0,0 +1,314 @@
|
||||
<script setup lang="ts">
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import ButtonStyled from './ButtonStyled.vue'
|
||||
|
||||
const PANEL_VIEWPORT_MARGIN = 8
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
placement?: 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end'
|
||||
distance?: number
|
||||
disabled?: boolean
|
||||
buttonClass?: string
|
||||
panelClass?: string
|
||||
autoFocus?: boolean
|
||||
}>(),
|
||||
{
|
||||
placement: 'bottom-end',
|
||||
distance: 8,
|
||||
disabled: false,
|
||||
autoFocus: true,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
open: []
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const triggerRef = ref<HTMLElement>()
|
||||
const panelRef = ref<HTMLElement>()
|
||||
const rafId = ref<number | null>(null)
|
||||
|
||||
const openDirection = ref<'up' | 'down'>('down')
|
||||
const horizontalAlignment = ref<'start' | 'end'>('end')
|
||||
|
||||
const panelStyle = ref({
|
||||
top: '0px',
|
||||
left: '0px',
|
||||
})
|
||||
|
||||
const transformOrigin = computed(() => {
|
||||
const vertical = openDirection.value === 'down' ? 'top' : 'bottom'
|
||||
const horizontal = horizontalAlignment.value === 'end' ? 'right' : 'left'
|
||||
return `${vertical} ${horizontal}`
|
||||
})
|
||||
|
||||
function determineOpenDirection(
|
||||
triggerRect: DOMRect,
|
||||
panelRect: DOMRect,
|
||||
viewportHeight: number,
|
||||
): 'up' | 'down' {
|
||||
const preferDown = props.placement.startsWith('bottom')
|
||||
|
||||
const hasSpaceBelow =
|
||||
triggerRect.bottom + props.distance + panelRect.height + PANEL_VIEWPORT_MARGIN <= viewportHeight
|
||||
const hasSpaceAbove =
|
||||
triggerRect.top - props.distance - panelRect.height - PANEL_VIEWPORT_MARGIN >= 0
|
||||
|
||||
if (preferDown) {
|
||||
return hasSpaceBelow ? 'down' : hasSpaceAbove ? 'up' : 'down'
|
||||
} else {
|
||||
return hasSpaceAbove ? 'up' : hasSpaceBelow ? 'down' : 'up'
|
||||
}
|
||||
}
|
||||
|
||||
function calculateVerticalPosition(
|
||||
triggerRect: DOMRect,
|
||||
panelRect: DOMRect,
|
||||
direction: 'up' | 'down',
|
||||
): number {
|
||||
return direction === 'up'
|
||||
? triggerRect.top - panelRect.height - props.distance
|
||||
: triggerRect.bottom + props.distance
|
||||
}
|
||||
|
||||
function calculateHorizontalPosition(
|
||||
triggerRect: DOMRect,
|
||||
panelRect: DOMRect,
|
||||
viewportWidth: number,
|
||||
): number {
|
||||
const alignEnd = props.placement.endsWith('end')
|
||||
let left: number
|
||||
|
||||
if (alignEnd) {
|
||||
left = triggerRect.right - panelRect.width
|
||||
} else {
|
||||
left = triggerRect.left
|
||||
}
|
||||
|
||||
if (left + panelRect.width > viewportWidth - PANEL_VIEWPORT_MARGIN) {
|
||||
left = Math.max(PANEL_VIEWPORT_MARGIN, viewportWidth - panelRect.width - PANEL_VIEWPORT_MARGIN)
|
||||
}
|
||||
if (left < PANEL_VIEWPORT_MARGIN) {
|
||||
left = PANEL_VIEWPORT_MARGIN
|
||||
}
|
||||
|
||||
return left
|
||||
}
|
||||
|
||||
async function updatePanelPosition() {
|
||||
if (!triggerRef.value || !panelRef.value) return
|
||||
|
||||
await nextTick()
|
||||
|
||||
const triggerRect = triggerRef.value.getBoundingClientRect()
|
||||
const panelRect = panelRef.value.getBoundingClientRect()
|
||||
const viewportHeight = window.innerHeight
|
||||
const viewportWidth = window.innerWidth
|
||||
|
||||
const direction = determineOpenDirection(triggerRect, panelRect, viewportHeight)
|
||||
const top = calculateVerticalPosition(triggerRect, panelRect, direction)
|
||||
const left = calculateHorizontalPosition(triggerRect, panelRect, viewportWidth)
|
||||
|
||||
panelStyle.value = {
|
||||
top: `${top}px`,
|
||||
left: `${left}px`,
|
||||
}
|
||||
|
||||
openDirection.value = direction
|
||||
horizontalAlignment.value = props.placement.endsWith('end') ? 'end' : 'start'
|
||||
}
|
||||
|
||||
function startPositionTracking() {
|
||||
function track() {
|
||||
updatePanelPosition()
|
||||
rafId.value = requestAnimationFrame(track)
|
||||
}
|
||||
rafId.value = requestAnimationFrame(track)
|
||||
}
|
||||
|
||||
function stopPositionTracking() {
|
||||
if (rafId.value !== null) {
|
||||
cancelAnimationFrame(rafId.value)
|
||||
rafId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function focusPanelContent() {
|
||||
if (!panelRef.value) return
|
||||
|
||||
const focusable = panelRef.value.querySelector<HTMLElement>(
|
||||
'button:not([data-focus-trap]), [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
)
|
||||
if (focusable) {
|
||||
focusable.focus()
|
||||
}
|
||||
}
|
||||
|
||||
async function open() {
|
||||
if (props.disabled || isOpen.value) return
|
||||
|
||||
isOpen.value = true
|
||||
emit('open')
|
||||
|
||||
await nextTick()
|
||||
await updatePanelPosition()
|
||||
startPositionTracking()
|
||||
|
||||
if (props.autoFocus) {
|
||||
setTimeout(() => {
|
||||
focusPanelContent()
|
||||
}, 50)
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!isOpen.value) return
|
||||
|
||||
stopPositionTracking()
|
||||
isOpen.value = false
|
||||
emit('close')
|
||||
|
||||
nextTick(() => {
|
||||
triggerRef.value?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (isOpen.value) {
|
||||
close()
|
||||
} else {
|
||||
open()
|
||||
}
|
||||
}
|
||||
|
||||
onClickOutside(
|
||||
panelRef,
|
||||
() => {
|
||||
close()
|
||||
},
|
||||
{ ignore: [triggerRef, '#teleports'] },
|
||||
)
|
||||
|
||||
function handleTriggerKeydown(event: KeyboardEvent) {
|
||||
switch (event.key) {
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
event.preventDefault()
|
||||
toggle()
|
||||
break
|
||||
case 'ArrowDown':
|
||||
event.preventDefault()
|
||||
open()
|
||||
break
|
||||
case 'ArrowUp':
|
||||
event.preventDefault()
|
||||
open()
|
||||
break
|
||||
case 'Escape':
|
||||
if (isOpen.value) {
|
||||
event.preventDefault()
|
||||
close()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function handlePanelKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
function handleWindowResize() {
|
||||
if (isOpen.value) {
|
||||
updatePanelPosition()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', handleWindowResize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleWindowResize)
|
||||
stopPositionTracking()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
open,
|
||||
close,
|
||||
toggle,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative inline-block">
|
||||
<ButtonStyled v-bind="$attrs">
|
||||
<button
|
||||
ref="triggerRef"
|
||||
:class="buttonClass"
|
||||
:disabled="disabled"
|
||||
:aria-expanded="isOpen"
|
||||
aria-haspopup="true"
|
||||
@click="toggle"
|
||||
@keydown="handleTriggerKeydown"
|
||||
>
|
||||
<slot></slot>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="floating-panel-enter-active"
|
||||
enter-from-class="floating-panel-enter-from"
|
||||
enter-to-class="floating-panel-enter-to"
|
||||
leave-active-class="floating-panel-leave-active"
|
||||
leave-from-class="floating-panel-leave-from"
|
||||
leave-to-class="floating-panel-leave-to"
|
||||
>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
ref="panelRef"
|
||||
class="fixed z-[9995] w-fit rounded-[14px] border border-surface-5 bg-surface-3 border-solid border-px p-3 shadow-2xl"
|
||||
:class="panelClass"
|
||||
:style="[panelStyle, { transformOrigin }]"
|
||||
role="dialog"
|
||||
tabindex="-1"
|
||||
@keydown="handlePanelKeydown"
|
||||
@mousedown.stop
|
||||
>
|
||||
<button class="sr-only" data-focus-trap @focusin="close"></button>
|
||||
<slot name="panel"></slot>
|
||||
<button class="sr-only" data-focus-trap @focusin="close"></button>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* .floating-panel-enter-active,
|
||||
.floating-panel-leave-active {
|
||||
transition:
|
||||
transform 0.125s ease-in-out,
|
||||
opacity 0.125s ease-in-out;
|
||||
}
|
||||
|
||||
.floating-panel-enter-from,
|
||||
.floating-panel-leave-to {
|
||||
transform: scale(0.85);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.floating-panel-enter-to,
|
||||
.floating-panel-leave-from {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
} */
|
||||
</style>
|
||||
19
packages/ui/src/components/base/FormattedTag.vue
Normal file
19
packages/ui/src/components/base/FormattedTag.vue
Normal file
@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useVIntl } from '../../composables'
|
||||
import { formatTag } from '../../utils/tag-messages.ts'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const props = defineProps<{
|
||||
tag: string
|
||||
enforceType?: 'loader' | 'category'
|
||||
}>()
|
||||
|
||||
const message = computed(() => formatTag(formatMessage, props.tag, props.enforceType))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
{{ message }}
|
||||
</template>
|
||||
21
packages/ui/src/components/base/HeadingLink.vue
Normal file
21
packages/ui/src/components/base/HeadingLink.vue
Normal file
@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<AutoLink
|
||||
:to="to"
|
||||
class="flex mb-3 leading-none items-center gap-1 text-primary text-lg font-bold hover:underline group w-fit"
|
||||
>
|
||||
<slot />
|
||||
<ChevronRightIcon
|
||||
class="h-5 w-5 stroke-[3px] group-hover:translate-x-1 transition-transform group-hover:text-brand"
|
||||
/>
|
||||
</AutoLink>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChevronRightIcon } from '@modrinth/assets'
|
||||
|
||||
import AutoLink from './AutoLink.vue'
|
||||
|
||||
defineProps<{
|
||||
to: unknown
|
||||
}>()
|
||||
</script>
|
||||
3
packages/ui/src/components/base/HorizontalRule.vue
Normal file
3
packages/ui/src/components/base/HorizontalRule.vue
Normal file
@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<div class="h-[1px] w-full bg-divider"></div>
|
||||
</template>
|
||||
542
packages/ui/src/components/base/I18nDebugPanel.vue
Normal file
542
packages/ui/src/components/base/I18nDebugPanel.vue
Normal file
@ -0,0 +1,542 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
EyeIcon,
|
||||
EyeOffIcon,
|
||||
MaximizeIcon,
|
||||
MinusIcon,
|
||||
ScanEyeIcon,
|
||||
SearchIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import { injectI18nDebug } from '../../composables/i18n-debug'
|
||||
import ButtonStyled from './ButtonStyled.vue'
|
||||
import StyledInput from './StyledInput.vue'
|
||||
|
||||
const debugContext = injectI18nDebug()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const minimized = ref(false)
|
||||
const copiedKey = ref<string | null>(null)
|
||||
const highlightedEl = ref<Element | null>(null)
|
||||
const searchInputRef = ref<HTMLInputElement | null>(null)
|
||||
const activeEntryIndex = ref(-1)
|
||||
const listContainerRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// Dragging state
|
||||
const isDragging = ref(false)
|
||||
const panelPos = ref({ x: -1, y: -1 })
|
||||
const dragOffset = ref({ x: 0, y: 0 })
|
||||
|
||||
// Resize state
|
||||
const isResizing = ref(false)
|
||||
const panelWidth = ref(380)
|
||||
const panelHeight = ref(420)
|
||||
const resizeStart = ref({ x: 0, y: 0, w: 0, h: 0 })
|
||||
|
||||
const filteredEntries = computed(() => {
|
||||
if (!debugContext) return []
|
||||
const entries = Array.from(debugContext.registry.values())
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
if (!q) return entries
|
||||
return entries.filter((e) => e.key.toLowerCase().includes(q) || e.value.toLowerCase().includes(q))
|
||||
})
|
||||
|
||||
const keyCount = computed(() => debugContext?.registry.size ?? 0)
|
||||
|
||||
// Reset active index when search changes
|
||||
watch(searchQuery, () => {
|
||||
activeEntryIndex.value = -1
|
||||
})
|
||||
|
||||
function truncate(str: string, max: number): string {
|
||||
return str.length > max ? str.slice(0, max) + '\u2026' : str
|
||||
}
|
||||
|
||||
function highlightMatch(text: string, query: string): string {
|
||||
if (!query) return escapeHtml(text)
|
||||
const escaped = escapeHtml(text)
|
||||
const q = escapeHtml(query)
|
||||
const regex = new RegExp(`(${q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi')
|
||||
return escaped.replace(regex, '<mark class="bg-brand/20 text-brand rounded-sm px-0.5">$1</mark>')
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
function toggleKeyReveal() {
|
||||
if (debugContext) {
|
||||
debugContext.keyReveal.value = !debugContext.keyReveal.value
|
||||
}
|
||||
}
|
||||
|
||||
function toggleOverlay() {
|
||||
if (debugContext?.enabled.value) {
|
||||
document.body.classList.toggle('i18n-debug')
|
||||
}
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
if (debugContext) {
|
||||
debugContext.panelOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function highlightElement(key: string) {
|
||||
clearHighlight()
|
||||
const el = document.querySelector(`[data-i18n-key="${CSS.escape(key)}"]`)
|
||||
if (el) {
|
||||
highlightedEl.value = el
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
;(el as HTMLElement).style.outline = '2px solid var(--color-brand)'
|
||||
;(el as HTMLElement).style.outlineOffset = '3px'
|
||||
;(el as HTMLElement).style.borderRadius = '4px'
|
||||
}
|
||||
}
|
||||
|
||||
function clearHighlight() {
|
||||
if (highlightedEl.value) {
|
||||
;(highlightedEl.value as HTMLElement).style.outline = ''
|
||||
;(highlightedEl.value as HTMLElement).style.outlineOffset = ''
|
||||
;(highlightedEl.value as HTMLElement).style.borderRadius = ''
|
||||
highlightedEl.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function copyKey(key: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(key)
|
||||
copiedKey.value = key
|
||||
setTimeout(() => {
|
||||
copiedKey.value = null
|
||||
}, 2000)
|
||||
} catch {
|
||||
// clipboard not available
|
||||
}
|
||||
}
|
||||
|
||||
function onPanelKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
activeEntryIndex.value = Math.min(activeEntryIndex.value + 1, filteredEntries.value.length - 1)
|
||||
scrollActiveIntoView()
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
activeEntryIndex.value = Math.max(activeEntryIndex.value - 1, 0)
|
||||
scrollActiveIntoView()
|
||||
} else if (e.key === 'Enter' && activeEntryIndex.value >= 0) {
|
||||
e.preventDefault()
|
||||
const entry = filteredEntries.value[activeEntryIndex.value]
|
||||
if (entry) copyKey(entry.key)
|
||||
} else if (e.key === 'Escape') {
|
||||
if (searchQuery.value) {
|
||||
searchQuery.value = ''
|
||||
} else {
|
||||
closePanel()
|
||||
}
|
||||
} else if (e.key === '/' && document.activeElement !== searchInputRef.value) {
|
||||
e.preventDefault()
|
||||
searchInputRef.value?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
function scrollActiveIntoView() {
|
||||
nextTick(() => {
|
||||
const activeEl = listContainerRef.value?.querySelector('[data-active="true"]')
|
||||
activeEl?.scrollIntoView({ block: 'nearest' })
|
||||
})
|
||||
}
|
||||
|
||||
// Drag handling
|
||||
function onHeaderMouseDown(e: MouseEvent) {
|
||||
if ((e.target as HTMLElement).closest('button')) return
|
||||
isDragging.value = true
|
||||
const panel = (e.currentTarget as HTMLElement).closest('.i18n-debug-panel') as HTMLElement
|
||||
if (panel) {
|
||||
const rect = panel.getBoundingClientRect()
|
||||
dragOffset.value = { x: e.clientX - rect.left, y: e.clientY - rect.top }
|
||||
}
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
}
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
if (!isDragging.value) return
|
||||
panelPos.value = {
|
||||
x: Math.max(0, Math.min(e.clientX - dragOffset.value.x, window.innerWidth - 100)),
|
||||
y: Math.max(0, Math.min(e.clientY - dragOffset.value.y, window.innerHeight - 60)),
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
isDragging.value = false
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
}
|
||||
|
||||
// Resize handling
|
||||
function onResizeMouseDown(e: MouseEvent) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
isResizing.value = true
|
||||
resizeStart.value = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
w: panelWidth.value,
|
||||
h: panelHeight.value,
|
||||
}
|
||||
document.addEventListener('mousemove', onResizeMove)
|
||||
document.addEventListener('mouseup', onResizeUp)
|
||||
}
|
||||
|
||||
function onResizeMove(e: MouseEvent) {
|
||||
if (!isResizing.value) return
|
||||
const dx = e.clientX - resizeStart.value.x
|
||||
const dy = e.clientY - resizeStart.value.y
|
||||
panelWidth.value = Math.max(320, Math.min(600, resizeStart.value.w + dx))
|
||||
panelHeight.value = Math.max(280, Math.min(700, resizeStart.value.h + dy))
|
||||
}
|
||||
|
||||
function onResizeUp() {
|
||||
isResizing.value = false
|
||||
document.removeEventListener('mousemove', onResizeMove)
|
||||
document.removeEventListener('mouseup', onResizeUp)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearHighlight()
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
document.removeEventListener('mousemove', onResizeMove)
|
||||
document.removeEventListener('mouseup', onResizeUp)
|
||||
})
|
||||
|
||||
const panelStyle = computed(() => {
|
||||
const base: Record<string, string> = {
|
||||
width: minimized.value ? 'auto' : `${panelWidth.value}px`,
|
||||
}
|
||||
if (panelPos.value.x >= 0 && panelPos.value.y >= 0) {
|
||||
base.left = `${panelPos.value.x}px`
|
||||
base.top = `${panelPos.value.y}px`
|
||||
base.right = 'auto'
|
||||
base.bottom = 'auto'
|
||||
} else {
|
||||
base.right = '20px'
|
||||
base.bottom = '20px'
|
||||
}
|
||||
return base
|
||||
})
|
||||
|
||||
const listMaxHeight = computed(() => `${panelHeight.value - 120}px`)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 translate-y-3 scale-95"
|
||||
enter-to-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-active-class="transition-all duration-150 ease-in"
|
||||
leave-from-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-to-class="opacity-0 translate-y-3 scale-95"
|
||||
>
|
||||
<div
|
||||
v-if="debugContext?.panelOpen.value"
|
||||
tabindex="-1"
|
||||
class="i18n-debug-panel fixed z-[9998] flex flex-col overflow-hidden rounded-xl border-2 border-solid border-surface-5 bg-surface-2 shadow-2xl outline-none"
|
||||
:class="{
|
||||
'cursor-grabbing': isDragging,
|
||||
'select-none': isDragging || isResizing,
|
||||
}"
|
||||
:style="panelStyle"
|
||||
@keydown="onPanelKeydown"
|
||||
>
|
||||
<!-- Resize handle (bottom-right corner) -->
|
||||
<div
|
||||
v-if="!minimized"
|
||||
class="absolute -bottom-0.5 -right-0.5 z-10 h-4 w-4 cursor-se-resize"
|
||||
@mousedown="onResizeMouseDown"
|
||||
>
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
class="absolute bottom-1 right-1 text-secondary/40"
|
||||
>
|
||||
<circle cx="8.5" cy="8.5" r="1" fill="currentColor" />
|
||||
<circle cx="5" cy="8.5" r="1" fill="currentColor" />
|
||||
<circle cx="8.5" cy="5" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex items-center gap-2.5 px-3.5 py-2.5 cursor-move select-none border-b border-surface-5/50"
|
||||
@mousedown="onHeaderMouseDown"
|
||||
>
|
||||
<!-- Title group -->
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex h-6 w-6 items-center justify-center rounded-md bg-brand/10">
|
||||
<ScanEyeIcon class="h-3.5 w-3.5 text-brand" />
|
||||
</div>
|
||||
<span class="text-[13px] font-semibold tracking-tight text-primary">
|
||||
i18n Inspector
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Key count badge -->
|
||||
<div class="flex items-center gap-1 rounded-full bg-surface-5/50 px-2 py-0.5">
|
||||
<span class="text-[11px] font-medium tabular-nums text-secondary">
|
||||
{{ keyCount }} {{ keyCount === 1 ? 'key' : 'keys' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="ml-auto flex items-center gap-0.5">
|
||||
<ButtonStyled circular type="transparent">
|
||||
<button
|
||||
v-tooltip="
|
||||
debugContext?.keyReveal.value ? 'Hide keys inline' : 'Reveal keys inline'
|
||||
"
|
||||
@click="toggleKeyReveal"
|
||||
>
|
||||
<component :is="debugContext?.keyReveal.value ? EyeOffIcon : EyeIcon" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular type="transparent">
|
||||
<button v-tooltip="'Toggle CSS debug overlay'" @click="toggleOverlay">
|
||||
<ScanEyeIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<div class="mx-0.5 h-4 w-px bg-surface-5/60" />
|
||||
|
||||
<ButtonStyled circular type="transparent">
|
||||
<button
|
||||
v-tooltip="minimized ? 'Expand panel' : 'Minimize panel'"
|
||||
@click="minimized = !minimized"
|
||||
>
|
||||
<component :is="minimized ? MaximizeIcon : MinusIcon" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular type="transparent">
|
||||
<button v-tooltip="'Close inspector'" @click="closePanel">
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body (hidden when minimized) -->
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-[600px]"
|
||||
leave-active-class="transition-all duration-150 ease-in"
|
||||
leave-from-class="opacity-100 max-h-[600px]"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div v-if="!minimized" class="flex flex-col overflow-hidden w-full">
|
||||
<!-- Search -->
|
||||
<div class="px-3 py-2.5 !w-full">
|
||||
<StyledInput
|
||||
ref="searchInputRef"
|
||||
v-model="searchQuery"
|
||||
placeholder="Search keys or values..."
|
||||
clearable
|
||||
:icon="SearchIcon"
|
||||
size="small"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Entry list -->
|
||||
<div
|
||||
ref="listContainerRef"
|
||||
class="overflow-y-auto overscroll-contain scroll-smooth"
|
||||
:style="{ maxHeight: listMaxHeight }"
|
||||
>
|
||||
<TransitionGroup
|
||||
move-class="transition-transform duration-200"
|
||||
enter-active-class="transition-all duration-150 ease-out"
|
||||
enter-from-class="opacity-0 -translate-x-2"
|
||||
enter-to-class="opacity-100 translate-x-0"
|
||||
leave-active-class="transition-all duration-100 ease-in absolute w-full"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-for="(entry, index) in filteredEntries"
|
||||
:key="entry.key"
|
||||
class="group relative flex items-center gap-2.5 px-3.5 py-2 transition-colors cursor-pointer"
|
||||
:class="[activeEntryIndex === index ? 'bg-brand/8' : 'hover:bg-surface-5/40']"
|
||||
:data-active="activeEntryIndex === index"
|
||||
@mouseenter="
|
||||
() => {
|
||||
highlightElement(entry.key)
|
||||
activeEntryIndex = index
|
||||
}
|
||||
"
|
||||
@mouseleave="clearHighlight"
|
||||
@click="copyKey(entry.key)"
|
||||
>
|
||||
<!-- Active indicator -->
|
||||
<div
|
||||
v-if="activeEntryIndex === index"
|
||||
class="absolute left-0 top-1/2 h-5 w-[3px] -translate-y-1/2 rounded-r-full bg-brand transition-all"
|
||||
/>
|
||||
|
||||
<!-- Entry content -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<div
|
||||
class="font-mono text-[12px] leading-relaxed text-primary truncate"
|
||||
:title="entry.key"
|
||||
v-html="highlightMatch(entry.key, searchQuery)"
|
||||
/>
|
||||
<div
|
||||
class="mt-0.5 text-[11px] leading-relaxed text-secondary truncate"
|
||||
:title="entry.value"
|
||||
v-html="highlightMatch(truncate(entry.value, 50), searchQuery)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<!-- Copied feedback -->
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-150 ease-out"
|
||||
enter-from-class="opacity-0 scale-90"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition-all duration-100"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0 scale-90"
|
||||
>
|
||||
<span
|
||||
v-if="copiedKey === entry.key"
|
||||
class="flex items-center gap-1 rounded-md bg-green/10 px-1.5 py-0.5 text-[10px] font-medium text-green"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
d="M3 8.5L6.5 12L13 4"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
Copied
|
||||
</span>
|
||||
</Transition>
|
||||
|
||||
<!-- Copy hint (shown on hover when not copied) -->
|
||||
<span
|
||||
v-if="copiedKey !== entry.key"
|
||||
class="text-[10px] text-secondary/0 transition-colors group-hover:text-secondary/60"
|
||||
>
|
||||
click to copy
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div
|
||||
v-if="filteredEntries.length === 0"
|
||||
class="flex flex-col items-center justify-center px-4 py-10"
|
||||
>
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-surface-5/40">
|
||||
<SearchIcon class="h-4 w-4 text-secondary/60" />
|
||||
</div>
|
||||
<p class="mt-3 text-[13px] font-medium text-primary">
|
||||
{{ searchQuery ? 'No matches found' : 'No keys registered' }}
|
||||
</p>
|
||||
<p class="mt-1 text-[11px] text-secondary">
|
||||
{{
|
||||
searchQuery
|
||||
? 'Try a different search term'
|
||||
: 'Navigate the app to discover i18n keys'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer status bar -->
|
||||
<div class="flex items-center justify-between border-t border-surface-5/50 px-3.5 py-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-1.5 w-1.5 rounded-full bg-green animate-pulse" />
|
||||
<span class="text-[11px] text-secondary"> Watching </span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-[10px] text-secondary/60">
|
||||
<kbd
|
||||
class="rounded border border-surface-5/40 bg-surface-3/60 px-1 py-px text-[10px]"
|
||||
>↑</kbd
|
||||
>
|
||||
<kbd
|
||||
class="rounded border border-surface-5/40 bg-surface-3/60 px-1 py-px text-[10px]"
|
||||
>↓</kbd
|
||||
>
|
||||
navigate
|
||||
</span>
|
||||
<span class="text-[10px] text-secondary/60">
|
||||
<kbd
|
||||
class="rounded border border-surface-5/40 bg-surface-3/60 px-1 py-px text-[10px]"
|
||||
>↵</kbd
|
||||
>
|
||||
copy
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.i18n-debug-panel {
|
||||
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
|
||||
-webkit-font-smoothing: antialiased;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(0, 0, 0, 0.03),
|
||||
0 2px 4px rgba(0, 0, 0, 0.04),
|
||||
0 12px 24px rgba(0, 0, 0, 0.12),
|
||||
0 24px 48px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
.i18n-debug-panel ::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.i18n-debug-panel ::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.i18n-debug-panel ::-webkit-scrollbar-thumb {
|
||||
background: var(--surface-5);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.i18n-debug-panel ::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
/* Animate the pulse indicator */
|
||||
@keyframes soft-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
animation: soft-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
</style>
|
||||
76
packages/ui/src/components/base/IconSelect.vue
Normal file
76
packages/ui/src/components/base/IconSelect.vue
Normal file
@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import { EditIcon, TrashIcon, UploadIcon } from '@modrinth/assets'
|
||||
|
||||
import { defineMessages, useVIntl } from '../../composables/i18n'
|
||||
import { Avatar, OverflowMenu } from '../index'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const icon = defineModel<string | undefined>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'select' | 'reset' | 'remove'): void
|
||||
}>()
|
||||
|
||||
type IconSelectOption = 'select' | 'replace' | 'reset' | 'remove'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
options?: IconSelectOption[]
|
||||
}>(),
|
||||
{
|
||||
options: () => ['select', 'replace', 'reset', 'remove'],
|
||||
},
|
||||
)
|
||||
|
||||
const messages = defineMessages({
|
||||
editIcon: {
|
||||
id: 'icon-select.edit',
|
||||
defaultMessage: 'Edit icon',
|
||||
},
|
||||
selectIcon: {
|
||||
id: 'icon-select.select',
|
||||
defaultMessage: 'Select icon',
|
||||
},
|
||||
replaceIcon: {
|
||||
id: 'icon-select.replace',
|
||||
defaultMessage: 'Replace icon',
|
||||
},
|
||||
removeIcon: {
|
||||
id: 'icon-select.remove',
|
||||
defaultMessage: 'Remove icon',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OverflowMenu
|
||||
v-tooltip="formatMessage(messages.editIcon)"
|
||||
class="m-0 cursor-pointer appearance-none border-none bg-transparent p-0 transition-transform group-active:scale-95"
|
||||
:options="[
|
||||
{
|
||||
id: 'select',
|
||||
action: () => emit('select'),
|
||||
},
|
||||
{
|
||||
id: 'remove',
|
||||
color: 'danger',
|
||||
action: () => emit('remove'),
|
||||
shown: !!icon,
|
||||
},
|
||||
]"
|
||||
>
|
||||
<Avatar :src="icon" size="108px" class="!border-4 group-hover:brightness-75" no-shadow />
|
||||
<div class="absolute right-0 top-0 m-2">
|
||||
<div
|
||||
class="hovering-icon-shadow m-0 flex aspect-square items-center justify-center rounded-full border-[1px] border-solid border-button-border bg-button-bg p-2 text-primary"
|
||||
>
|
||||
<EditIcon aria-hidden="true" class="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<template #select>
|
||||
<UploadIcon />
|
||||
{{ icon ? formatMessage(messages.replaceIcon) : formatMessage(messages.selectIcon) }}
|
||||
</template>
|
||||
<template #remove> <TrashIcon /> {{ formatMessage(messages.removeIcon) }} </template>
|
||||
</OverflowMenu>
|
||||
</template>
|
||||
27
packages/ui/src/components/base/InstanceRowCard.vue
Normal file
27
packages/ui/src/components/base/InstanceRowCard.vue
Normal file
@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
name: string
|
||||
version?: string | null
|
||||
loader?: string | null
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
select: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="flex w-full items-center gap-3 rounded-xl border border-surface-4 bg-surface-2 p-3 text-left transition-all duration-200 hover:cursor-pointer hover:border-brand hover:bg-brand-highlight active:scale-[0.98]"
|
||||
@click="$emit('select')"
|
||||
>
|
||||
<slot name="prepend" />
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<span class="truncate text-sm font-semibold text-contrast">{{ name }}</span>
|
||||
<span v-if="version || loader" class="truncate text-xs text-secondary">
|
||||
{{ version ?? '—' }}<template v-if="loader"> · {{ loader }}</template>
|
||||
</span>
|
||||
</div>
|
||||
<slot name="append" />
|
||||
</button>
|
||||
</template>
|
||||
103
packages/ui/src/components/base/IntlFormatted.vue
Normal file
103
packages/ui/src/components/base/IntlFormatted.vue
Normal file
@ -0,0 +1,103 @@
|
||||
<script setup lang="ts">
|
||||
import IntlMessageFormat, { type FormatXMLElementFn, type PrimitiveType } from 'intl-messageformat'
|
||||
import { computed, markRaw, useSlots, type VNode } from 'vue'
|
||||
|
||||
import type { MessageDescriptor } from '../../composables/i18n'
|
||||
import { injectI18nDebug } from '../../composables/i18n-debug'
|
||||
import { injectI18n } from '../../providers/i18n'
|
||||
|
||||
const props = defineProps<{
|
||||
messageId: MessageDescriptor
|
||||
values?: Record<string, PrimitiveType>
|
||||
}>()
|
||||
|
||||
const slots = useSlots()
|
||||
const { t, locale } = injectI18n()
|
||||
const debugContext = injectI18nDebug()
|
||||
|
||||
const debugEnabled = computed(() => debugContext?.enabled.value ?? false)
|
||||
const debugKeyReveal = computed(() => debugContext?.keyReveal.value ?? false)
|
||||
|
||||
const formattedParts = computed(() => {
|
||||
const key = props.messageId.id
|
||||
const translation = t(key, {}) as string
|
||||
|
||||
let msg: string
|
||||
if (translation && translation !== key) {
|
||||
msg = translation
|
||||
} else {
|
||||
msg = props.messageId.defaultMessage ?? key
|
||||
}
|
||||
|
||||
if (debugEnabled.value) {
|
||||
debugContext!.registry.set(key, {
|
||||
key,
|
||||
value: msg,
|
||||
defaultMessage: props.messageId.defaultMessage,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
if (debugKeyReveal.value) {
|
||||
return [`\u300C${key}\u300D`]
|
||||
}
|
||||
}
|
||||
|
||||
const slotHandlers: Record<string, FormatXMLElementFn<VNode>> = {}
|
||||
const slotNames = Object.keys(slots)
|
||||
|
||||
for (const slotName of slotNames) {
|
||||
const normalizedName = slotName.startsWith('~') ? slotName.slice(1) : slotName
|
||||
slotHandlers[normalizedName] = (chunks) => {
|
||||
const slot = slots[slotName]
|
||||
if (slot) {
|
||||
return markRaw(
|
||||
slot({
|
||||
children: chunks,
|
||||
}),
|
||||
) as VNode[]
|
||||
}
|
||||
return markRaw(chunks) as VNode[]
|
||||
}
|
||||
|
||||
msg = msg.replace(
|
||||
new RegExp(`\\{${normalizedName}\\}`, 'g'),
|
||||
`<${normalizedName}></${normalizedName}>`,
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const formatter = new IntlMessageFormat(msg, locale.value)
|
||||
const result = formatter.format({
|
||||
...props.values,
|
||||
...slotHandlers,
|
||||
})
|
||||
|
||||
// ensure result array items are marked as raw if they're VNodes
|
||||
// prevents VNodes from entering the reactive system and SSR payload
|
||||
if (Array.isArray(result)) {
|
||||
return result.map((part) =>
|
||||
typeof part === 'object' && part !== null ? markRaw(part) : part,
|
||||
)
|
||||
}
|
||||
return [typeof result === 'object' && result !== null ? markRaw(result) : result]
|
||||
} catch {
|
||||
return [msg]
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
v-if="debugEnabled && !debugKeyReveal"
|
||||
:data-i18n-key="messageId.id"
|
||||
style="display: contents"
|
||||
>
|
||||
<template v-for="(part, index) in formattedParts" :key="index">
|
||||
<component :is="() => part" v-if="typeof part === 'object'" />
|
||||
<template v-else>{{ part }}</template>
|
||||
</template>
|
||||
</span>
|
||||
<template v-for="(part, index) in formattedParts" v-else :key="index">
|
||||
<component :is="() => part" v-if="typeof part === 'object'" />
|
||||
<template v-else>{{ part }}</template>
|
||||
</template>
|
||||
</template>
|
||||
141
packages/ui/src/components/base/JoinedButtons.vue
Normal file
141
packages/ui/src/components/base/JoinedButtons.vue
Normal file
@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<div class="joined-buttons flex items-center">
|
||||
<ButtonStyled :color="color" :size="size">
|
||||
<button
|
||||
v-tooltip="primaryTooltip"
|
||||
:class="{ 'opacity-60': primaryMuted }"
|
||||
:disabled="primaryDisabledResolved"
|
||||
@click="handlePrimaryAction"
|
||||
>
|
||||
<component :is="primaryAction.icon" v-if="primaryAction.icon" aria-hidden="true" />
|
||||
{{ primaryAction.label }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="dropdownActions.length > 0" :color="color" :size="size">
|
||||
<OverflowMenu
|
||||
class="btn-dropdown-animation !w-10"
|
||||
:options="dropdownOptions"
|
||||
:disabled="dropdownDisabledResolved"
|
||||
:tooltip="dropdownTooltip"
|
||||
>
|
||||
<DropdownIcon />
|
||||
<template v-for="action in dropdownActions" :key="action.id" #[action.id]>
|
||||
<component :is="action.icon" v-if="action.icon" aria-hidden="true" />
|
||||
{{ action.label }}
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DropdownIcon } from '@modrinth/assets'
|
||||
import type { Component } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { ButtonStyled, OverflowMenu } from '../index'
|
||||
|
||||
// TODO: This should be moved to a shared types file.
|
||||
type Colors = 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
|
||||
|
||||
export interface JoinedButtonAction {
|
||||
id: string
|
||||
label: string
|
||||
icon?: Component
|
||||
action: () => void
|
||||
color?: Colors
|
||||
hoverFilled?: boolean
|
||||
}
|
||||
|
||||
interface Props {
|
||||
actions: JoinedButtonAction[]
|
||||
color?: Colors
|
||||
size?: 'standard' | 'large' | 'small'
|
||||
disabled?: boolean
|
||||
primaryDisabled?: boolean
|
||||
dropdownDisabled?: boolean
|
||||
primaryMuted?: boolean
|
||||
primaryTooltip?: string
|
||||
dropdownTooltip?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
color: 'standard',
|
||||
size: 'standard',
|
||||
disabled: false,
|
||||
primaryDisabled: undefined,
|
||||
dropdownDisabled: undefined,
|
||||
primaryMuted: false,
|
||||
primaryTooltip: undefined,
|
||||
dropdownTooltip: undefined,
|
||||
})
|
||||
|
||||
const primaryDisabledResolved = computed(() => props.primaryDisabled ?? props.disabled)
|
||||
const dropdownDisabledResolved = computed(() => props.dropdownDisabled ?? props.disabled)
|
||||
|
||||
const primaryAction = computed(() => props.actions[0])
|
||||
|
||||
const dropdownActions = computed(() => props.actions.slice(1))
|
||||
|
||||
const colorMap: Record<
|
||||
Colors,
|
||||
| 'red'
|
||||
| 'orange'
|
||||
| 'green'
|
||||
| 'blue'
|
||||
| 'purple'
|
||||
| 'highlight'
|
||||
| 'primary'
|
||||
| 'danger'
|
||||
| 'secondary'
|
||||
| undefined
|
||||
> = {
|
||||
standard: 'secondary',
|
||||
brand: 'primary',
|
||||
red: 'red',
|
||||
orange: 'orange',
|
||||
green: 'green',
|
||||
blue: 'blue',
|
||||
purple: 'purple',
|
||||
}
|
||||
|
||||
const dropdownOptions = computed(() =>
|
||||
dropdownActions.value.map((action) => ({
|
||||
id: action.id,
|
||||
color: action.color ? colorMap[action.color] : undefined,
|
||||
action: action.action,
|
||||
hoverFilled: action.hoverFilled ?? true,
|
||||
})),
|
||||
)
|
||||
|
||||
function handlePrimaryAction() {
|
||||
if (primaryAction.value && !primaryDisabledResolved.value) {
|
||||
primaryAction.value.action()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.joined-buttons > :deep(.btn) {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.joined-buttons > :deep(.btn:first-child) {
|
||||
border-top-left-radius: var(--radius-md);
|
||||
border-bottom-left-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.joined-buttons > :deep(.btn:last-child) {
|
||||
border-top-right-radius: var(--radius-md);
|
||||
border-bottom-right-radius: var(--radius-md);
|
||||
margin-left: -1px;
|
||||
}
|
||||
|
||||
.joined-buttons > :deep(.btn:not(:last-child)) {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.btn-dropdown-animation {
|
||||
padding: 0.5rem !important;
|
||||
}
|
||||
</style>
|
||||
41
packages/ui/src/components/base/LargeRadioButton.vue
Normal file
41
packages/ui/src/components/base/LargeRadioButton.vue
Normal file
@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<button
|
||||
role="radio"
|
||||
:aria-checked="selected"
|
||||
:aria-disabled="disabled"
|
||||
class="px-4 py-3 text-left border-0 font-medium border-2 border-button-bg border-solid flex gap-2 transition-all cursor-pointer rounded-xl"
|
||||
:class="
|
||||
(selected ? 'text-contrast bg-button-bg' : 'text-primary bg-transparent') +
|
||||
(disabled
|
||||
? ' opacity-50'
|
||||
: ' active:scale-[0.98] hover:bg-button-bg hover:brightness-[--hover-brightness]')
|
||||
"
|
||||
:disabled="disabled"
|
||||
@click="emit('select')"
|
||||
>
|
||||
<RadioButtonCheckedIcon
|
||||
v-if="selected"
|
||||
class="text-brand h-5 w-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<RadioButtonIcon v-else class="h-5 w-5 shrink-0" aria-hidden="true" />
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
<script setup lang="ts" generic="T">
|
||||
import { RadioButtonCheckedIcon, RadioButtonIcon } from '@modrinth/assets'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select'): void
|
||||
}>()
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
selected: boolean
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
},
|
||||
)
|
||||
</script>
|
||||
148
packages/ui/src/components/base/LoadingBar.vue
Normal file
148
packages/ui/src/components/base/LoadingBar.vue
Normal file
@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import { injectLoadingState } from '#ui/providers/loading-state'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** Bar height in pixels. */
|
||||
height?: number
|
||||
/** Background gradient. Defaults to the brand green. */
|
||||
color?: string
|
||||
/** Total bar fill duration in ms (visual progress easing). */
|
||||
duration?: number
|
||||
/** Delay in ms before the bar becomes visible after a load begins. */
|
||||
throttle?: number
|
||||
/** CSS position. Use `absolute` when wrapping in a custom positioned container (e.g. desktop top-bar offset). */
|
||||
position?: 'fixed' | 'absolute'
|
||||
/** Top offset CSS value. */
|
||||
offsetTop?: string
|
||||
/** Left offset CSS value. */
|
||||
offsetLeft?: string
|
||||
/** Right offset CSS value. */
|
||||
offsetRight?: string
|
||||
}>(),
|
||||
{
|
||||
height: 2,
|
||||
color: 'var(--loading-bar-gradient)',
|
||||
duration: 1000,
|
||||
throttle: 0,
|
||||
position: 'fixed',
|
||||
offsetTop: '0',
|
||||
offsetLeft: '0',
|
||||
offsetRight: '0',
|
||||
},
|
||||
)
|
||||
|
||||
const loadingState = injectLoadingState(null)
|
||||
|
||||
const progress = ref(0)
|
||||
const isVisible = ref(false)
|
||||
const step = computed(() => 10000 / props.duration)
|
||||
|
||||
let _timer: ReturnType<typeof setInterval> | null = null
|
||||
let _throttle: ReturnType<typeof setTimeout> | null = null
|
||||
let _hideTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let _resetTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function clearTimers() {
|
||||
if (_timer) clearInterval(_timer)
|
||||
if (_throttle) clearTimeout(_throttle)
|
||||
if (_hideTimeout) clearTimeout(_hideTimeout)
|
||||
if (_resetTimeout) clearTimeout(_resetTimeout)
|
||||
_timer = null
|
||||
_throttle = null
|
||||
_hideTimeout = null
|
||||
_resetTimeout = null
|
||||
}
|
||||
|
||||
function startTimer() {
|
||||
if (typeof window === 'undefined') return
|
||||
_timer = setInterval(() => {
|
||||
progress.value = Math.min(100, progress.value + step.value)
|
||||
}, 100)
|
||||
}
|
||||
|
||||
function start() {
|
||||
clearTimers()
|
||||
progress.value = 0
|
||||
if (props.throttle && typeof window !== 'undefined') {
|
||||
_throttle = setTimeout(() => {
|
||||
isVisible.value = true
|
||||
startTimer()
|
||||
}, props.throttle)
|
||||
} else {
|
||||
isVisible.value = true
|
||||
startTimer()
|
||||
}
|
||||
}
|
||||
|
||||
function finish() {
|
||||
progress.value = 100
|
||||
clearTimers()
|
||||
if (typeof window === 'undefined') {
|
||||
isVisible.value = false
|
||||
progress.value = 0
|
||||
return
|
||||
}
|
||||
_hideTimeout = setTimeout(() => {
|
||||
isVisible.value = false
|
||||
_resetTimeout = setTimeout(() => {
|
||||
progress.value = 0
|
||||
}, 400)
|
||||
}, 500)
|
||||
}
|
||||
|
||||
if (loadingState) {
|
||||
watch(
|
||||
() => loadingState.pending.value && loadingState.barEnabled.value,
|
||||
(active) => {
|
||||
if (active) start()
|
||||
else finish()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
}
|
||||
|
||||
onBeforeUnmount(clearTimers)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="modrinth-loading-bar"
|
||||
:style="{
|
||||
position: props.position,
|
||||
top: props.offsetTop,
|
||||
right: props.offsetRight,
|
||||
left: props.offsetLeft,
|
||||
pointerEvents: 'none',
|
||||
width: `${progress}%`,
|
||||
height: `${isVisible ? props.height : 0}px`,
|
||||
borderRadius: `${props.height}px`,
|
||||
background: props.color,
|
||||
backgroundSize: `${(100 / Math.max(progress, 0.01)) * 100}% auto`,
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transition: 'width 0.1s ease-in-out, height 0.1s ease-out, opacity 0.4s',
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.modrinth-loading-bar {
|
||||
z-index: 999999;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
background-image: radial-gradient(80% 100% at 20% 0%, var(--color-brand) 0%, transparent 80%);
|
||||
opacity: 0.1;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
130
packages/ui/src/components/base/LoadingIndicator.vue
Normal file
130
packages/ui/src/components/base/LoadingIndicator.vue
Normal file
@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<div class="w-full flex items-center justify-center flex-col gap-2">
|
||||
<div class="title">{{ formatMessage(messages.loadingLabel) }}</div>
|
||||
<div class="placeholder"></div>
|
||||
<div class="placeholder"></div>
|
||||
<div class="placeholder"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
|
||||
const messages = defineMessages({
|
||||
loadingLabel: {
|
||||
id: 'omorphia.component.loading-indicator.label',
|
||||
defaultMessage: 'Loading',
|
||||
},
|
||||
})
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
</script>
|
||||
<style scoped>
|
||||
.title {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
font-weight: bold;
|
||||
color: var(--color-contrast);
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
animation: dots 2s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dots {
|
||||
0%,
|
||||
24.99% {
|
||||
content: '';
|
||||
}
|
||||
|
||||
25%,
|
||||
49.99% {
|
||||
content: '.';
|
||||
}
|
||||
|
||||
50%,
|
||||
74.99% {
|
||||
content: '..';
|
||||
}
|
||||
|
||||
75%,
|
||||
100% {
|
||||
content: '...';
|
||||
}
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
border-radius: var(--radius-lg);
|
||||
width: 100%;
|
||||
height: 4rem;
|
||||
opacity: 0.25;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-color: var(--color-raised-bg);
|
||||
animation: pop 4s ease-in-out infinite;
|
||||
border: 1px solid transparent;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: linear-gradient(
|
||||
-45deg,
|
||||
transparent 30%,
|
||||
rgba(196, 217, 237, 0.075) 50%,
|
||||
transparent 70%
|
||||
);
|
||||
animation: shimmer 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
&:nth-child(2)::before {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
&:nth-child(3)::before {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
&:nth-child(4)::before {
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
&:nth-child(4) {
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pop {
|
||||
from {
|
||||
opacity: 0.25;
|
||||
border-color: transparent;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
border-color: var(--color-button-bg);
|
||||
}
|
||||
to {
|
||||
opacity: 0.25;
|
||||
border-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
from {
|
||||
transform: translateX(-80%);
|
||||
}
|
||||
50%,
|
||||
to {
|
||||
transform: translateX(80%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
149
packages/ui/src/components/base/ManySelect.vue
Normal file
149
packages/ui/src/components/base/ManySelect.vue
Normal file
@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<ButtonStyled>
|
||||
<PopoutMenu
|
||||
v-if="options.length > 1 || showAlways"
|
||||
v-bind="$attrs"
|
||||
:disabled="disabled"
|
||||
:position="position"
|
||||
:direction="direction"
|
||||
:dropdown-id="dropdownId"
|
||||
:dropdown-class="dropdownClass"
|
||||
:tooltip="tooltip"
|
||||
@open="
|
||||
() => {
|
||||
searchQuery = ''
|
||||
}
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
<DropdownIcon class="h-5 w-5 text-secondary" />
|
||||
<template #menu>
|
||||
<StyledInput
|
||||
v-if="search"
|
||||
id="search-input"
|
||||
ref="searchInput"
|
||||
v-model="searchQuery"
|
||||
:icon="SearchIcon"
|
||||
placeholder="Search..."
|
||||
type="text"
|
||||
wrapper-class="mb-2 w-full"
|
||||
@keydown.enter="
|
||||
() => {
|
||||
toggleOption(filteredOptions[0])
|
||||
}
|
||||
"
|
||||
/>
|
||||
<ScrollablePanel v-if="search">
|
||||
<Button
|
||||
v-for="(option, index) in filteredOptions"
|
||||
:key="`option-${index}`"
|
||||
:transparent="!manyValues.includes(option)"
|
||||
:action="() => toggleOption(option)"
|
||||
class="!w-full"
|
||||
:color="manyValues.includes(option) ? 'secondary' : 'default'"
|
||||
>
|
||||
<slot name="option" :option="option">{{ getOptionLabel(option) }}</slot>
|
||||
<CheckIcon
|
||||
class="h-5 w-5 text-contrast ml-auto transition-opacity"
|
||||
:class="{ 'opacity-0': !manyValues.includes(option) }"
|
||||
/>
|
||||
</Button>
|
||||
</ScrollablePanel>
|
||||
<div v-else class="flex flex-col gap-1">
|
||||
<Button
|
||||
v-for="(option, index) in filteredOptions"
|
||||
:key="`option-${index}`"
|
||||
:transparent="!manyValues.includes(option)"
|
||||
:action="() => toggleOption(option)"
|
||||
class="!w-full"
|
||||
:color="manyValues.includes(option) ? 'secondary' : 'default'"
|
||||
>
|
||||
<slot name="option" :option="option">{{ getOptionLabel(option) }}</slot>
|
||||
<CheckIcon
|
||||
class="h-5 w-5 text-contrast ml-auto transition-opacity"
|
||||
:class="{ 'opacity-0': !manyValues.includes(option) }"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
<slot name="footer" />
|
||||
</template>
|
||||
</PopoutMenu>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, DropdownIcon, SearchIcon } from '@modrinth/assets'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { Button, ButtonStyled, PopoutMenu, StyledInput } from '../index'
|
||||
import ScrollablePanel from './ScrollablePanel.vue'
|
||||
|
||||
type Option = string | number | object
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: Option[]
|
||||
options: Option[]
|
||||
disabled?: boolean
|
||||
position?: string
|
||||
direction?: string
|
||||
displayName?: (option: Option) => string
|
||||
search?: boolean
|
||||
dropdownId?: string
|
||||
dropdownClass?: string
|
||||
showAlways?: boolean
|
||||
tooltip?: string
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
position: 'auto',
|
||||
direction: 'auto',
|
||||
displayName: undefined,
|
||||
search: false,
|
||||
dropdownId: '',
|
||||
dropdownClass: '',
|
||||
showAlways: false,
|
||||
tooltip: '',
|
||||
},
|
||||
)
|
||||
|
||||
function getOptionLabel(option: Option): string {
|
||||
return props.displayName?.(option) ?? (option as string)
|
||||
}
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
const selectedValues = ref(props.modelValue || [])
|
||||
const searchInput = ref()
|
||||
|
||||
const searchQuery = ref('')
|
||||
|
||||
const manyValues = computed({
|
||||
get() {
|
||||
return props.modelValue || selectedValues.value
|
||||
},
|
||||
set(newValue) {
|
||||
emit('update:modelValue', newValue)
|
||||
emit('change', newValue)
|
||||
selectedValues.value = newValue
|
||||
},
|
||||
})
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
return props.options.filter(
|
||||
(x) =>
|
||||
!searchQuery.value ||
|
||||
getOptionLabel(x).toLowerCase().includes(searchQuery.value.toLowerCase()),
|
||||
)
|
||||
})
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
function toggleOption(id: Option) {
|
||||
if (manyValues.value.includes(id)) {
|
||||
manyValues.value = manyValues.value.filter((x) => x !== id)
|
||||
} else {
|
||||
manyValues.value = [...manyValues.value, id]
|
||||
}
|
||||
}
|
||||
</script>
|
||||
1217
packages/ui/src/components/base/MarkdownEditor.vue
Normal file
1217
packages/ui/src/components/base/MarkdownEditor.vue
Normal file
File diff suppressed because it is too large
Load Diff
15
packages/ui/src/components/base/MinecraftFormattedText.vue
Normal file
15
packages/ui/src/components/base/MinecraftFormattedText.vue
Normal file
@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { autoToHTML } from '@sfirew/minecraft-motd-parser'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
text: string
|
||||
}>()
|
||||
|
||||
const renderedText = computed(() => autoToHTML(props.text))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<span v-html="renderedText" />
|
||||
</template>
|
||||
1584
packages/ui/src/components/base/MultiSelect.vue
Normal file
1584
packages/ui/src/components/base/MultiSelect.vue
Normal file
File diff suppressed because it is too large
Load Diff
458
packages/ui/src/components/base/MultiStageModal.vue
Normal file
458
packages/ui/src/components/base/MultiStageModal.vue
Normal file
@ -0,0 +1,458 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:scrollable="true"
|
||||
max-content-height="72vh"
|
||||
:on-hide="onModalHide"
|
||||
:closable="true"
|
||||
:close-on-click-outside="closeOnClickOutside"
|
||||
:width="resolvedMaxWidth"
|
||||
:fade="fade"
|
||||
:disable-close="resolveCtxFn(currentStage.disableClose, context)"
|
||||
>
|
||||
<template #title>
|
||||
<div class="flex w-full min-w-0 items-center gap-3">
|
||||
<ButtonStyled
|
||||
v-if="backButtonVisible"
|
||||
class="shrink-0"
|
||||
type="outlined"
|
||||
circular
|
||||
size="small"
|
||||
>
|
||||
<button v-tooltip="backButtonLabel" :aria-label="backButtonLabel" @click="prevStage()">
|
||||
<ChevronLeftIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div
|
||||
v-if="breadcrumbs && !resolveCtxFn(currentStage.nonProgressStage, context)"
|
||||
class="relative min-w-0 flex-1"
|
||||
>
|
||||
<div
|
||||
class="pointer-events-none absolute left-0 top-0 bottom-0 w-8 bg-gradient-to-r from-bg-raised to-transparent z-10 transition-opacity duration-200"
|
||||
:class="showLeftShadow ? 'opacity-100' : 'opacity-0'"
|
||||
/>
|
||||
<div
|
||||
ref="breadcrumbScroller"
|
||||
class="flex w-full overflow-x-auto overflow-y-hidden scrollbar-hide pr-6"
|
||||
@wheel.prevent="onBreadcrumbWheel"
|
||||
@scroll="updateScrollShadows"
|
||||
>
|
||||
<template v-for="(stage, index) in breadcrumbStages" :key="stage.id">
|
||||
<div
|
||||
:ref="(el) => setBreadcrumbRef(stage.id, el as HTMLElement | null)"
|
||||
class="flex w-max items-center"
|
||||
>
|
||||
<button
|
||||
class="bg-transparent active:scale-95 font-bold text-secondary p-0 w-max py-3 px-1"
|
||||
:class="{
|
||||
'!text-contrast font-bold': resolveCtxFn(currentStage.id, context) === stage.id,
|
||||
'font-bold': resolveCtxFn(currentStage.id, context) !== stage.id,
|
||||
'opacity-50 cursor-not-allowed': cannotNavigateToStage(index),
|
||||
}"
|
||||
:disabled="cannotNavigateToStage(index)"
|
||||
@click="setStage(stage.id)"
|
||||
>
|
||||
{{ resolveCtxFn(stage.title, context) }}
|
||||
</button>
|
||||
<ChevronRightIcon
|
||||
v-if="index < breadcrumbStages.length - 1"
|
||||
class="h-5 w-5 text-secondary"
|
||||
stroke-width="3"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
class="pointer-events-none absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-bg-raised to-transparent z-10 transition-opacity duration-200"
|
||||
:class="showRightShadow ? 'opacity-100' : 'opacity-0'"
|
||||
/>
|
||||
</div>
|
||||
<span v-else class="min-w-0 flex-1 text-lg font-bold text-contrast sm:text-xl">{{
|
||||
resolvedTitle
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<progress
|
||||
v-if="nonProgressStage !== true && !disableProgress"
|
||||
:value="progressValue"
|
||||
max="100"
|
||||
class="w-full h-1 appearance-none border-none absolute top-0 left-0"
|
||||
></progress>
|
||||
|
||||
<component :is="currentStage?.stageContent" />
|
||||
|
||||
<template #actions>
|
||||
<div
|
||||
class="flex flex-col justify-end gap-2 sm:flex-row"
|
||||
:class="leftButtonConfig || rightButtonConfig || cancelButton ? 'mt-4' : ''"
|
||||
>
|
||||
<ButtonStyled v-if="leftButtonConfig" type="outlined">
|
||||
<button
|
||||
v-tooltip="leftButtonConfig.tooltip"
|
||||
:class="leftButtonConfig.buttonClass"
|
||||
:disabled="leftButtonConfig.disabled"
|
||||
@click="leftButtonConfig.onClick"
|
||||
>
|
||||
<component :is="leftButtonConfig.icon" />
|
||||
{{ leftButtonConfig.label }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="cancelButton" type="outlined">
|
||||
<button :disabled="cancelButton.disabled" @click="cancelButton.onClick">
|
||||
{{ cancelButton.label }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="rightButtonConfig" :color="rightButtonConfig.color">
|
||||
<button
|
||||
v-tooltip="rightButtonConfig.tooltip"
|
||||
:data-onboarding-id="rightButtonConfig.onboardingId"
|
||||
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-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-else-if="rightButtonConfig.iconPosition === 'after'"
|
||||
:class="rightButtonConfig.iconClass"
|
||||
/>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { ChevronLeftIcon, ChevronRightIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, commonMessages, NewModal, useVIntl } from '@modrinth/ui'
|
||||
import type { Component } from 'vue'
|
||||
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
|
||||
|
||||
export interface StageButtonConfig {
|
||||
label?: string
|
||||
icon?: Component | null
|
||||
iconPosition?: 'before' | 'after'
|
||||
color?: InstanceType<typeof ButtonStyled>['$props']['color']
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
tooltip?: string
|
||||
iconClass?: string | null
|
||||
buttonClass?: string | null
|
||||
onboardingId?: string
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
export type MaybeCtxFn<T, R> = R | ((ctx: T) => R)
|
||||
|
||||
export interface StageConfigInput<T> {
|
||||
id: string
|
||||
stageContent: Component
|
||||
title: MaybeCtxFn<T, string>
|
||||
skip?: MaybeCtxFn<T, boolean>
|
||||
hideStageInBreadcrumb?: MaybeCtxFn<T, boolean>
|
||||
// Determines whether this stage shows the progress bar
|
||||
nonProgressStage?: MaybeCtxFn<T, boolean>
|
||||
cannotNavigateForward?: MaybeCtxFn<T, boolean>
|
||||
disableClose?: MaybeCtxFn<T, boolean>
|
||||
leftButtonConfig: MaybeCtxFn<T, StageButtonConfig | null>
|
||||
rightButtonConfig: MaybeCtxFn<T, StageButtonConfig | null>
|
||||
/** Max width for the modal content and header defined in px (e.g., '460px', '600px'). Defaults to '460px'. */
|
||||
maxWidth?: MaybeCtxFn<T, string>
|
||||
}
|
||||
|
||||
export function resolveCtxFn<T, R>(value: MaybeCtxFn<T, R>, ctx: T): R {
|
||||
return typeof value === 'function' ? (value as (ctx: T) => R)(ctx) : value
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts" generic="T">
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
stages: StageConfigInput<T>[]
|
||||
context: T
|
||||
breadcrumbs?: boolean
|
||||
fitContent?: boolean
|
||||
fade?: 'standard' | 'warning' | 'danger'
|
||||
disableProgress?: boolean
|
||||
closeOnClickOutside?: boolean
|
||||
/** Whether to show a back (previous stage) button at the left of the title. */
|
||||
backButtonEnabled?: MaybeCtxFn<T, boolean>
|
||||
/** Renders an extra outlined button at the bottom right, before the primary action. */
|
||||
cancelButton?: {
|
||||
label: string
|
||||
disabled?: boolean
|
||||
onClick: () => void
|
||||
} | null
|
||||
}>(),
|
||||
{
|
||||
closeOnClickOutside: false,
|
||||
backButtonEnabled: (() => false) as () => boolean,
|
||||
cancelButton: null,
|
||||
},
|
||||
)
|
||||
|
||||
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
|
||||
const currentStageIndex = ref<number>(0)
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const backButtonLabel = computed(() => formatMessage(commonMessages.backButton))
|
||||
const backButtonVisible = computed(
|
||||
() =>
|
||||
currentStageIndex.value > 0 && resolveCtxFn(props.backButtonEnabled ?? false, props.context),
|
||||
)
|
||||
|
||||
function show() {
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
const setStage = (indexOrId: number | string) => {
|
||||
let index: number = 0
|
||||
if (typeof indexOrId === 'number') {
|
||||
index = indexOrId
|
||||
if (index < 0 || index >= props.stages.length) return
|
||||
} else {
|
||||
index = props.stages.findIndex((stage) => stage.id === indexOrId)
|
||||
if (index === -1) return
|
||||
}
|
||||
while (index < props.stages.length) {
|
||||
const skip = props.stages[index]?.skip
|
||||
if (!skip || !resolveCtxFn(skip, props.context)) break
|
||||
index++
|
||||
}
|
||||
if (index < props.stages.length) {
|
||||
currentStageIndex.value = index
|
||||
}
|
||||
}
|
||||
|
||||
const nextStage = () => {
|
||||
if (currentStageIndex.value === -1) return
|
||||
if (currentStageIndex.value >= props.stages.length - 1) return
|
||||
let nextIndex = currentStageIndex.value + 1
|
||||
while (nextIndex < props.stages.length) {
|
||||
const skip = props.stages[nextIndex]?.skip
|
||||
if (!skip || !resolveCtxFn(skip, props.context)) break
|
||||
nextIndex++
|
||||
}
|
||||
if (nextIndex < props.stages.length) {
|
||||
currentStageIndex.value = nextIndex
|
||||
}
|
||||
}
|
||||
|
||||
const prevStage = () => {
|
||||
if (currentStageIndex.value <= 0) return
|
||||
let prevIndex = currentStageIndex.value - 1
|
||||
while (prevIndex >= 0) {
|
||||
const skip = props.stages[prevIndex]?.skip
|
||||
if (!skip || !resolveCtxFn(skip, props.context)) break
|
||||
prevIndex--
|
||||
}
|
||||
if (prevIndex >= 0) {
|
||||
currentStageIndex.value = prevIndex
|
||||
}
|
||||
}
|
||||
|
||||
const currentStage = computed(() => props.stages[currentStageIndex.value])
|
||||
|
||||
const resolvedTitle = computed(() => {
|
||||
const stage = currentStage.value
|
||||
if (!stage) return ''
|
||||
return resolveCtxFn(stage.title, props.context)
|
||||
})
|
||||
|
||||
const leftButtonConfig = computed(() => {
|
||||
const stage = currentStage.value
|
||||
if (!stage) return null
|
||||
return resolveCtxFn(stage.leftButtonConfig, props.context)
|
||||
})
|
||||
|
||||
const rightButtonConfig = computed(() => {
|
||||
const stage = currentStage.value
|
||||
if (!stage) return null
|
||||
return resolveCtxFn(stage.rightButtonConfig, props.context)
|
||||
})
|
||||
|
||||
const nonProgressStage = computed(() => {
|
||||
const stage = currentStage.value
|
||||
if (!stage) return false
|
||||
return resolveCtxFn(stage.nonProgressStage, props.context)
|
||||
})
|
||||
|
||||
const resolvedMaxWidth = computed(() => {
|
||||
const stage = currentStage.value
|
||||
if (!stage?.maxWidth) return '560px'
|
||||
return resolveCtxFn(stage.maxWidth, props.context)
|
||||
})
|
||||
|
||||
const progressValue = computed(() => {
|
||||
const isProgressStage = (stage: StageConfigInput<T>) => {
|
||||
if (resolveCtxFn(stage.nonProgressStage, props.context)) return false
|
||||
const skip = stage.skip ? resolveCtxFn(stage.skip, props.context) : false
|
||||
return !skip
|
||||
}
|
||||
|
||||
const completedCount = props.stages
|
||||
.slice(0, currentStageIndex.value + 1)
|
||||
.filter(isProgressStage).length
|
||||
const totalCount = props.stages.filter(isProgressStage).length
|
||||
|
||||
return totalCount > 0 ? (completedCount / totalCount) * 100 : 0
|
||||
})
|
||||
|
||||
const breadcrumbScroller = ref<HTMLElement | null>(null)
|
||||
const breadcrumbRefs = ref<Map<string, HTMLElement>>(new Map())
|
||||
const showLeftShadow = ref(false)
|
||||
const showRightShadow = ref(false)
|
||||
|
||||
function setBreadcrumbRef(stageId: string, el: HTMLElement | null) {
|
||||
if (el) breadcrumbRefs.value.set(stageId, el)
|
||||
else breadcrumbRefs.value.delete(stageId)
|
||||
}
|
||||
|
||||
function scrollToCurrentBreadcrumb() {
|
||||
const stage = currentStage.value
|
||||
if (!stage || !breadcrumbScroller.value) return
|
||||
|
||||
const el = breadcrumbRefs.value.get(stage.id)
|
||||
if (!el) return
|
||||
|
||||
nextTick(() => {
|
||||
breadcrumbScroller.value?.scrollTo({
|
||||
left: el.offsetLeft - 50,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function updateScrollShadows() {
|
||||
const el = breadcrumbScroller.value
|
||||
if (!el) {
|
||||
showLeftShadow.value = false
|
||||
showRightShadow.value = false
|
||||
return
|
||||
}
|
||||
|
||||
showLeftShadow.value = el.scrollLeft > 0
|
||||
showRightShadow.value = el.scrollLeft < el.scrollWidth - el.clientWidth - 1
|
||||
}
|
||||
|
||||
function onBreadcrumbWheel(e: WheelEvent) {
|
||||
if (!breadcrumbScroller.value) return
|
||||
|
||||
const el = breadcrumbScroller.value
|
||||
const canScrollHorizontally = el.scrollWidth > el.clientWidth
|
||||
|
||||
if (canScrollHorizontally) {
|
||||
// Support both horizontal and vertical scroll input
|
||||
const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY
|
||||
el.scrollLeft += delta
|
||||
}
|
||||
}
|
||||
|
||||
// Stages that are not skipped (visible in breadcrumbs)
|
||||
const breadcrumbStages = computed(() => {
|
||||
return props.stages.filter((stage) => {
|
||||
const visibleStep =
|
||||
!resolveCtxFn(stage.skip, props.context) &&
|
||||
!resolveCtxFn(stage.nonProgressStage, props.context) &&
|
||||
!resolveCtxFn(stage.hideStageInBreadcrumb, props.context)
|
||||
return visibleStep
|
||||
})
|
||||
})
|
||||
|
||||
// Check if navigation to a breadcrumb stage is allowed
|
||||
// Navigation backwards is always allowed, but forward navigation requires all intermediate stages to allow it
|
||||
function cannotNavigateToStage(breadcrumbIndex: number): boolean {
|
||||
const targetStage = breadcrumbStages.value[breadcrumbIndex]
|
||||
if (!targetStage) return false
|
||||
|
||||
const targetStageIndex = props.stages.findIndex((s) => s.id === targetStage.id)
|
||||
if (targetStageIndex === -1) return false
|
||||
|
||||
// Always allow navigating to current or previous stages
|
||||
if (targetStageIndex <= currentStageIndex.value) return false
|
||||
|
||||
// For forward navigation, check all stages between current and target
|
||||
for (let i = currentStageIndex.value; i < targetStageIndex; i++) {
|
||||
const stage = props.stages[i]
|
||||
if (stage.skip && resolveCtxFn(stage.skip, props.context)) continue
|
||||
if (resolveCtxFn(stage.cannotNavigateForward, props.context)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
watch([breadcrumbStages, currentStageIndex], () => nextTick(() => updateScrollShadows()), {
|
||||
immediate: true,
|
||||
})
|
||||
|
||||
watch(currentStageIndex, () => {
|
||||
scrollToCurrentBreadcrumb()
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'refresh-data' | 'hide'): void
|
||||
}>()
|
||||
|
||||
function onModalHide() {
|
||||
emit('hide')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
hide,
|
||||
setStage,
|
||||
nextStage,
|
||||
prevStage,
|
||||
currentStageIndex,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
progress {
|
||||
@apply bg-surface-3;
|
||||
background-color: var(--surface-3, rgb(30, 30, 30));
|
||||
}
|
||||
|
||||
progress::-webkit-progress-bar {
|
||||
@apply bg-surface-3;
|
||||
}
|
||||
|
||||
progress::-webkit-progress-value {
|
||||
@apply bg-contrast;
|
||||
}
|
||||
|
||||
progress::-moz-progress-bar {
|
||||
@apply bg-contrast;
|
||||
}
|
||||
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
393
packages/ui/src/components/base/NavTabs.vue
Normal file
393
packages/ui/src/components/base/NavTabs.vue
Normal file
@ -0,0 +1,393 @@
|
||||
<template>
|
||||
<nav
|
||||
v-if="filteredLinks.length > 1"
|
||||
ref="scrollContainer"
|
||||
class="relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold"
|
||||
:class="{ 'drop-shadow-xl border border-solid border-surface-4': mode === 'navigation' }"
|
||||
>
|
||||
<template v-if="mode === 'navigation'">
|
||||
<RouterLink
|
||||
v-for="(link, index) in filteredLinks"
|
||||
v-show="link.shown ?? true"
|
||||
:key="link.href"
|
||||
ref="tabLinkElements"
|
||||
:replace="replace"
|
||||
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
|
||||
:data-onboarding-id="link.onboardingId"
|
||||
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 focus:rounded-full"
|
||||
:class="getSSRFallbackClasses(index)"
|
||||
@click="saveSliderSnapshot(true)"
|
||||
@mouseenter="link.onHover?.()"
|
||||
@focus="link.onHover?.()"
|
||||
>
|
||||
<component :is="link.icon" v-if="link.icon" class="size-5" :class="getIconClasses(index)" />
|
||||
<span class="text-nowrap" :class="getLabelClasses(index)">
|
||||
{{ link.label }}
|
||||
</span>
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<button
|
||||
v-for="(link, index) in filteredLinks"
|
||||
v-show="link.shown ?? true"
|
||||
:key="link.href"
|
||||
ref="tabLinkElements"
|
||||
type="button"
|
||||
class="button-animation z-[1] flex flex-row items-center gap-2 border-0 bg-transparent px-4 py-2 text-inherit hover:cursor-pointer focus:rounded-full"
|
||||
:class="getSSRFallbackClasses(index)"
|
||||
@click="emit('tabClick', index, link)"
|
||||
>
|
||||
<component :is="link.icon" v-if="link.icon" class="size-5" :class="getIconClasses(index)" />
|
||||
<span class="text-nowrap" :class="getLabelClasses(index)">
|
||||
{{ link.label }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Animated slider background -->
|
||||
<div
|
||||
v-if="sliderReady && currentActiveIndex !== -1"
|
||||
class="pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full p-1"
|
||||
:class="[
|
||||
subpageSelected ? 'bg-button-bg' : 'bg-button-bgSelected',
|
||||
{ 'navtabs-transition': transitionsEnabled },
|
||||
]"
|
||||
:style="sliderStyle"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
interface Tab {
|
||||
label: string
|
||||
href: string
|
||||
shown?: boolean
|
||||
icon?: Component
|
||||
subpages?: string[]
|
||||
onHover?: () => void
|
||||
onboardingId?: string
|
||||
}
|
||||
|
||||
interface SliderSnapshot {
|
||||
left: number
|
||||
top: number
|
||||
right: number
|
||||
bottom: number
|
||||
savedAt: number
|
||||
preserveOnUnmount: boolean
|
||||
}
|
||||
|
||||
const sliderSnapshotMaxAge = 1000
|
||||
const navigationSliderSnapshots = new Map<string, SliderSnapshot>()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
replace?: boolean
|
||||
links: Tab[]
|
||||
query?: string
|
||||
mode?: 'navigation' | 'local'
|
||||
activeIndex?: number
|
||||
}>(),
|
||||
{
|
||||
mode: 'navigation',
|
||||
query: undefined,
|
||||
activeIndex: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
tabClick: [index: number, tab: Tab]
|
||||
}>()
|
||||
|
||||
// DOM refs
|
||||
const scrollContainer = ref<HTMLElement | null>(null)
|
||||
const tabLinkElements = ref<HTMLElement[]>()
|
||||
|
||||
// Slider pos state
|
||||
const sliderLeft = ref(4)
|
||||
const sliderTop = ref(4)
|
||||
const sliderRight = ref(4)
|
||||
const sliderBottom = ref(4)
|
||||
|
||||
// active tab state
|
||||
const currentActiveIndex = ref(-1)
|
||||
const subpageSelected = ref(false)
|
||||
|
||||
// SSR state
|
||||
const sliderReady = ref(false)
|
||||
const transitionsEnabled = ref(false)
|
||||
|
||||
// Stagger delays for the trailing edges of the slider animation
|
||||
const sliderDelays = ref({ left: '0ms', top: '0ms', right: '0ms', bottom: '0ms' })
|
||||
|
||||
const filteredLinks = computed(() => props.links.filter((link) => link.shown ?? true))
|
||||
const navigationGroupKey = computed(() =>
|
||||
props.mode === 'navigation'
|
||||
? filteredLinks.value.map((link) => link.href.split('?')[0]).join('|')
|
||||
: null,
|
||||
)
|
||||
|
||||
const sliderStyle = computed(() => ({
|
||||
left: `${sliderLeft.value}px`,
|
||||
top: `${sliderTop.value}px`,
|
||||
right: `${sliderRight.value}px`,
|
||||
bottom: `${sliderBottom.value}px`,
|
||||
opacity: sliderReady.value && currentActiveIndex.value !== -1 ? 1 : 0,
|
||||
}))
|
||||
|
||||
const leftDelay = computed(() => sliderDelays.value.left)
|
||||
const rightDelay = computed(() => sliderDelays.value.right)
|
||||
const topDelay = computed(() => sliderDelays.value.top)
|
||||
const bottomDelay = computed(() => sliderDelays.value.bottom)
|
||||
|
||||
const isActiveAndNotSubpage = computed(
|
||||
() => (index: number) => currentActiveIndex.value === index && !subpageSelected.value,
|
||||
)
|
||||
|
||||
function getSSRFallbackClasses(index: number) {
|
||||
if (sliderReady.value) return {}
|
||||
if (currentActiveIndex.value !== index) return {}
|
||||
|
||||
return {
|
||||
'rounded-full': true,
|
||||
'bg-button-bgSelected': !subpageSelected.value,
|
||||
'bg-button-bg': subpageSelected.value,
|
||||
}
|
||||
}
|
||||
|
||||
function getIconClasses(index: number) {
|
||||
return {
|
||||
'text-button-textSelected': isActiveAndNotSubpage.value(index),
|
||||
'text-secondary': !isActiveAndNotSubpage.value(index),
|
||||
}
|
||||
}
|
||||
|
||||
function getLabelClasses(index: number) {
|
||||
return {
|
||||
'text-button-textSelected': isActiveAndNotSubpage.value(index),
|
||||
'text-contrast': !isActiveAndNotSubpage.value(index),
|
||||
}
|
||||
}
|
||||
|
||||
function computeActiveIndex(): { index: number; isSubpage: boolean } {
|
||||
if (props.mode === 'local' && props.activeIndex !== undefined) {
|
||||
return {
|
||||
index: Math.min(props.activeIndex, filteredLinks.value.length - 1),
|
||||
isSubpage: false,
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = filteredLinks.value.length - 1; i >= 0; i--) {
|
||||
const link = filteredLinks.value[i]
|
||||
const decodedPath = decodeURIComponent(route.path)
|
||||
const decodedHref = decodeURIComponent(link.href.split('?')[0])
|
||||
|
||||
if (props.query) {
|
||||
const queryValue = route.query[props.query]
|
||||
if (queryValue === link.href || (!queryValue && !link.href)) {
|
||||
return { index: i, isSubpage: false }
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (decodedPath === decodedHref) {
|
||||
return { index: i, isSubpage: false }
|
||||
}
|
||||
|
||||
const isSubpageMatch =
|
||||
(decodedPath.startsWith(decodedHref) &&
|
||||
(decodedPath.length === decodedHref.length || decodedPath[decodedHref.length] === '/')) ||
|
||||
link.subpages?.some((subpage) => decodedPath.includes(subpage))
|
||||
|
||||
if (isSubpageMatch) {
|
||||
return { index: i, isSubpage: true }
|
||||
}
|
||||
}
|
||||
|
||||
return { index: -1, isSubpage: false }
|
||||
}
|
||||
|
||||
function getTabElement(index: number): HTMLElement | null {
|
||||
if (index === -1) return null
|
||||
|
||||
const container = scrollContainer.value as HTMLElement | undefined
|
||||
if (!container) return null
|
||||
|
||||
const tabs = container.querySelectorAll('.button-animation')
|
||||
const element = tabs[index] as HTMLElement | undefined
|
||||
|
||||
if (!element) return null
|
||||
|
||||
return element
|
||||
}
|
||||
|
||||
function positionSlider() {
|
||||
const el = getTabElement(currentActiveIndex.value)
|
||||
if (!el?.offsetParent) return
|
||||
|
||||
const parent = el.offsetParent as HTMLElement
|
||||
const newPosition = {
|
||||
left: el.offsetLeft,
|
||||
top: el.offsetTop,
|
||||
right: parent.offsetWidth - el.offsetLeft - el.offsetWidth,
|
||||
bottom: parent.offsetHeight - el.offsetTop - el.offsetHeight,
|
||||
}
|
||||
|
||||
const isInitialPosition = sliderLeft.value === 4 && sliderRight.value === 4
|
||||
|
||||
if (!sliderReady.value || isInitialPosition) {
|
||||
sliderLeft.value = newPosition.left
|
||||
sliderRight.value = newPosition.right
|
||||
sliderTop.value = newPosition.top
|
||||
sliderBottom.value = newPosition.bottom
|
||||
|
||||
sliderReady.value = true
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
transitionsEnabled.value = true
|
||||
})
|
||||
} else {
|
||||
animateSliderTo(newPosition)
|
||||
}
|
||||
}
|
||||
|
||||
function animateSliderTo(newPosition: {
|
||||
left: number
|
||||
top: number
|
||||
right: number
|
||||
bottom: number
|
||||
}) {
|
||||
const STAGGER_DELAY = '200ms'
|
||||
|
||||
sliderDelays.value = {
|
||||
left: newPosition.left < sliderLeft.value ? '0ms' : STAGGER_DELAY,
|
||||
right: newPosition.left < sliderLeft.value ? STAGGER_DELAY : '0ms',
|
||||
top: newPosition.top < sliderTop.value ? '0ms' : STAGGER_DELAY,
|
||||
bottom: newPosition.top < sliderTop.value ? STAGGER_DELAY : '0ms',
|
||||
}
|
||||
|
||||
sliderLeft.value = newPosition.left
|
||||
sliderRight.value = newPosition.right
|
||||
sliderTop.value = newPosition.top
|
||||
sliderBottom.value = newPosition.bottom
|
||||
}
|
||||
|
||||
function saveSliderSnapshot(preserveOnUnmount = false) {
|
||||
const key = navigationGroupKey.value
|
||||
if (!key || !sliderReady.value || currentActiveIndex.value === -1) return
|
||||
|
||||
navigationSliderSnapshots.set(key, {
|
||||
left: sliderLeft.value,
|
||||
top: sliderTop.value,
|
||||
right: sliderRight.value,
|
||||
bottom: sliderBottom.value,
|
||||
savedAt: Date.now(),
|
||||
preserveOnUnmount,
|
||||
})
|
||||
}
|
||||
|
||||
async function updateActiveTab() {
|
||||
await nextTick()
|
||||
const { index, isSubpage } = computeActiveIndex()
|
||||
currentActiveIndex.value = index
|
||||
subpageSelected.value = isSubpage
|
||||
|
||||
if (index !== -1) {
|
||||
positionSlider()
|
||||
} else {
|
||||
sliderLeft.value = 0
|
||||
sliderRight.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
const initialActive = computeActiveIndex()
|
||||
currentActiveIndex.value = initialActive.index
|
||||
subpageSelected.value = initialActive.isSubpage
|
||||
|
||||
const restoredNavigationGroupKey = navigationGroupKey.value
|
||||
const navigationGroupSnapshot = restoredNavigationGroupKey
|
||||
? navigationSliderSnapshots.get(restoredNavigationGroupKey)
|
||||
: undefined
|
||||
const restoredSliderSnapshot =
|
||||
!!navigationGroupSnapshot && Date.now() - navigationGroupSnapshot.savedAt <= sliderSnapshotMaxAge
|
||||
if (restoredSliderSnapshot) {
|
||||
sliderLeft.value = navigationGroupSnapshot.left
|
||||
sliderTop.value = navigationGroupSnapshot.top
|
||||
sliderRight.value = navigationGroupSnapshot.right
|
||||
sliderBottom.value = navigationGroupSnapshot.bottom
|
||||
sliderReady.value = true
|
||||
transitionsEnabled.value = true
|
||||
navigationSliderSnapshots.delete(restoredNavigationGroupKey)
|
||||
} else if (restoredNavigationGroupKey) {
|
||||
navigationSliderSnapshots.delete(restoredNavigationGroupKey)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!restoredSliderSnapshot) {
|
||||
void updateActiveTab()
|
||||
return
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => void updateActiveTab())
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
const key = navigationGroupKey.value
|
||||
const existingSnapshot = key ? navigationSliderSnapshots.get(key) : undefined
|
||||
if (
|
||||
existingSnapshot?.preserveOnUnmount &&
|
||||
Date.now() - existingSnapshot.savedAt <= sliderSnapshotMaxAge
|
||||
) {
|
||||
return
|
||||
}
|
||||
saveSliderSnapshot()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [route.path, route.query],
|
||||
() => {
|
||||
if (props.mode === 'navigation') {
|
||||
updateActiveTab()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.activeIndex,
|
||||
() => {
|
||||
if (props.mode === 'local') {
|
||||
updateActiveTab()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.links,
|
||||
async () => {
|
||||
await nextTick()
|
||||
updateActiveTab()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.navtabs-transition {
|
||||
transition:
|
||||
left 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(leftDelay),
|
||||
right 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(rightDelay),
|
||||
top 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(topDelay),
|
||||
bottom 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(bottomDelay),
|
||||
opacity 250ms cubic-bezier(0.5, 0, 0.2, 1) 50ms;
|
||||
}
|
||||
</style>
|
||||
128
packages/ui/src/components/base/OptionGroup.vue
Normal file
128
packages/ui/src/components/base/OptionGroup.vue
Normal file
@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<nav
|
||||
ref="scrollContainer"
|
||||
class="card-shadow relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold"
|
||||
>
|
||||
<button
|
||||
v-for="(option, index) in options"
|
||||
:key="`option-group-${index}`"
|
||||
ref="optionButtons"
|
||||
class="button-animation z-[1] flex flex-row items-center gap-2 rounded-full bg-transparent px-4 py-2 font-semibold"
|
||||
:class="{
|
||||
'text-button-textSelected': modelValue === option,
|
||||
'text-primary': modelValue !== option,
|
||||
}"
|
||||
@click="setOption(option)"
|
||||
>
|
||||
<slot :option="option" :selected="modelValue === option" />
|
||||
</button>
|
||||
<div
|
||||
class="navtabs-transition pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full bg-button-bgSelected p-1"
|
||||
:style="{
|
||||
left: sliderLeftPx,
|
||||
top: sliderTopPx,
|
||||
right: sliderRightPx,
|
||||
bottom: sliderBottomPx,
|
||||
opacity: initialized ? 1 : 0,
|
||||
}"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
const modelValue = defineModel<T>({ required: true })
|
||||
|
||||
const props = defineProps<{
|
||||
options: T[]
|
||||
}>()
|
||||
|
||||
const scrollContainer = ref<HTMLElement | null>(null)
|
||||
|
||||
const sliderLeft = ref(4)
|
||||
const sliderTop = ref(4)
|
||||
const sliderRight = ref(4)
|
||||
const sliderBottom = ref(4)
|
||||
|
||||
const sliderLeftPx = computed(() => `${sliderLeft.value}px`)
|
||||
const sliderTopPx = computed(() => `${sliderTop.value}px`)
|
||||
const sliderRightPx = computed(() => `${sliderRight.value}px`)
|
||||
const sliderBottomPx = computed(() => `${sliderBottom.value}px`)
|
||||
|
||||
const optionButtons = ref()
|
||||
|
||||
const initialized = ref(false)
|
||||
|
||||
function setOption(option: T) {
|
||||
modelValue.value = option
|
||||
}
|
||||
|
||||
watch(modelValue, () => {
|
||||
startAnimation(props.options.indexOf(modelValue.value))
|
||||
})
|
||||
|
||||
function startAnimation(index: number) {
|
||||
const el = optionButtons.value[index]
|
||||
|
||||
if (!el || !el.offsetParent) return
|
||||
|
||||
const newValues = {
|
||||
left: el.offsetLeft,
|
||||
top: el.offsetTop,
|
||||
right: el.offsetParent.offsetWidth - el.offsetLeft - el.offsetWidth,
|
||||
bottom: el.offsetParent.offsetHeight - el.offsetTop - el.offsetHeight,
|
||||
}
|
||||
|
||||
if (sliderLeft.value === 4 && sliderRight.value === 4) {
|
||||
sliderLeft.value = newValues.left
|
||||
sliderRight.value = newValues.right
|
||||
sliderTop.value = newValues.top
|
||||
sliderBottom.value = newValues.bottom
|
||||
} else {
|
||||
const delay = 200
|
||||
|
||||
if (newValues.left < sliderLeft.value) {
|
||||
sliderLeft.value = newValues.left
|
||||
setTimeout(() => {
|
||||
sliderRight.value = newValues.right
|
||||
}, delay)
|
||||
} else {
|
||||
sliderRight.value = newValues.right
|
||||
setTimeout(() => {
|
||||
sliderLeft.value = newValues.left
|
||||
}, delay)
|
||||
}
|
||||
|
||||
if (newValues.top < sliderTop.value) {
|
||||
sliderTop.value = newValues.top
|
||||
setTimeout(() => {
|
||||
sliderBottom.value = newValues.bottom
|
||||
}, delay)
|
||||
} else {
|
||||
sliderBottom.value = newValues.bottom
|
||||
setTimeout(() => {
|
||||
sliderTop.value = newValues.top
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
initialized.value = true
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
startAnimation(props.options.indexOf(modelValue.value))
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.navtabs-transition {
|
||||
transition:
|
||||
all 150ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||
opacity 250ms cubic-bezier(0.5, 0, 0.2, 1) 50ms;
|
||||
}
|
||||
|
||||
.card-shadow {
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
</style>
|
||||
149
packages/ui/src/components/base/OverflowMenu.vue
Normal file
149
packages/ui/src/components/base/OverflowMenu.vue
Normal file
@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<PopoutMenu
|
||||
ref="dropdown"
|
||||
v-bind="$attrs"
|
||||
:disabled="disabled"
|
||||
:dropdown-id="dropdownId"
|
||||
:tooltip="tooltip"
|
||||
:placement="placement"
|
||||
>
|
||||
<slot></slot>
|
||||
<template #menu>
|
||||
<slot name="menu-header" />
|
||||
<template v-for="(option, index) in options.filter((x) => x.shown === undefined || x.shown)">
|
||||
<div
|
||||
v-if="isDivider(option)"
|
||||
:key="`divider-${index}`"
|
||||
class="h-px mx-[0.625rem] my-2 bg-surface-5"
|
||||
></div>
|
||||
<Button
|
||||
v-else
|
||||
:key="`option-${option.id}`"
|
||||
v-tooltip="option.tooltip"
|
||||
:color="option.color ? option.color : 'default'"
|
||||
:hover-filled="option.hoverFilled"
|
||||
:hover-filled-only="option.hoverFilledOnly"
|
||||
transparent
|
||||
:v-close-popper="!option.remainOnClick"
|
||||
:action="
|
||||
option.action
|
||||
? (event: MouseEvent) => {
|
||||
option.action?.(event)
|
||||
if (!option.remainOnClick) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
"
|
||||
:link="option.link ? option.link : undefined"
|
||||
:download="option.download ? option.download : undefined"
|
||||
:external="option.external ? option.external : false"
|
||||
:disabled="option.disabled"
|
||||
@click="
|
||||
() => {
|
||||
if (option.link && !option.remainOnClick) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<template v-if="!$slots[option.id]">
|
||||
<component :is="option.icon" v-if="option.icon" class="size-5" />
|
||||
{{ option.id }}
|
||||
</template>
|
||||
<slot :name="option.id"></slot>
|
||||
</Button>
|
||||
</template>
|
||||
</template>
|
||||
</PopoutMenu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type Component, type Ref, ref } from 'vue'
|
||||
|
||||
import Button from './Button.vue'
|
||||
import PopoutMenu from './PopoutMenu.vue'
|
||||
|
||||
interface BaseOption {
|
||||
shown?: boolean
|
||||
}
|
||||
|
||||
interface Divider extends BaseOption {
|
||||
divider?: boolean
|
||||
}
|
||||
|
||||
interface Item extends BaseOption {
|
||||
id: string
|
||||
icon?: Component
|
||||
action?: (event?: MouseEvent) => void
|
||||
link?: string
|
||||
download?: string
|
||||
external?: boolean
|
||||
color?:
|
||||
| 'primary'
|
||||
| 'danger'
|
||||
| 'secondary'
|
||||
| 'highlight'
|
||||
| 'red'
|
||||
| 'orange'
|
||||
| 'green'
|
||||
| 'blue'
|
||||
| 'purple'
|
||||
hoverFilled?: boolean
|
||||
hoverFilledOnly?: boolean
|
||||
remainOnClick?: boolean
|
||||
disabled?: boolean
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
export type Option = Divider | Item
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
options: Option[]
|
||||
disabled?: boolean
|
||||
dropdownId?: string
|
||||
tooltip?: string
|
||||
placement?: string
|
||||
}>(),
|
||||
{
|
||||
options: () => [],
|
||||
disabled: false,
|
||||
dropdownId: undefined,
|
||||
tooltip: undefined,
|
||||
placement: 'bottom-end',
|
||||
},
|
||||
)
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const dropdown: Ref<InstanceType<typeof PopoutMenu> | null> = ref(null)
|
||||
|
||||
const close = () => {
|
||||
dropdown.value?.hide()
|
||||
}
|
||||
|
||||
const open = () => {
|
||||
dropdown.value?.show()
|
||||
}
|
||||
|
||||
function isDivider(option: BaseOption): option is Divider {
|
||||
return 'divider' in option
|
||||
}
|
||||
|
||||
defineExpose({ open, close })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.btn {
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
box-shadow: none;
|
||||
--text-color: var(--color-base);
|
||||
--background-color: transparent;
|
||||
justify-content: flex-start;
|
||||
padding: 0.55rem 0.625rem;
|
||||
}
|
||||
</style>
|
||||
112
packages/ui/src/components/base/Page.vue
Normal file
112
packages/ui/src/components/base/Page.vue
Normal file
@ -0,0 +1,112 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
collapsible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
rightSidebar: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="omorphia__page flex flex-col px-3"
|
||||
:class="{
|
||||
'right-sidebar': rightSidebar,
|
||||
'has-sidebar': !!$slots.sidebar,
|
||||
'has-header': !!$slots.header,
|
||||
'has-footer': !!$slots.footer,
|
||||
}"
|
||||
>
|
||||
<div v-if="!!$slots.header" class="header">
|
||||
<slot name="header" />
|
||||
</div>
|
||||
<div v-if="!!$slots.sidebar" class="sidebar lg:min-w-80 lg:w-80">
|
||||
<slot name="sidebar" />
|
||||
</div>
|
||||
<div class="content">
|
||||
<slot />
|
||||
</div>
|
||||
<div v-if="!!$slots.footer" class="footer">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.omorphia__page {
|
||||
.header {
|
||||
grid-area: header;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
grid-area: sidebar;
|
||||
}
|
||||
|
||||
.footer {
|
||||
grid-area: footer;
|
||||
}
|
||||
|
||||
.content {
|
||||
grid-area: content;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.omorphia__page {
|
||||
margin: 0 auto;
|
||||
max-width: 80rem;
|
||||
column-gap: 0.75rem;
|
||||
|
||||
&.has-sidebar {
|
||||
display: grid;
|
||||
grid-template:
|
||||
'sidebar content' auto
|
||||
'footer content' auto
|
||||
'dummy content' 1fr
|
||||
/ 20rem 1fr;
|
||||
|
||||
&.has-header {
|
||||
grid-template:
|
||||
'header header' auto
|
||||
'sidebar content' auto
|
||||
'footer content' auto
|
||||
'dummy content' 1fr
|
||||
/ 20rem 1fr;
|
||||
}
|
||||
|
||||
&.right-sidebar {
|
||||
grid-template:
|
||||
'content sidebar' auto
|
||||
'content footer' auto
|
||||
'content dummy' 1fr
|
||||
/ 1fr 20rem;
|
||||
|
||||
&.has-header {
|
||||
grid-template:
|
||||
'header header' auto
|
||||
'content sidebar' auto
|
||||
'content footer' auto
|
||||
'content dummy' 1fr
|
||||
/ 1fr 20rem;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
max-width: calc(60rem - 0.75rem);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 80rem) {
|
||||
.omorphia__page.has-sidebar {
|
||||
.content {
|
||||
width: calc(60rem - 0.75rem);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
11
packages/ui/src/components/base/PageHeader.vue
Normal file
11
packages/ui/src/components/base/PageHeader.vue
Normal file
@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-[min-content_1fr_auto] gap-4">
|
||||
<slot name="icon" />
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<slot name="title" />
|
||||
<slot name="summary" />
|
||||
<slot name="stats" />
|
||||
</div>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</template>
|
||||
125
packages/ui/src/components/base/Pagination.vue
Normal file
125
packages/ui/src/components/base/Pagination.vue
Normal file
@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<div v-if="count > 1" class="flex items-center gap-1">
|
||||
<ButtonStyled v-if="page > 1" circular type="transparent">
|
||||
<a
|
||||
v-if="linkFunction"
|
||||
aria-label="Previous Page"
|
||||
:href="linkFunction(page - 1)"
|
||||
@click.prevent="switchPage(page - 1)"
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</a>
|
||||
<button v-else aria-label="Previous Page" @click="switchPage(page - 1)">
|
||||
<ChevronLeftIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div
|
||||
v-for="(item, index) in pages"
|
||||
:key="'page-' + item + '-' + index"
|
||||
:class="{
|
||||
'page-number': page !== item,
|
||||
shrink: item !== '-' && item > 99,
|
||||
}"
|
||||
class="page-number-container"
|
||||
>
|
||||
<div v-if="item === '-'" class="rotate-90 grid place-content-center">
|
||||
<EllipsisVerticalIcon />
|
||||
</div>
|
||||
<ButtonStyled
|
||||
v-else
|
||||
circular
|
||||
:color="page === item ? 'brand' : 'standard'"
|
||||
:type="page === item ? 'highlight' : 'transparent'"
|
||||
>
|
||||
<a
|
||||
v-if="linkFunction"
|
||||
:href="linkFunction(item)"
|
||||
:class="page === item ? '!text-brand' : ''"
|
||||
@click.prevent="page !== item ? switchPage(item) : null"
|
||||
>
|
||||
{{ item }}
|
||||
</a>
|
||||
<button
|
||||
v-else
|
||||
:class="page === item ? '!text-brand' : ''"
|
||||
@click="page !== item ? switchPage(item) : null"
|
||||
>
|
||||
{{ item }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<ButtonStyled v-if="page !== pages[pages.length - 1]" circular type="transparent">
|
||||
<a
|
||||
v-if="linkFunction"
|
||||
aria-label="Next Page"
|
||||
:href="linkFunction(page + 1)"
|
||||
@click.prevent="switchPage(page + 1)"
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</a>
|
||||
<button v-else aria-label="Next Page" @click="switchPage(page + 1)">
|
||||
<ChevronRightIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ChevronLeftIcon, ChevronRightIcon, EllipsisVerticalIcon } from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import ButtonStyled from './ButtonStyled.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
'switch-page': [page: number]
|
||||
}>()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
page: number
|
||||
count: number
|
||||
linkFunction?: (page: number) => string | undefined
|
||||
}>(),
|
||||
{
|
||||
page: 1,
|
||||
count: 1,
|
||||
},
|
||||
)
|
||||
|
||||
const pages = computed(() => {
|
||||
const pages: ('-' | number)[] = []
|
||||
|
||||
const first = 1
|
||||
const last = props.count
|
||||
const current = props.page
|
||||
const prev = current - 1
|
||||
const next = current + 1
|
||||
const gap = '-'
|
||||
|
||||
if (prev > first) {
|
||||
pages.push(first)
|
||||
}
|
||||
if (prev > first + 1) {
|
||||
pages.push(gap)
|
||||
}
|
||||
if (prev >= first) {
|
||||
pages.push(prev)
|
||||
}
|
||||
pages.push(current)
|
||||
if (next <= last) {
|
||||
pages.push(next)
|
||||
}
|
||||
if (next < last - 1) {
|
||||
pages.push(gap)
|
||||
}
|
||||
if (next < last) {
|
||||
pages.push(last)
|
||||
}
|
||||
|
||||
return pages
|
||||
})
|
||||
|
||||
function switchPage(newPage: number) {
|
||||
emit('switch-page', Math.min(Math.max(newPage, 1), props.count))
|
||||
}
|
||||
</script>
|
||||
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>
|
||||
99
packages/ui/src/components/base/PopoutMenu.vue
Normal file
99
packages/ui/src/components/base/PopoutMenu.vue
Normal file
@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<Dropdown
|
||||
ref="dropdown"
|
||||
no-auto-focus
|
||||
:aria-id="dropdownId || null"
|
||||
:placement="placement"
|
||||
:container="container"
|
||||
:class="dropdownClass"
|
||||
@apply-hide="focusTrigger"
|
||||
>
|
||||
<button ref="trigger" v-bind="$attrs" v-tooltip="tooltip">
|
||||
<slot></slot>
|
||||
</button>
|
||||
<template #popper="{ hide: hideFunction }">
|
||||
<button class="dummy-button" @focusin="hideAndFocusTrigger(hideFunction)"></button>
|
||||
<div ref="menu" class="contents">
|
||||
<slot name="menu"> </slot>
|
||||
</div>
|
||||
<button class="dummy-button" @focusin="hideAndFocusTrigger(hideFunction)"></button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Dropdown } from 'floating-vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const trigger = ref()
|
||||
const menu = ref()
|
||||
const dropdown = ref()
|
||||
|
||||
defineProps({
|
||||
dropdownId: {
|
||||
type: String,
|
||||
default: null,
|
||||
required: false,
|
||||
},
|
||||
dropdownClass: {
|
||||
type: String,
|
||||
default: null,
|
||||
required: false,
|
||||
},
|
||||
tooltip: {
|
||||
type: String,
|
||||
default: null,
|
||||
required: false,
|
||||
},
|
||||
placement: {
|
||||
type: String,
|
||||
default: 'bottom-end',
|
||||
required: false,
|
||||
},
|
||||
container: {
|
||||
type: [String, Object, Boolean],
|
||||
default: 'body',
|
||||
required: false,
|
||||
},
|
||||
})
|
||||
|
||||
function hideAndFocusTrigger(hide) {
|
||||
hide()
|
||||
focusTrigger()
|
||||
}
|
||||
|
||||
function focusTrigger() {
|
||||
trigger.value.focus()
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
function hide() {
|
||||
dropdown.value.hide()
|
||||
}
|
||||
|
||||
function show() {
|
||||
dropdown.value.show()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
hide,
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.dummy-button {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
22
packages/ui/src/components/base/PreviewSelectButton.vue
Normal file
22
packages/ui/src/components/base/PreviewSelectButton.vue
Normal file
@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { RadioButtonCheckedIcon, RadioButtonIcon } from '@modrinth/assets'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
checked: boolean
|
||||
}>(),
|
||||
{
|
||||
checked: false,
|
||||
},
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<div class="" role="button" @click="() => {}">
|
||||
<slot name="preview" />
|
||||
<div>
|
||||
<RadioButtonIcon v-if="!checked" class="w-4 h-4" />
|
||||
<RadioButtonCheckedIcon v-else class="w-4 h-4" />
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
190
packages/ui/src/components/base/ProgressBar.vue
Normal file
190
packages/ui/src/components/base/ProgressBar.vue
Normal file
@ -0,0 +1,190 @@
|
||||
<script setup lang="ts">
|
||||
import SpinnerIcon from '@modrinth/assets/icons/spinner.svg'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
progress: number
|
||||
max?: number
|
||||
color?: 'brand' | 'green' | 'red' | 'orange' | 'blue' | 'purple' | 'gray'
|
||||
waiting?: boolean
|
||||
fullWidth?: boolean
|
||||
striped?: boolean
|
||||
gradientBorder?: boolean
|
||||
label?: string
|
||||
labelClass?: string
|
||||
showProgress?: boolean
|
||||
}>(),
|
||||
{
|
||||
max: 1,
|
||||
color: 'brand',
|
||||
waiting: false,
|
||||
fullWidth: false,
|
||||
striped: false,
|
||||
gradientBorder: true,
|
||||
showProgress: false,
|
||||
},
|
||||
)
|
||||
|
||||
const colors = {
|
||||
brand: {
|
||||
fg: 'bg-brand',
|
||||
bg: 'bg-brand-highlight',
|
||||
},
|
||||
green: {
|
||||
fg: 'bg-green',
|
||||
bg: 'bg-bg-green',
|
||||
},
|
||||
red: {
|
||||
fg: 'bg-red',
|
||||
bg: 'bg-bg-red',
|
||||
},
|
||||
orange: {
|
||||
fg: 'bg-orange',
|
||||
bg: 'bg-bg-orange',
|
||||
},
|
||||
blue: {
|
||||
fg: 'bg-blue',
|
||||
bg: 'bg-bg-blue',
|
||||
},
|
||||
purple: {
|
||||
fg: 'bg-purple',
|
||||
bg: 'bg-bg-purple',
|
||||
},
|
||||
gray: {
|
||||
fg: 'bg-gray',
|
||||
bg: 'bg-bg-gray',
|
||||
},
|
||||
}
|
||||
|
||||
const percent = computed(() => props.progress / props.max)
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex w-full flex-col gap-2" :class="fullWidth ? '' : 'max-w-[15rem]'">
|
||||
<div v-if="label || showProgress" class="flex items-center justify-between">
|
||||
<span v-if="label" :class="labelClass">{{ label }}</span>
|
||||
<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" aria-hidden="true" />
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
<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="[
|
||||
colors[props.color].fg,
|
||||
{ 'progress-bar--waiting': waiting },
|
||||
{ 'progress-bar--gradient-border': gradientBorder },
|
||||
striped ? `progress-bar--striped--${color}` : '',
|
||||
]"
|
||||
:style="!waiting ? { width: `${percent * 100}%` } : {}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped lang="scss">
|
||||
.progress-bar {
|
||||
transition: width 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.progress-bar--waiting {
|
||||
animation: progress-bar-waiting 1s linear infinite;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@keyframes progress-bar-waiting {
|
||||
0% {
|
||||
left: -50%;
|
||||
width: 20%;
|
||||
}
|
||||
50% {
|
||||
width: 60%;
|
||||
}
|
||||
100% {
|
||||
left: 100%;
|
||||
width: 20%;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-bar--gradient-border {
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.3), transparent);
|
||||
border-radius: inherit;
|
||||
mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
mask-composite: xor;
|
||||
padding: 2px;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
%progress-bar--striped-common {
|
||||
background-attachment: scroll;
|
||||
background-position: 0 0;
|
||||
background-size: 9.38px 9.38px;
|
||||
}
|
||||
|
||||
@mixin striped-background($color-variable) {
|
||||
background-image: linear-gradient(
|
||||
135deg,
|
||||
$color-variable 11.54%,
|
||||
transparent 11.54%,
|
||||
transparent 50%,
|
||||
$color-variable 50%,
|
||||
$color-variable 61.54%,
|
||||
transparent 61.54%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
.progress-bar--striped--brand {
|
||||
@include striped-background(var(--color-brand));
|
||||
@extend %progress-bar--striped-common;
|
||||
}
|
||||
|
||||
.progress-bar--striped--green {
|
||||
@include striped-background(var(--color-green));
|
||||
@extend %progress-bar--striped-common;
|
||||
}
|
||||
|
||||
.progress-bar--striped--red {
|
||||
@include striped-background(var(--color-red));
|
||||
@extend %progress-bar--striped-common;
|
||||
}
|
||||
|
||||
.progress-bar--striped--orange {
|
||||
@include striped-background(var(--color-orange));
|
||||
@extend %progress-bar--striped-common;
|
||||
}
|
||||
|
||||
.progress-bar--striped--blue {
|
||||
@include striped-background(var(--color-blue));
|
||||
@extend %progress-bar--striped-common;
|
||||
}
|
||||
|
||||
.progress-bar--striped--purple {
|
||||
@include striped-background(var(--color-purple));
|
||||
@extend %progress-bar--striped-common;
|
||||
}
|
||||
|
||||
.progress-bar--striped--gray {
|
||||
@include striped-background(var(--color-divider-dark));
|
||||
@extend %progress-bar--striped-common;
|
||||
}
|
||||
</style>
|
||||
58
packages/ui/src/components/base/ProgressSpinner.vue
Normal file
58
packages/ui/src/components/base/ProgressSpinner.vue
Normal file
@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
progress: number
|
||||
max?: number
|
||||
}>(),
|
||||
{
|
||||
max: 1,
|
||||
},
|
||||
)
|
||||
|
||||
const percent = computed(() => props.progress / props.max)
|
||||
</script>
|
||||
<template>
|
||||
<span class="relative flex items-center justify-center">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="absolute"
|
||||
>
|
||||
<circle opacity="0.25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
</svg>
|
||||
<svg
|
||||
:style="{ '--_progress': `${percent * 100}%` }"
|
||||
width="24"
|
||||
height="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="absolute progress-circle"
|
||||
>
|
||||
<circle opacity="0.75" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
</svg>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@property --_progress {
|
||||
syntax: '<percentage>';
|
||||
inherits: false;
|
||||
initial-value: 0%;
|
||||
}
|
||||
|
||||
.progress-circle {
|
||||
transition: --_progress 0.125s ease-in-out;
|
||||
mask-image: conic-gradient(
|
||||
black 0%,
|
||||
black var(--_progress),
|
||||
transparent calc(var(--_progress) + 1%),
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
</style>
|
||||
51
packages/ui/src/components/base/RadialHeader.vue
Normal file
51
packages/ui/src/components/base/RadialHeader.vue
Normal file
@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div>
|
||||
<div :style="colorClasses" class="radial-header relative" v-bind="$attrs">
|
||||
<slot />
|
||||
</div>
|
||||
<div class="radial-header-divider" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
color?: 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple' | 'gray'
|
||||
}>(),
|
||||
{
|
||||
color: 'brand',
|
||||
},
|
||||
)
|
||||
|
||||
const colorClasses = computed(
|
||||
() =>
|
||||
`--_radial-bg: var(--color-${props.color}-highlight);--_radial-border: var(--color-${props.color});`,
|
||||
)
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.radial-header {
|
||||
background-image: radial-gradient(50% 100% at 50% 100%, var(--_radial-bg) 10%, #ffffff00 100%);
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
#ffffff00 0%,
|
||||
var(--_radial-border) 50%,
|
||||
#ffffff00 100%
|
||||
);
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
48
packages/ui/src/components/base/RadioButtons.vue
Normal file
48
packages/ui/src/components/base/RadioButtons.vue
Normal file
@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<button
|
||||
v-for="(item, index) in items"
|
||||
:key="`radio-button-${index}`"
|
||||
class="p-0 py-2 px-2 border-0 font-medium flex gap-2 transition-all items-center cursor-pointer active:scale-95 hover:bg-button-bg rounded-xl"
|
||||
:class="{
|
||||
'text-contrast bg-button-bg': selected === item,
|
||||
'text-primary bg-transparent': selected !== item,
|
||||
}"
|
||||
@click="selected = item"
|
||||
>
|
||||
<RadioButtonCheckedIcon v-if="selected === item" class="text-brand h-5 w-5" />
|
||||
<RadioButtonIcon v-else class="h-5 w-5" />
|
||||
<slot :item="item" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts" generic="T">
|
||||
import { RadioButtonCheckedIcon, RadioButtonIcon } from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: T
|
||||
items: T[]
|
||||
forceSelection?: boolean
|
||||
}>(),
|
||||
{
|
||||
forceSelection: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const selected = computed({
|
||||
get() {
|
||||
return props.modelValue
|
||||
},
|
||||
set(value) {
|
||||
emit('update:modelValue', value)
|
||||
},
|
||||
})
|
||||
|
||||
if (props.items.length > 0 && props.forceSelection && !props.modelValue) {
|
||||
selected.value = props.items[0]
|
||||
}
|
||||
</script>
|
||||
95
packages/ui/src/components/base/ReadyTransition.vue
Normal file
95
packages/ui/src/components/base/ReadyTransition.vue
Normal file
@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* If `pending` is false on mount and never becomes true, the slot renders with no
|
||||
* enter transition (cache-hit fast path). After a real pending phase, transitions
|
||||
* behave as before for subsequent toggles.
|
||||
*/
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, ref, toRef, watch } from 'vue'
|
||||
|
||||
import { injectLoadingState } from '#ui/providers/loading-state'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** True while wrapped content loads. The optional pending slot renders and loading bar runs. */
|
||||
pending: boolean | Ref<boolean>
|
||||
/** Fade duration applied to the slot when content reveals. */
|
||||
duration?: number
|
||||
/** When true, do NOT register a token with the global loading bar — only fade locally. */
|
||||
silent?: boolean
|
||||
}>(),
|
||||
{
|
||||
duration: 200,
|
||||
silent: false,
|
||||
},
|
||||
)
|
||||
|
||||
const pendingRef = toRef(props, 'pending') as Ref<boolean | Ref<boolean>>
|
||||
const resolvedPending = computed(() => {
|
||||
const v = pendingRef.value
|
||||
if (typeof v === 'boolean') return v
|
||||
return Boolean((v as Ref<boolean>).value)
|
||||
})
|
||||
|
||||
const hasBeenPending = ref(false)
|
||||
const useShell = computed(() => resolvedPending.value || hasBeenPending.value)
|
||||
|
||||
const loadingState = injectLoadingState(null)
|
||||
let token: symbol | null = null
|
||||
|
||||
function release() {
|
||||
if (token && loadingState) {
|
||||
loadingState.end(token)
|
||||
}
|
||||
token = null
|
||||
}
|
||||
|
||||
watch(
|
||||
resolvedPending,
|
||||
(now) => {
|
||||
if (now) {
|
||||
hasBeenPending.value = true
|
||||
}
|
||||
if (loadingState && !props.silent && typeof window !== 'undefined') {
|
||||
if (now) {
|
||||
if (!token) token = loadingState.begin()
|
||||
} else {
|
||||
release()
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onBeforeUnmount(release)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="useShell">
|
||||
<Transition name="ready-fade" mode="out-in" :duration="props.duration">
|
||||
<div v-if="!resolvedPending" key="content" class="w-full">
|
||||
<slot />
|
||||
</div>
|
||||
<div v-else key="pending" class="w-full h-full">
|
||||
<slot name="pending" />
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
<slot v-else />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ready-fade-enter-active,
|
||||
.ready-fade-leave-active {
|
||||
transition: opacity v-bind('`${props.duration}ms`') ease-in-out;
|
||||
}
|
||||
|
||||
.ready-fade-enter-from,
|
||||
.ready-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
70
packages/ui/src/components/base/ScrollToTopButton.vue
Normal file
70
packages/ui/src/components/base/ScrollToTopButton.vue
Normal file
@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronUpIcon } from '@modrinth/assets'
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import ButtonStyled from './ButtonStyled.vue'
|
||||
|
||||
const visible = ref(false)
|
||||
let scrollContainer: Element | null = null
|
||||
|
||||
function update() {
|
||||
visible.value = (scrollContainer?.scrollTop ?? 0) > 300
|
||||
}
|
||||
|
||||
function scrollToTop() {
|
||||
scrollContainer?.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
scrollContainer = document.querySelector('.app-viewport')
|
||||
if (scrollContainer) {
|
||||
scrollContainer.addEventListener('scroll', update, { passive: true })
|
||||
update()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
scrollContainer?.removeEventListener('scroll', update)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="scroll-to-top">
|
||||
<div v-if="visible" class="scroll-to-top-wrapper">
|
||||
<ButtonStyled circular size="large" color="brand">
|
||||
<button
|
||||
v-tooltip="'Scroll to top'"
|
||||
class="scroll-to-top-btn"
|
||||
type="button"
|
||||
aria-label="Scroll to top"
|
||||
@click="scrollToTop"
|
||||
>
|
||||
<ChevronUpIcon aria-hidden="true" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.scroll-to-top-btn {
|
||||
@apply shadow-lg transition-all duration-200 hover:brightness-110 hover:shadow-xl active:scale-95;
|
||||
}
|
||||
|
||||
.scroll-to-top-wrapper {
|
||||
@apply fixed bottom-10 left-24 z-50;
|
||||
}
|
||||
|
||||
.scroll-to-top-enter-active,
|
||||
.scroll-to-top-leave-active {
|
||||
transition:
|
||||
opacity 0.24s ease,
|
||||
transform 0.24s ease;
|
||||
}
|
||||
|
||||
.scroll-to-top-enter-from,
|
||||
.scroll-to-top-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
</style>
|
||||
115
packages/ui/src/components/base/ScrollablePanel.vue
Normal file
115
packages/ui/src/components/base/ScrollablePanel.vue
Normal file
@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<div class="flex flex-col relative">
|
||||
<div
|
||||
class="wrapper-wrapper"
|
||||
:class="{
|
||||
'top-fade': !scrollableAtTop && !disableScrolling,
|
||||
'bottom-fade': !scrollableAtBottom && !disableScrolling,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
ref="scrollablePane"
|
||||
:class="{
|
||||
'max-h-[19rem]': !disableScrolling,
|
||||
}"
|
||||
class="scrollable-pane"
|
||||
@scroll="onScroll"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
disableScrolling?: boolean
|
||||
}>(),
|
||||
{
|
||||
disableScrolling: false,
|
||||
},
|
||||
)
|
||||
|
||||
const scrollableAtTop = ref(true)
|
||||
const scrollableAtBottom = ref(false)
|
||||
const scrollablePane = ref(null)
|
||||
let resizeObserver
|
||||
onMounted(() => {
|
||||
resizeObserver = new ResizeObserver(function () {
|
||||
if (scrollablePane.value) {
|
||||
updateFade(
|
||||
scrollablePane.value.scrollTop,
|
||||
scrollablePane.value.offsetHeight,
|
||||
scrollablePane.value.scrollHeight,
|
||||
)
|
||||
}
|
||||
})
|
||||
resizeObserver.observe(scrollablePane.value)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
})
|
||||
function updateFade(scrollTop, offsetHeight, scrollHeight) {
|
||||
scrollableAtBottom.value = Math.ceil(scrollTop + offsetHeight) >= scrollHeight
|
||||
scrollableAtTop.value = scrollTop <= 0
|
||||
}
|
||||
function onScroll({ target: { scrollTop, offsetHeight, scrollHeight } }) {
|
||||
updateFade(scrollTop, offsetHeight, scrollHeight)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@property --_top-fade-height {
|
||||
syntax: '<length-percentage>';
|
||||
inherits: false;
|
||||
initial-value: 0%;
|
||||
}
|
||||
|
||||
@property --_bottom-fade-height {
|
||||
syntax: '<length-percentage>';
|
||||
inherits: false;
|
||||
initial-value: 0%;
|
||||
}
|
||||
|
||||
.wrapper-wrapper {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition:
|
||||
--_top-fade-height 0.05s linear,
|
||||
--_bottom-fade-height 0.05s linear;
|
||||
|
||||
--_fade-height: 3rem;
|
||||
|
||||
mask-image: linear-gradient(
|
||||
transparent,
|
||||
rgb(0 0 0 / 100%) var(--_top-fade-height, 0%),
|
||||
rgb(0 0 0 / 100%) calc(100% - var(--_bottom-fade-height, 0%)),
|
||||
transparent 100%
|
||||
);
|
||||
|
||||
&.top-fade {
|
||||
--_top-fade-height: var(--_fade-height);
|
||||
}
|
||||
|
||||
&.bottom-fade {
|
||||
--_bottom-fade-height: var(--_fade-height);
|
||||
}
|
||||
}
|
||||
.scrollable-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
50
packages/ui/src/components/base/SelectionCard.vue
Normal file
50
packages/ui/src/components/base/SelectionCard.vue
Normal file
@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon } from '@modrinth/assets'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
icon: Component
|
||||
title: string
|
||||
description: string
|
||||
selected?: boolean
|
||||
disabled?: boolean
|
||||
value: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
select: [value: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="group flex flex-col rounded-xl border-2 p-4 transition-all duration-300 cursor-pointer text-left"
|
||||
:class="[
|
||||
selected
|
||||
? 'border-brand bg-brand-highlight'
|
||||
: 'border-surface-4 bg-surface-2 hover:border-surface-5',
|
||||
disabled ? 'opacity-60 pointer-events-none' : '',
|
||||
]"
|
||||
:disabled="disabled"
|
||||
@click="$emit('select', value)"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<component :is="icon" class="size-6 text-secondary shrink-0" stroke-width="1.5" />
|
||||
<div class="flex flex-col min-w-0 flex-1">
|
||||
<span class="text-sm font-semibold text-contrast">{{ title }}</span>
|
||||
<span class="text-xs text-secondary">{{ description }}</span>
|
||||
</div>
|
||||
<CheckIcon v-if="selected" class="size-5 text-brand shrink-0" stroke-width="2.5" />
|
||||
</div>
|
||||
<div
|
||||
class="overflow-hidden transition-all duration-300"
|
||||
:class="
|
||||
selected
|
||||
? 'max-h-24 mt-3 opacity-100'
|
||||
: 'max-h-0 group-hover:max-h-24 group-hover:mt-3 group-hover:opacity-100 opacity-0'
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
106
packages/ui/src/components/base/ServerNotice.vue
Normal file
106
packages/ui/src/components/base/ServerNotice.vue
Normal file
@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="level === 'survey'"
|
||||
class="flex items-center gap-2 border-2 border-solid border-brand-purple bg-bg-purple p-4 rounded-2xl"
|
||||
>
|
||||
<span class="text-contrast font-bold">Survey ID:</span> <CopyCode :text="message" />
|
||||
</div>
|
||||
<Admonition v-else :type="NOTICE_TYPE[level]">
|
||||
<template #header>
|
||||
<template v-if="!hideDefaultTitle">
|
||||
{{ formatMessage(heading) }}
|
||||
</template>
|
||||
<template v-if="title">
|
||||
<template v-if="hideDefaultTitle">
|
||||
{{ title.substring(1) }}
|
||||
</template>
|
||||
<template v-else> - {{ title }}</template>
|
||||
</template>
|
||||
</template>
|
||||
<template #actions>
|
||||
<ButtonStyled v-if="dismissable" :color="NOTICE_TYPE_BTN[level]">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.dismiss)"
|
||||
@click="() => (preview ? {} : emit('dismiss'))"
|
||||
>
|
||||
<XIcon /> Dismiss
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<div v-if="message" class="markdown-body" v-html="renderString(message)" />
|
||||
</Admonition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { XIcon } from '@modrinth/assets'
|
||||
import { renderString } from '@modrinth/utils'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { defineMessages, type MessageDescriptor, useVIntl } from '../../composables/i18n'
|
||||
import Admonition from './Admonition.vue'
|
||||
import ButtonStyled from './ButtonStyled.vue'
|
||||
import CopyCode from './CopyCode.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const emit = defineEmits<{
|
||||
(e: 'dismiss'): void
|
||||
}>()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
level: string
|
||||
message: string
|
||||
dismissable: boolean
|
||||
preview?: boolean
|
||||
title?: string
|
||||
}>(),
|
||||
{
|
||||
preview: false,
|
||||
title: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const hideDefaultTitle = computed(
|
||||
() => props.title && props.title.length > 1 && props.title.startsWith('\\'),
|
||||
)
|
||||
|
||||
const messages = defineMessages({
|
||||
info: {
|
||||
id: 'servers.notice.heading.info',
|
||||
defaultMessage: 'Info',
|
||||
},
|
||||
attention: {
|
||||
id: 'servers.notice.heading.attention',
|
||||
defaultMessage: 'Attention',
|
||||
},
|
||||
dismiss: {
|
||||
id: 'servers.notice.dismiss',
|
||||
defaultMessage: 'Dismiss',
|
||||
},
|
||||
})
|
||||
|
||||
const NOTICE_HEADINGS: Record<string, MessageDescriptor> = {
|
||||
info: messages.info,
|
||||
warn: messages.attention,
|
||||
critical: messages.attention,
|
||||
}
|
||||
|
||||
const NOTICE_TYPE: Record<string, 'info' | 'warning' | 'critical'> = {
|
||||
info: 'info',
|
||||
warn: 'warning',
|
||||
critical: 'critical',
|
||||
}
|
||||
|
||||
const NOTICE_TYPE_BTN: Record<string, 'blue' | 'orange' | 'red'> = {
|
||||
info: 'blue',
|
||||
warn: 'orange',
|
||||
critical: 'red',
|
||||
}
|
||||
|
||||
const heading = computed(() => NOTICE_HEADINGS[props.level] ?? messages.info)
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.markdown-body > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
</style>
|
||||
45
packages/ui/src/components/base/SettingsLabel.vue
Normal file
45
packages/ui/src/components/base/SettingsLabel.vue
Normal file
@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { MessageDescriptor } from '../../composables/i18n'
|
||||
import { useVIntl } from '../../composables/i18n'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
title: string | MessageDescriptor
|
||||
description?: string | MessageDescriptor
|
||||
}>(),
|
||||
{
|
||||
id: undefined,
|
||||
description: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const formattedTitle = computed(() =>
|
||||
typeof props.title === 'string' ? props.title : formatMessage(props.title),
|
||||
)
|
||||
const formattedDescription = computed(() =>
|
||||
typeof props.description === 'string'
|
||||
? props.description
|
||||
: props.description
|
||||
? formatMessage(props.description)
|
||||
: undefined,
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mb-2">
|
||||
<label v-if="id" :for="id" class="text-lg font-extrabold text-contrast">
|
||||
{{ formattedTitle }}
|
||||
</label>
|
||||
<p v-else class="m-0 text-lg font-extrabold text-contrast">
|
||||
{{ formattedTitle }}
|
||||
</p>
|
||||
<p v-if="formattedDescription" class="text-sm m-0 text-secondary">
|
||||
{{ formattedDescription }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
16
packages/ui/src/components/base/SimpleBadge.vue
Normal file
16
packages/ui/src/components/base/SimpleBadge.vue
Normal file
@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<span class="inline-flex items-center gap-1 font-semibold text-secondary">
|
||||
<component :is="icon" v-if="icon" :aria-hidden="true" class="shrink-0" />
|
||||
{{ formattedName }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
icon?: Component
|
||||
formattedName: string
|
||||
color?: 'brand' | 'green' | 'blue' | 'purple' | 'orange' | 'red'
|
||||
}>()
|
||||
</script>
|
||||
191
packages/ui/src/components/base/Slider.vue
Normal file
191
packages/ui/src/components/base/Slider.vue
Normal file
@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<div class="flex flex-row items-center w-full">
|
||||
<div class="w-full relative">
|
||||
<div class="absolute top-0 h-1/2 w-full">
|
||||
<div
|
||||
class="relative inline-block align-middle w-[calc(100%-0.75rem)] h-3 left-[calc(0.75rem/2)]"
|
||||
>
|
||||
<div
|
||||
v-for="snapPoint in snapPoints"
|
||||
:key="snapPoint"
|
||||
class="absolute inline-block w-1 h-full rounded-sm -translate-x-1/2"
|
||||
:class="{
|
||||
'opacity-0': disabled,
|
||||
}"
|
||||
:style="{
|
||||
left: ((snapPoint - min) / (max - min)) * 100 + '%',
|
||||
backgroundColor:
|
||||
snapPoint <= currentValue ? 'var(--color-brand)' : 'var(--color-base)',
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref="input"
|
||||
v-model="currentValue"
|
||||
type="range"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
class="slider relative rounded-sm h-1 w-full p-0 min-h-0 shadow-none outline-none align-middle appearance-none"
|
||||
:class="{
|
||||
'opacity-50 cursor-not-allowed': disabled,
|
||||
}"
|
||||
:disabled="disabled"
|
||||
:style="{
|
||||
'--current-value': currentValue,
|
||||
'--min-value': min,
|
||||
'--max-value': max,
|
||||
}"
|
||||
@input="onInputWithSnap(($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<div class="flex flex-row justify-between text-xs m-0">
|
||||
<span> {{ min }} {{ unit }} </span>
|
||||
<span> {{ max }} {{ unit }} </span>
|
||||
</div>
|
||||
</div>
|
||||
<StyledInput
|
||||
:model-value="String(currentValue)"
|
||||
type="number"
|
||||
class="w-24 ml-3"
|
||||
:disabled="disabled"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
@change="onInput(($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import StyledInput from './StyledInput.vue'
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [number] }>()
|
||||
|
||||
interface Props {
|
||||
modelValue?: number
|
||||
min: number
|
||||
max: number
|
||||
step?: number
|
||||
forceStep?: boolean
|
||||
snapPoints?: number[]
|
||||
snapRange?: number
|
||||
disabled?: boolean
|
||||
unit?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: 0,
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 10,
|
||||
forceStep: true,
|
||||
snapPoints: () => [],
|
||||
snapRange: 100,
|
||||
disabled: false,
|
||||
unit: '',
|
||||
})
|
||||
|
||||
const currentValue = ref(Math.max(props.min, props.modelValue))
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
currentValue.value = Math.max(props.min, newValue ?? props.min)
|
||||
},
|
||||
)
|
||||
|
||||
const inputValueValid = (inputValue: number) => {
|
||||
let newValue = inputValue || props.min
|
||||
|
||||
if (props.forceStep) {
|
||||
newValue -= newValue % props.step
|
||||
}
|
||||
newValue = Math.max(props.min, Math.min(newValue, props.max))
|
||||
|
||||
currentValue.value = newValue
|
||||
emit('update:modelValue', currentValue.value)
|
||||
}
|
||||
|
||||
const onInputWithSnap = (value: string) => {
|
||||
let parsedValue = parseInt(value)
|
||||
|
||||
for (const snapPoint of props.snapPoints) {
|
||||
const distance = Math.abs(snapPoint - parsedValue)
|
||||
|
||||
if (distance < props.snapRange) {
|
||||
parsedValue = snapPoint
|
||||
}
|
||||
}
|
||||
|
||||
inputValueValid(parsedValue)
|
||||
}
|
||||
|
||||
const onInput = (value: string) => {
|
||||
inputValueValid(parseInt(value))
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--color-brand) 0%,
|
||||
var(--color-brand)
|
||||
calc(
|
||||
(var(--current-value) - var(--min-value)) / (var(--max-value) - var(--min-value)) * 100%
|
||||
),
|
||||
var(--color-base)
|
||||
calc(
|
||||
(var(--current-value) - var(--min-value)) / (var(--max-value) - var(--min-value)) * 100%
|
||||
),
|
||||
var(--color-base) 100%
|
||||
)
|
||||
100% 100% no-repeat;
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
background: var(--color-brand);
|
||||
border-radius: 50%;
|
||||
transition:
|
||||
width 0.2s,
|
||||
height 0.2s;
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
border: none;
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
background: var(--color-brand);
|
||||
border-radius: 50%;
|
||||
transition:
|
||||
width 0.2s,
|
||||
height 0.2s;
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover:not(:disabled)::-webkit-slider-thumb,
|
||||
&:hover:not(:disabled)::-moz-range-thumb {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
86
packages/ui/src/components/base/SmartClickable.vue
Normal file
86
packages/ui/src/components/base/SmartClickable.vue
Normal file
@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div
|
||||
class="smart-clickable grid"
|
||||
:class="{ 'smart-clickable--has-clickable': !!$slots.clickable }"
|
||||
>
|
||||
<slot name="clickable" />
|
||||
<div
|
||||
v-bind="$attrs"
|
||||
class="smart-clickable__contents"
|
||||
:class="{
|
||||
'pointer-events-none': !!$slots.clickable,
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.smart-clickable {
|
||||
> * {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
|
||||
.smart-clickable__contents {
|
||||
// Utility classes for contents
|
||||
:deep(.smart-clickable\:allow-pointer-events) {
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
:deep(.ease-brightness) {
|
||||
opacity: 1;
|
||||
transition: opacity 0.125s ease-out;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only apply effects when a clickable is present
|
||||
.smart-clickable.smart-clickable--has-clickable {
|
||||
// Setup base styles for contents
|
||||
.smart-clickable__contents {
|
||||
transition: scale 0.125s ease-out;
|
||||
}
|
||||
|
||||
// When clickable is being hovered or focus-visible, give contents an effect
|
||||
:first-child:hover + .smart-clickable__contents,
|
||||
:first-child:focus-visible + .smart-clickable__contents,
|
||||
.smart-clickable__contents:hover,
|
||||
.smart-clickable__contents:focus-within {
|
||||
// Utility classes for contents
|
||||
:deep(.smart-clickable\:underline-on-hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
:deep(.smart-clickable\:highlight-on-hover) {
|
||||
filter: brightness(var(--hover-brightness, 1.25));
|
||||
}
|
||||
:deep(.smart-clickable\:surface-4-on-hover) {
|
||||
@apply bg-surface-4;
|
||||
}
|
||||
:deep(.smart-clickable\:surface-5-on-hover) {
|
||||
@apply bg-surface-5;
|
||||
}
|
||||
:deep(.ease-brightness) {
|
||||
opacity: 0.85;
|
||||
transition: opacity 0.125s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
:first-child:focus-visible + .smart-clickable__contents {
|
||||
// Utility classes for contents
|
||||
:deep(.smart-clickable\:outline-on-focus) {
|
||||
outline: 0.25rem solid var(--color-focus-ring);
|
||||
}
|
||||
}
|
||||
|
||||
// When clickable is being clicked, give contents an effect
|
||||
:first-child:active + .smart-clickable__contents {
|
||||
scale: 0.97;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
617
packages/ui/src/components/base/StackedAdmonitions.vue
Normal file
617
packages/ui/src/components/base/StackedAdmonitions.vue
Normal file
@ -0,0 +1,617 @@
|
||||
<script lang="ts"></script>
|
||||
|
||||
<script setup lang="ts" generic="ItemType extends StackedAdmonitionItem">
|
||||
import { ChevronDownIcon, XIcon } from '@modrinth/assets'
|
||||
import { AnimatePresence, Motion } from 'motion-v'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, useAttrs, useId, watch } from 'vue'
|
||||
|
||||
import { defineMessages, useVIntl } from '../../composables/i18n'
|
||||
import ButtonStyled from './ButtonStyled.vue'
|
||||
export type StackedAdmonitionType = 'info' | 'warning' | 'critical' | 'success'
|
||||
|
||||
/** Extend this interface to attach arbitrary per-item data consumed in the #item slot. */
|
||||
export interface StackedAdmonitionItem {
|
||||
id: string
|
||||
type: StackedAdmonitionType
|
||||
dismissible?: boolean
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items: ItemType[]
|
||||
peek?: number
|
||||
hoverPeek?: number
|
||||
expandedGap?: number
|
||||
scaleStep?: number
|
||||
hoverScaleStep?: number
|
||||
maxVisibleBehind?: number
|
||||
dismissAllEnabled?: boolean
|
||||
expanded?: boolean
|
||||
}>(),
|
||||
{
|
||||
peek: 8,
|
||||
hoverPeek: 16,
|
||||
expandedGap: 12,
|
||||
scaleStep: 0.04,
|
||||
hoverScaleStep: 0.025,
|
||||
maxVisibleBehind: 2,
|
||||
dismissAllEnabled: true,
|
||||
expanded: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'dismiss-all': []
|
||||
'update:expanded': [value: boolean]
|
||||
expand: []
|
||||
collapse: []
|
||||
}>()
|
||||
|
||||
defineSlots<{
|
||||
item(props: {
|
||||
item: ItemType
|
||||
index: number
|
||||
isFront: boolean
|
||||
expanded: boolean
|
||||
/** Whether the consumer should render the Admonition's own dismiss button. */
|
||||
dismissible: boolean
|
||||
}): unknown
|
||||
'header-label'(props: { count: number; expanded: boolean }): unknown
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const stackId = useId()
|
||||
const attrs = useAttrs()
|
||||
|
||||
const internalExpanded = ref(false)
|
||||
const isHovered = ref(false)
|
||||
const prefersReducedMotion = ref(false)
|
||||
const initialMeasurementSettled = ref(false)
|
||||
const enteringItemIds = ref<Set<string>>(new Set())
|
||||
const actionBarHeight = ref(0)
|
||||
|
||||
const heights = ref<Record<string, number>>({})
|
||||
const cardEls = new Map<string, HTMLElement>()
|
||||
const observers = new Map<string, ResizeObserver>()
|
||||
const pendingHeights = new Map<string, number>()
|
||||
let flushHandle: number | null = null
|
||||
let initialMeasurementHandle: number | null = null
|
||||
let enteringHandle: number | null = null
|
||||
let actionBarObserver: ResizeObserver | null = null
|
||||
|
||||
// Slot content may run effects, so measure the one real tree instead of mounting
|
||||
// hidden duplicates just to discover natural card heights.
|
||||
function scheduleHeightFlush() {
|
||||
if (flushHandle != null) return
|
||||
flushHandle = requestAnimationFrame(() => {
|
||||
flushHandle = null
|
||||
if (pendingHeights.size === 0) return
|
||||
const next = { ...heights.value }
|
||||
let changed = false
|
||||
for (const [id, h] of pendingHeights) {
|
||||
if (next[id] !== h) {
|
||||
next[id] = h
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
pendingHeights.clear()
|
||||
if (changed) {
|
||||
heights.value = next
|
||||
if (!initialMeasurementSettled.value && initialMeasurementHandle == null) {
|
||||
initialMeasurementHandle = requestAnimationFrame(() => {
|
||||
initialMeasurementHandle = null
|
||||
initialMeasurementSettled.value = true
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const isExpanded = computed(() => {
|
||||
if (props.items.length <= 1) return false
|
||||
return props.expanded ?? internalExpanded.value
|
||||
})
|
||||
const hasActionBar = computed(() => props.items.length >= 2)
|
||||
function itemDismissible(item: ItemType) {
|
||||
return item.dismissible ?? true
|
||||
}
|
||||
|
||||
type StackPhase = 'collapsed' | 'expanding' | 'expanded' | 'collapsing'
|
||||
|
||||
const phase = ref<StackPhase>(isExpanded.value ? 'expanded' : 'collapsed')
|
||||
const isSettledCollapsed = computed(() => phase.value === 'collapsed')
|
||||
const containerHeightSettled = ref(true)
|
||||
const singleItemEntrance = ref(false)
|
||||
|
||||
// Behind cards morph between a collapsed placeholder and real content. The shell
|
||||
// height owns that morph so mixed-height cards do not swap DOM midway through motion.
|
||||
function measuredCardHeight(index: number) {
|
||||
const item = props.items[index]
|
||||
return item ? (heights.value[item.id] ?? 0) : 0
|
||||
}
|
||||
|
||||
function hasMeasuredCard(index: number) {
|
||||
const item = props.items[index]
|
||||
return !!item && heights.value[item.id] != null
|
||||
}
|
||||
|
||||
const frontCardHeight = computed(() => measuredCardHeight(0))
|
||||
|
||||
const hasBehind = computed(() => props.items.length > 1)
|
||||
|
||||
function currentPeek() {
|
||||
return isHovered.value ? props.hoverPeek : props.peek
|
||||
}
|
||||
|
||||
function currentScaleStep() {
|
||||
return isHovered.value ? props.hoverScaleStep : props.scaleStep
|
||||
}
|
||||
|
||||
function targetCardHeight(index: number) {
|
||||
if (index === 0) return measuredCardHeight(0)
|
||||
|
||||
const measured = measuredCardHeight(index) || frontCardHeight.value
|
||||
return isExpanded.value ? measured : frontCardHeight.value
|
||||
}
|
||||
|
||||
const containerHeight = computed(() => {
|
||||
if (isExpanded.value) {
|
||||
return props.items.reduce((acc, _, i) => {
|
||||
return acc + measuredCardHeight(i) + (i > 0 ? props.expandedGap : 0)
|
||||
}, 0)
|
||||
}
|
||||
if (!hasBehind.value) return frontCardHeight.value
|
||||
const behind = Math.min(props.items.length - 1, props.maxVisibleBehind)
|
||||
const pad = isHovered.value ? 6 : 0
|
||||
return frontCardHeight.value + currentPeek() * behind + pad
|
||||
})
|
||||
|
||||
const stackShellHeight = computed(() => {
|
||||
return containerHeight.value + (hasActionBar.value ? actionBarHeight.value : 0)
|
||||
})
|
||||
const containerOverflow = computed(() => {
|
||||
if (isExpanded.value) return 'visible'
|
||||
if (!containerHeightSettled.value) return 'hidden'
|
||||
if (!hasBehind.value && hasMeasuredCard(0)) return 'visible'
|
||||
return 'hidden'
|
||||
})
|
||||
|
||||
const springTransition = computed(() =>
|
||||
prefersReducedMotion.value || !initialMeasurementSettled.value
|
||||
? { duration: 0 }
|
||||
: { type: 'spring' as const, stiffness: 260, damping: 32 },
|
||||
)
|
||||
const heightTransition = computed(() =>
|
||||
singleItemEntrance.value ? { duration: 0.12, ease: 'easeOut' as const } : springTransition.value,
|
||||
)
|
||||
|
||||
const exitTransition = computed(() =>
|
||||
prefersReducedMotion.value ? { duration: 0 } : { duration: 0.18 },
|
||||
)
|
||||
|
||||
const shellExitTransition = computed(() =>
|
||||
prefersReducedMotion.value ? { duration: 0 } : { duration: 0.16 },
|
||||
)
|
||||
|
||||
function collapsedCardPosition(index: number) {
|
||||
const hidden = index > props.maxVisibleBehind
|
||||
return {
|
||||
y: index * currentPeek(),
|
||||
scale: Math.max(0.8, 1 - index * currentScaleStep()),
|
||||
opacity: hidden ? 0 : 1,
|
||||
}
|
||||
}
|
||||
|
||||
function expandedCardPosition(index: number) {
|
||||
let y = 0
|
||||
for (let i = 0; i < index; i++) {
|
||||
y += measuredCardHeight(i) + props.expandedGap
|
||||
}
|
||||
return { y, scale: 1, opacity: 1 }
|
||||
}
|
||||
|
||||
function cardPosition(index: number) {
|
||||
const position = isExpanded.value ? expandedCardPosition(index) : collapsedCardPosition(index)
|
||||
const item = props.items[index]
|
||||
if (index === 0 && singleItemEntrance.value) {
|
||||
return {
|
||||
...position,
|
||||
opacity: 0,
|
||||
}
|
||||
}
|
||||
if (!item || !enteringItemIds.value.has(item.id)) return position
|
||||
|
||||
return {
|
||||
...position,
|
||||
y: position.y + 8,
|
||||
opacity: 0,
|
||||
scale: Math.min(1, position.scale + 0.02),
|
||||
}
|
||||
}
|
||||
|
||||
function contentOpacity(index: number) {
|
||||
return isExpanded.value && hasMeasuredCard(index) ? 1 : 0
|
||||
}
|
||||
|
||||
// Newly inserted cards need an explicit two-frame enter target because Motion's
|
||||
// initial state is disabled to avoid animating from zero-height on first mount.
|
||||
function markEntering(ids: string[]) {
|
||||
if (!initialMeasurementSettled.value || prefersReducedMotion.value || ids.length === 0) return
|
||||
|
||||
const next = new Set(enteringItemIds.value)
|
||||
for (const id of ids) next.add(id)
|
||||
enteringItemIds.value = next
|
||||
|
||||
if (enteringHandle != null) cancelAnimationFrame(enteringHandle)
|
||||
enteringHandle = requestAnimationFrame(() => {
|
||||
enteringHandle = requestAnimationFrame(() => {
|
||||
enteringHandle = null
|
||||
enteringItemIds.value = new Set()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function onContainerAnimationComplete() {
|
||||
phase.value = isExpanded.value ? 'expanded' : 'collapsed'
|
||||
containerHeightSettled.value = true
|
||||
if (containerHeight.value > 0) {
|
||||
singleItemEntrance.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const containerMotionProps = computed(() => ({
|
||||
onAnimationComplete: onContainerAnimationComplete,
|
||||
}))
|
||||
|
||||
function resolveNode(el: unknown): HTMLElement | null {
|
||||
if (!el) return null
|
||||
if (el instanceof HTMLElement) return el
|
||||
if (typeof el === 'object' && '$el' in el) {
|
||||
const node = (el as { $el: unknown }).$el
|
||||
return node instanceof HTMLElement ? node : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function setCardRef(id: string, el: unknown) {
|
||||
const node = resolveNode(el)
|
||||
if (!node) return
|
||||
pendingHeights.set(id, node.offsetHeight)
|
||||
scheduleHeightFlush()
|
||||
if (cardEls.get(id) === node) return
|
||||
observers.get(id)?.disconnect()
|
||||
cardEls.set(id, node)
|
||||
const ro = new ResizeObserver(() => {
|
||||
pendingHeights.set(id, node.offsetHeight)
|
||||
scheduleHeightFlush()
|
||||
})
|
||||
ro.observe(node)
|
||||
observers.set(id, ro)
|
||||
}
|
||||
|
||||
function setActionBarRef(el: unknown) {
|
||||
const node = resolveNode(el)
|
||||
actionBarObserver?.disconnect()
|
||||
actionBarObserver = null
|
||||
if (!node) {
|
||||
actionBarHeight.value = 0
|
||||
return
|
||||
}
|
||||
|
||||
actionBarHeight.value = node.offsetHeight
|
||||
const ro = new ResizeObserver(() => {
|
||||
actionBarHeight.value = node.offsetHeight
|
||||
})
|
||||
ro.observe(node)
|
||||
actionBarObserver = ro
|
||||
}
|
||||
|
||||
function setExpanded(v: boolean) {
|
||||
internalExpanded.value = v
|
||||
emit('update:expanded', v)
|
||||
if (v) emit('expand')
|
||||
else emit('collapse')
|
||||
}
|
||||
|
||||
function openStack() {
|
||||
if (props.items.length <= 1 || isExpanded.value) return
|
||||
phase.value = 'expanding'
|
||||
setExpanded(true)
|
||||
}
|
||||
|
||||
function closeStack() {
|
||||
if (!isExpanded.value) return
|
||||
phase.value = 'collapsing'
|
||||
setExpanded(false)
|
||||
}
|
||||
|
||||
function toggleExpanded() {
|
||||
if (props.items.length <= 1) return
|
||||
if (isExpanded.value) closeStack()
|
||||
else openStack()
|
||||
}
|
||||
|
||||
function isInteractiveTarget(target: HTMLElement | null, currentTarget: EventTarget | null) {
|
||||
if (!target) return false
|
||||
const interactive = target.closest(
|
||||
'button, a, input, select, textarea, summary, [role="button"], [role="link"]',
|
||||
)
|
||||
return !!interactive && interactive !== currentTarget
|
||||
}
|
||||
|
||||
function onContainerClick(e: MouseEvent) {
|
||||
if (isExpanded.value || props.items.length <= 1) return
|
||||
const target = e.target as HTMLElement | null
|
||||
if (isInteractiveTarget(target, e.currentTarget)) return
|
||||
openStack()
|
||||
}
|
||||
|
||||
function onCardClick(e: MouseEvent) {
|
||||
if (!isExpanded.value) return
|
||||
const target = e.target as HTMLElement | null
|
||||
if (isInteractiveTarget(target, e.currentTarget)) return
|
||||
e.stopPropagation()
|
||||
closeStack()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.items.length,
|
||||
(n, previousLength) => {
|
||||
if (previousLength === 0 && n === 1 && !prefersReducedMotion.value) {
|
||||
singleItemEntrance.value = true
|
||||
} else if (n !== 1) {
|
||||
singleItemEntrance.value = false
|
||||
}
|
||||
|
||||
if (n <= 1 && (props.expanded ?? internalExpanded.value)) {
|
||||
phase.value = 'collapsed'
|
||||
setExpanded(false)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(isExpanded, (expanded, previousExpanded) => {
|
||||
if (previousExpanded === undefined) {
|
||||
phase.value = expanded ? 'expanded' : 'collapsed'
|
||||
return
|
||||
}
|
||||
if (expanded && phase.value !== 'expanding') phase.value = 'expanding'
|
||||
else if (!expanded && phase.value !== 'collapsing') phase.value = 'collapsing'
|
||||
})
|
||||
|
||||
watch(containerHeight, (height, previousHeight) => {
|
||||
if (height !== previousHeight) {
|
||||
const openingSingleItem =
|
||||
previousHeight === 0 && height > 0 && props.items.length === 1 && !prefersReducedMotion.value
|
||||
|
||||
if (openingSingleItem) {
|
||||
singleItemEntrance.value = true
|
||||
} else if (height === 0 || props.items.length !== 1) {
|
||||
singleItemEntrance.value = false
|
||||
}
|
||||
|
||||
containerHeightSettled.value =
|
||||
prefersReducedMotion.value || (!initialMeasurementSettled.value && !openingSingleItem)
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.items.map((i) => i.id),
|
||||
(ids, previousIds = []) => {
|
||||
const idSet = new Set(ids)
|
||||
const previousIdSet = new Set(previousIds)
|
||||
markEntering(ids.filter((id) => !previousIdSet.has(id)))
|
||||
|
||||
for (const [id, ro] of observers) {
|
||||
if (!idSet.has(id)) {
|
||||
ro.disconnect()
|
||||
observers.delete(id)
|
||||
cardEls.delete(id)
|
||||
}
|
||||
}
|
||||
const next: Record<string, number> = {}
|
||||
for (const id of idSet) {
|
||||
if (heights.value[id] != null) next[id] = heights.value[id]
|
||||
}
|
||||
heights.value = next
|
||||
},
|
||||
)
|
||||
|
||||
let mql: MediaQueryList | null = null
|
||||
function syncRM(e: MediaQueryListEvent | MediaQueryList) {
|
||||
prefersReducedMotion.value = 'matches' in e ? e.matches : false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return
|
||||
mql = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
prefersReducedMotion.value = mql.matches
|
||||
mql.addEventListener('change', syncRM)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
mql?.removeEventListener('change', syncRM)
|
||||
for (const ro of observers.values()) ro.disconnect()
|
||||
actionBarObserver?.disconnect()
|
||||
observers.clear()
|
||||
cardEls.clear()
|
||||
pendingHeights.clear()
|
||||
if (flushHandle != null) cancelAnimationFrame(flushHandle)
|
||||
if (initialMeasurementHandle != null) cancelAnimationFrame(initialMeasurementHandle)
|
||||
if (enteringHandle != null) cancelAnimationFrame(enteringHandle)
|
||||
})
|
||||
|
||||
const placeholderClasses: Record<StackedAdmonitionType, string> = {
|
||||
info: 'border-brand-blue bg-bg-blue',
|
||||
warning: 'border-brand-orange bg-bg-orange',
|
||||
critical: 'border-brand-red bg-bg-red',
|
||||
success: 'border-brand-green bg-bg-green',
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
alertCount: {
|
||||
id: 'ui.stacked-admonitions.alert-count',
|
||||
defaultMessage: '{count, plural, one {# alert} other {# alerts}}',
|
||||
},
|
||||
dismissAll: {
|
||||
id: 'ui.stacked-admonitions.dismiss-all',
|
||||
defaultMessage: 'Dismiss all',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AnimatePresence :initial="false">
|
||||
<Motion
|
||||
v-if="items.length > 0"
|
||||
v-bind="attrs"
|
||||
as="div"
|
||||
class="relative"
|
||||
:initial="false"
|
||||
:animate="{ height: stackShellHeight, opacity: 1, y: 0 }"
|
||||
:exit="{
|
||||
height: 0,
|
||||
opacity: 0,
|
||||
overflow: 'hidden',
|
||||
y: -4,
|
||||
transition: shellExitTransition,
|
||||
}"
|
||||
:transition="heightTransition"
|
||||
>
|
||||
<Transition
|
||||
enter-active-class="overflow-hidden transition-all duration-150 ease-out"
|
||||
enter-from-class="-translate-y-1 opacity-0 max-h-0"
|
||||
enter-to-class="translate-y-0 opacity-100 max-h-14"
|
||||
leave-active-class="overflow-hidden transition-all duration-100 ease-in"
|
||||
leave-from-class="translate-y-0 opacity-100 max-h-14"
|
||||
leave-to-class="-translate-y-1 opacity-0 max-h-0"
|
||||
>
|
||||
<div v-if="hasActionBar" :ref="(el: unknown) => setActionBarRef(el)">
|
||||
<div class="flex items-center justify-between pb-2">
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
type="button"
|
||||
:aria-expanded="isExpanded"
|
||||
:aria-controls="stackId"
|
||||
@click="toggleExpanded"
|
||||
>
|
||||
<Motion
|
||||
as="span"
|
||||
class="inline-flex"
|
||||
:animate="{ rotate: isExpanded ? 0 : -90 }"
|
||||
:transition="{ type: 'spring', stiffness: 350, damping: 30 }"
|
||||
>
|
||||
<ChevronDownIcon class="h-4 w-4" />
|
||||
</Motion>
|
||||
<slot name="header-label" :count="items.length" :expanded="isExpanded">
|
||||
{{ formatMessage(messages.alertCount, { count: items.length }) }}
|
||||
</slot>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="dismissAllEnabled" type="transparent">
|
||||
<button type="button" @click="$emit('dismiss-all')">
|
||||
<XIcon class="h-4 w-4" />
|
||||
{{ formatMessage(messages.dismissAll) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Expanded-target overflow must become visible immediately so cards added
|
||||
during the tail of the expand spring do not inherit collapse clipping. -->
|
||||
<Motion
|
||||
:id="stackId"
|
||||
as="div"
|
||||
class="relative"
|
||||
:initial="false"
|
||||
:animate="{ height: containerHeight }"
|
||||
:transition="heightTransition"
|
||||
:style="{ overflow: containerOverflow }"
|
||||
v-bind="containerMotionProps"
|
||||
@mouseenter="isHovered = true"
|
||||
@mouseleave="isHovered = false"
|
||||
@click="onContainerClick"
|
||||
>
|
||||
<AnimatePresence :initial="false">
|
||||
<Motion
|
||||
v-for="(item, index) in items"
|
||||
:key="item.id"
|
||||
as="div"
|
||||
class="absolute inset-x-0 top-0 rounded-2xl bg-bg will-change-transform"
|
||||
:initial="false"
|
||||
:animate="cardPosition(index)"
|
||||
:exit="{ opacity: 0, scale: 0.9, transition: exitTransition }"
|
||||
:transition="springTransition"
|
||||
:style="{
|
||||
zIndex: items.length - index,
|
||||
transformOrigin: 'top center',
|
||||
}"
|
||||
:aria-hidden="isSettledCollapsed && index !== 0 ? 'true' : undefined"
|
||||
@click="onCardClick"
|
||||
>
|
||||
<template v-if="index === 0">
|
||||
<div :ref="(el: unknown) => setCardRef(item.id, el)">
|
||||
<slot
|
||||
name="item"
|
||||
:item="item"
|
||||
:index="index"
|
||||
:is-front="true"
|
||||
:expanded="isExpanded"
|
||||
:dismissible="itemDismissible(item)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="relative">
|
||||
<Motion
|
||||
as="div"
|
||||
:class="[
|
||||
'absolute inset-0 rounded-2xl border border-solid',
|
||||
placeholderClasses[item.type],
|
||||
]"
|
||||
:initial="false"
|
||||
:animate="{ opacity: isExpanded ? 0 : 1 }"
|
||||
:transition="springTransition"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Motion
|
||||
as="div"
|
||||
:initial="false"
|
||||
:animate="{ height: targetCardHeight(index) }"
|
||||
:transition="springTransition"
|
||||
:style="{ overflow: isExpanded ? 'visible' : 'hidden' }"
|
||||
>
|
||||
<Motion
|
||||
as="div"
|
||||
:initial="false"
|
||||
:animate="{ opacity: contentOpacity(index) }"
|
||||
:transition="springTransition"
|
||||
>
|
||||
<div
|
||||
:ref="(el: unknown) => setCardRef(item.id, el)"
|
||||
:inert="!isExpanded ? true : undefined"
|
||||
>
|
||||
<slot
|
||||
name="item"
|
||||
:item="item"
|
||||
:index="index"
|
||||
:is-front="false"
|
||||
:expanded="isExpanded"
|
||||
:dismissible="itemDismissible(item)"
|
||||
/>
|
||||
</div>
|
||||
</Motion>
|
||||
</Motion>
|
||||
</div>
|
||||
</template>
|
||||
</Motion>
|
||||
</AnimatePresence>
|
||||
</Motion>
|
||||
</Motion>
|
||||
</AnimatePresence>
|
||||
</template>
|
||||
5
packages/ui/src/components/base/StatItem.vue
Normal file
5
packages/ui/src/components/base/StatItem.vue
Normal file
@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-1">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
195
packages/ui/src/components/base/StyledInput.vue
Normal file
195
packages/ui/src/components/base/StyledInput.vue
Normal file
@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative"
|
||||
:class="[
|
||||
wrapperClass,
|
||||
multiline ? 'flex' : 'inline-flex',
|
||||
{ 'opacity-50 cursor-not-allowed': disabled },
|
||||
!multiline && variant === 'outlined' ? 'items-stretch' : 'items-center',
|
||||
]"
|
||||
>
|
||||
<!-- Left icon (filled variant, single-line only) -->
|
||||
<component
|
||||
:is="icon"
|
||||
v-if="icon && variant === 'filled' && !multiline"
|
||||
class="absolute left-3 h-5 w-5 z-[1] pointer-events-none transition-colors"
|
||||
:class="[isFocused ? 'opacity-100 text-contrast' : 'opacity-60 text-secondary']"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<!-- Multiline textarea -->
|
||||
<textarea
|
||||
v-if="multiline"
|
||||
:id="id"
|
||||
v-bind="inputAttrs"
|
||||
ref="inputRef"
|
||||
:value="model"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:name="name"
|
||||
:autocomplete="autocomplete"
|
||||
:autocorrect="autocorrect"
|
||||
:autocapitalize="autocapitalize"
|
||||
:spellcheck="spellcheck"
|
||||
:maxlength="maxlength"
|
||||
:rows="rows"
|
||||
class="w-full touch-manipulation text-primary placeholder:text-secondary focus:text-contrast font-medium transition-[shadow,color] appearance-none shadow-none focus:ring-4 focus:ring-brand-shadow bg-surface-4 border-none rounded-xl"
|
||||
:class="[
|
||||
inputClass,
|
||||
'pl-3 pr-3 py-2 text-base',
|
||||
error ? 'outline outline-2 outline-red bg-warning-bg' : 'outline-none',
|
||||
disabled ? 'cursor-not-allowed' : '',
|
||||
resizeClass,
|
||||
]"
|
||||
@input="onInput"
|
||||
@focus="isFocused = true"
|
||||
@blur="isFocused = false"
|
||||
/>
|
||||
|
||||
<!-- Single-line input -->
|
||||
<input
|
||||
v-else
|
||||
:id="id"
|
||||
v-bind="inputAttrs"
|
||||
ref="inputRef"
|
||||
:type="type"
|
||||
:value="model"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:name="name"
|
||||
:autocomplete="autocomplete"
|
||||
:autocorrect="autocorrect"
|
||||
:autocapitalize="autocapitalize"
|
||||
:spellcheck="spellcheck"
|
||||
:inputmode="inputmode"
|
||||
:maxlength="maxlength"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
class="min-w-0 w-full touch-manipulation text-primary placeholder:text-secondary focus:text-contrast font-medium transition-[shadow,color] appearance-none shadow-none focus:ring-4 focus:ring-brand-shadow"
|
||||
:class="[
|
||||
inputClass,
|
||||
!multiline && hasRightSlot ? 'flex-1' : '',
|
||||
variant === 'filled' && icon ? 'pl-10' : 'pl-3',
|
||||
clearable && model && variant === 'filled' ? 'pr-8' : 'pr-3',
|
||||
size === 'small' ? 'h-8 py-1.5 text-sm' : 'h-9 py-2 text-base',
|
||||
error ? 'outline outline-2 outline-red bg-warning-bg' : 'outline-none',
|
||||
disabled ? 'cursor-not-allowed' : '',
|
||||
variant === 'outlined'
|
||||
? 'bg-transparent border border-solid border-button-bg rounded-l-xl border-r-0'
|
||||
: 'bg-surface-4 border-none rounded-xl',
|
||||
]"
|
||||
@input="onInput"
|
||||
@focus="isFocused = true"
|
||||
@blur="isFocused = false"
|
||||
/>
|
||||
|
||||
<!-- Clear button (right side, filled variant, single-line only) -->
|
||||
<button
|
||||
v-if="!multiline && clearable && model && !disabled && !readonly && variant === 'filled'"
|
||||
type="button"
|
||||
class="absolute right-0.5 z-[1] p-2 touch-manipulation bg-transparent border-none text-secondary hover:text-contrast transition-colors cursor-pointer select-none"
|
||||
aria-label="Clear input"
|
||||
@click="clear"
|
||||
>
|
||||
<XIcon class="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<!-- Right icon button (outlined variant, single-line only) -->
|
||||
<button
|
||||
v-if="!multiline && variant === 'outlined'"
|
||||
type="button"
|
||||
class="flex touch-manipulation items-center justify-center px-2 bg-transparent border border-solid border-button-bg rounded-r-xl text-secondary hover:text-contrast transition-colors shrink-0"
|
||||
:aria-label="clearable && model ? 'Clear input' : 'Search'"
|
||||
:tabindex="clearable && model ? undefined : -1"
|
||||
@click="clearable && model ? clear() : undefined"
|
||||
>
|
||||
<XIcon v-if="clearable && model" class="h-4 w-4" />
|
||||
<component :is="icon" v-else-if="icon" class="h-4 w-4" />
|
||||
<SearchIcon v-else class="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<!-- Custom rightside slot -->
|
||||
<slot name="right" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SearchIcon, XIcon } from '@modrinth/assets'
|
||||
import { type Component, computed, ref, useSlots } from 'vue'
|
||||
|
||||
const model = defineModel<string | number | undefined>()
|
||||
const hasRightSlot = Boolean(useSlots().right)
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
icon?: Component
|
||||
type?: 'text' | 'email' | 'password' | 'number' | 'url' | 'search' | 'date' | 'datetime-local'
|
||||
placeholder?: string
|
||||
id?: string
|
||||
name?: string
|
||||
autocomplete?: string
|
||||
autocorrect?: 'on' | 'off'
|
||||
autocapitalize?: 'none' | 'off' | 'sentences' | 'words' | 'characters'
|
||||
spellcheck?: boolean
|
||||
inputmode?: 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url'
|
||||
maxlength?: number
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
error?: boolean
|
||||
size?: 'standard' | 'small'
|
||||
variant?: 'filled' | 'outlined'
|
||||
clearable?: boolean
|
||||
multiline?: boolean
|
||||
rows?: number
|
||||
resize?: 'none' | 'vertical' | 'both'
|
||||
inputClass?: string
|
||||
wrapperClass?: string
|
||||
inputAttrs?: Record<string, string | number | boolean | undefined>
|
||||
}>(),
|
||||
{
|
||||
type: 'text',
|
||||
size: 'standard',
|
||||
variant: 'filled',
|
||||
disabled: false,
|
||||
readonly: false,
|
||||
error: false,
|
||||
clearable: false,
|
||||
multiline: false,
|
||||
rows: 3,
|
||||
resize: 'none',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
clear: []
|
||||
}>()
|
||||
|
||||
const inputRef = ref<HTMLInputElement | HTMLTextAreaElement>()
|
||||
const isFocused = ref(false)
|
||||
const resizeClass = computed(
|
||||
() => ({ none: 'resize-none', vertical: 'resize-y', both: 'resize' })[props.resize ?? 'none'],
|
||||
)
|
||||
|
||||
defineExpose({ focus: () => inputRef.value?.focus() })
|
||||
|
||||
function onInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement | HTMLTextAreaElement
|
||||
model.value =
|
||||
props.type === 'number' && !props.multiline
|
||||
? target.value === ''
|
||||
? undefined
|
||||
: Number(target.value)
|
||||
: target.value
|
||||
}
|
||||
|
||||
function clear() {
|
||||
model.value = props.type === 'number' && !props.multiline ? undefined : ''
|
||||
emit('clear')
|
||||
}
|
||||
</script>
|
||||
490
packages/ui/src/components/base/Table.vue
Normal file
490
packages/ui/src/components/base/Table.vue
Normal file
@ -0,0 +1,490 @@
|
||||
<template>
|
||||
<div class="overflow-hidden rounded-2xl border border-solid border-surface-4">
|
||||
<div
|
||||
v-if="hasHeaderSlot"
|
||||
class="border-solid border-0 border-b border-surface-4 bg-surface-3 p-4"
|
||||
>
|
||||
<slot name="header" />
|
||||
</div>
|
||||
<div class="overflow-x-auto overflow-y-hidden">
|
||||
<table
|
||||
class="w-full border-separate border-spacing-0 border-surface-4"
|
||||
:class="tableLayout === 'auto' ? 'table-auto' : 'table-fixed'"
|
||||
:style="tableMinWidth ? { minWidth: tableMinWidth } : undefined"
|
||||
>
|
||||
<colgroup>
|
||||
<col v-if="showSelection" class="w-12" />
|
||||
<col
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
:style="column.width ? { width: column.width } : undefined"
|
||||
/>
|
||||
</colgroup>
|
||||
<thead class="">
|
||||
<tr class="bg-surface-3">
|
||||
<th v-if="showSelection" class="w-12">
|
||||
<Checkbox
|
||||
:model-value="allSelected"
|
||||
:indeterminate="someSelected"
|
||||
class="shrink-0 p-4 focus-visible:!outline-none"
|
||||
@update:model-value="toggleSelectAll"
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
class="h-12 pr-2 first:pl-4 last:pr-4"
|
||||
:class="[
|
||||
`text-${column.align ?? 'left'}`,
|
||||
column.enableSorting ? 'cursor-pointer select-none' : '',
|
||||
column.headerClass,
|
||||
]"
|
||||
:style="column.width ? { width: column.width } : undefined"
|
||||
@click="column.enableSorting ? handleSort(column.key) : undefined"
|
||||
>
|
||||
<slot :name="`header-${column.key}`" :column="column">
|
||||
<span
|
||||
v-if="column.label || column.enableSorting"
|
||||
class="inline-flex min-w-0 max-w-full items-center gap-1 font-semibold"
|
||||
:class="`${sortColumn === column.key ? 'text-contrast -mr-1' : ''}`"
|
||||
>
|
||||
<span class="min-w-0 truncate">{{ column.label ?? '' }}</span>
|
||||
<template v-if="column.enableSorting">
|
||||
<ChevronUpIcon
|
||||
v-if="sortColumn === column.key && sortDirection === 'asc'"
|
||||
class="size-4 shrink-0"
|
||||
/>
|
||||
<ChevronDownIcon
|
||||
v-else-if="sortColumn === column.key && sortDirection === 'desc'"
|
||||
class="size-4 shrink-0"
|
||||
/>
|
||||
</template>
|
||||
</span>
|
||||
</slot>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<TransitionGroup
|
||||
v-if="rowTransitionName && !virtualized"
|
||||
:name="rowTransitionName"
|
||||
tag="tbody"
|
||||
>
|
||||
<tr v-if="data.length === 0" key="empty" class="bg-surface-2">
|
||||
<td :colspan="columnSpan" class="border-solid border-0 border-t border-surface-4 p-0">
|
||||
<slot name="empty-state">
|
||||
<div class="text-secondary flex h-64 items-center justify-center">
|
||||
No data available.
|
||||
</div>
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
<template v-else>
|
||||
<template
|
||||
v-for="(row, rowIndex) in renderedRows"
|
||||
:key="getRowPartRenderKey(row, getAbsoluteRowIndex(rowIndex), 'group')"
|
||||
>
|
||||
<tr
|
||||
:class="getRowClass(row, getAbsoluteRowIndex(rowIndex))"
|
||||
@click="handleRowClick(row, getAbsoluteRowIndex(rowIndex), $event)"
|
||||
>
|
||||
<td
|
||||
v-if="showSelection"
|
||||
class="w-12 border-solid border-0 border-t border-surface-4 focus:outline-none"
|
||||
>
|
||||
<Checkbox
|
||||
:model-value="isSelected(row)"
|
||||
class="shrink-0 p-4 -outline-offset-[14px] outline rounded-2xl"
|
||||
@update:model-value="
|
||||
(selectRow, event) => toggleSelection(row, selectRow, event)
|
||||
"
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
class="text-secondary h-14 overflow-hidden first:pl-4 last:pr-4 border-solid border-0 border-t border-surface-4"
|
||||
:class="[`text-${column.align ?? 'left'}`, column.cellClass]"
|
||||
>
|
||||
<slot
|
||||
:name="`cell-${column.key}`"
|
||||
:row="row"
|
||||
:value="row[column.key]"
|
||||
:column="column"
|
||||
:index="getAbsoluteRowIndex(rowIndex)"
|
||||
>
|
||||
{{ row[column.key] ?? '' }}
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-if="isRowBelowVisible(row, getAbsoluteRowIndex(rowIndex))"
|
||||
:class="getRowBelowClass(row, getAbsoluteRowIndex(rowIndex))"
|
||||
@click="handleRowClick(row, getAbsoluteRowIndex(rowIndex), $event)"
|
||||
>
|
||||
<td :colspan="columnSpan" class="p-0">
|
||||
<slot name="row-below" :row="row" :index="getAbsoluteRowIndex(rowIndex)" />
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</template>
|
||||
</TransitionGroup>
|
||||
<tbody v-else :ref="setListContainer">
|
||||
<tr v-if="data.length === 0" class="bg-surface-2">
|
||||
<td :colspan="columnSpan" class="border-solid border-0 border-t border-surface-4 p-0">
|
||||
<slot name="empty-state">
|
||||
<div class="text-secondary flex h-64 items-center justify-center">
|
||||
No data available.
|
||||
</div>
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
<template v-else>
|
||||
<tr v-if="virtualized && topSpacerHeight > 0" aria-hidden="true">
|
||||
<td
|
||||
:colspan="columnSpan"
|
||||
class="border-0 p-0"
|
||||
:style="{ height: `${topSpacerHeight}px` }"
|
||||
></td>
|
||||
</tr>
|
||||
<template
|
||||
v-for="(row, rowIndex) in renderedRows"
|
||||
:key="getRowPartRenderKey(row, getAbsoluteRowIndex(rowIndex), 'group')"
|
||||
>
|
||||
<tr
|
||||
:class="getRowClass(row, getAbsoluteRowIndex(rowIndex))"
|
||||
@click="handleRowClick(row, getAbsoluteRowIndex(rowIndex), $event)"
|
||||
>
|
||||
<td
|
||||
v-if="showSelection"
|
||||
class="w-12 border-solid border-0 border-t border-surface-4 focus:outline-none"
|
||||
>
|
||||
<Checkbox
|
||||
:model-value="isSelected(row)"
|
||||
class="shrink-0 p-4 -outline-offset-[14px] outline rounded-2xl"
|
||||
@update:model-value="
|
||||
(selectRow, event) => toggleSelection(row, selectRow, event)
|
||||
"
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
class="text-secondary h-14 overflow-hidden first:pl-4 last:pr-4 border-solid border-0 border-t border-surface-4"
|
||||
:class="[`text-${column.align ?? 'left'}`, column.cellClass]"
|
||||
>
|
||||
<slot
|
||||
:name="`cell-${column.key}`"
|
||||
:row="row"
|
||||
:value="row[column.key]"
|
||||
:column="column"
|
||||
:index="getAbsoluteRowIndex(rowIndex)"
|
||||
>
|
||||
{{ row[column.key] ?? '' }}
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-if="isRowBelowVisible(row, getAbsoluteRowIndex(rowIndex))"
|
||||
:class="getRowBelowClass(row, getAbsoluteRowIndex(rowIndex))"
|
||||
@click="handleRowClick(row, getAbsoluteRowIndex(rowIndex), $event)"
|
||||
>
|
||||
<td :colspan="columnSpan" class="p-0">
|
||||
<slot name="row-below" :row="row" :index="getAbsoluteRowIndex(rowIndex)" />
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-if="virtualized && bottomSpacerHeight > 0" aria-hidden="true">
|
||||
<td
|
||||
:colspan="columnSpan"
|
||||
class="border-0 p-0"
|
||||
:style="{ height: `${bottomSpacerHeight}px` }"
|
||||
></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script
|
||||
setup
|
||||
lang="ts"
|
||||
generic="K extends string = string, T extends Record<string, unknown> = Record<K, unknown>"
|
||||
>
|
||||
import { ChevronDownIcon, ChevronUpIcon } from '@modrinth/assets'
|
||||
import { computed, ref, toRef, useSlots } from 'vue'
|
||||
|
||||
import { useVirtualScroll } from '../../composables/virtual-scroll'
|
||||
import Checkbox from './Checkbox.vue'
|
||||
|
||||
export type TableColumnAlign = 'left' | 'center' | 'right'
|
||||
export type SortDirection = 'asc' | 'desc'
|
||||
export type TableLayout = 'fixed' | 'auto'
|
||||
|
||||
/**
|
||||
* Defines a table column configuration.
|
||||
* @template K - The column key is used to get cell data of row
|
||||
*/
|
||||
export interface TableColumn<K extends string = string> {
|
||||
key: K
|
||||
label?: string
|
||||
align?: TableColumnAlign
|
||||
enableSorting?: boolean
|
||||
defaultSortDirection?: SortDirection
|
||||
/**
|
||||
* CSS width value for the column.
|
||||
* Accepts any valid CSS width (e.g., '200px', '20%', '10rem', 'auto', 'fit-content').
|
||||
*/
|
||||
width?: string
|
||||
headerClass?: string
|
||||
cellClass?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
columns: TableColumn<K>[]
|
||||
data: T[] /* Row data for table */
|
||||
showSelection?: boolean
|
||||
rowKey?: keyof T /* The key used to uniquely identify each row */
|
||||
selectionKey?: keyof T /* The key used to identify selectable rows */
|
||||
selectionData?: T[] /* The complete selectable data set when data is paginated */
|
||||
selectionIds?: unknown[] /* Complete selectable IDs when callers do not want to retain row objects */
|
||||
virtualized?: boolean
|
||||
virtualRowHeight?: number
|
||||
virtualBufferSize?: number /* The number of extra rows rendered above and below the visible viewport */
|
||||
rowTransitionName?: string
|
||||
/**
|
||||
* Sets a minimum width for the table content, allowing horizontal overflow below that width.
|
||||
*/
|
||||
tableMinWidth?: string
|
||||
tableLayout?: TableLayout
|
||||
rowBelowVisible?: boolean | ((row: T, index: number) => boolean)
|
||||
rowClass?: string | ((row: T, index: number) => string)
|
||||
rowClickable?: boolean | ((row: T, index: number) => boolean)
|
||||
}>(),
|
||||
{
|
||||
showSelection: false,
|
||||
rowKey: 'id' as keyof T,
|
||||
tableLayout: 'fixed',
|
||||
virtualized: false,
|
||||
virtualRowHeight: 56,
|
||||
virtualBufferSize: 5,
|
||||
},
|
||||
)
|
||||
|
||||
const selectedIds = defineModel<unknown[]>('selectedIds', { default: () => [] })
|
||||
const sortColumn = defineModel<string | undefined>('sortColumn')
|
||||
const sortDirection = defineModel<SortDirection>('sortDirection', { default: 'asc' })
|
||||
const slots = useSlots()
|
||||
const selectionAnchorId = ref<unknown>()
|
||||
const hasHeaderSlot = computed(() => Boolean(slots.header))
|
||||
const hasRowBelowSlot = computed(() => Boolean(slots['row-below']))
|
||||
const columnSpan = computed(() => Math.max(props.columns.length + (props.showSelection ? 1 : 0), 1))
|
||||
|
||||
const {
|
||||
listContainer,
|
||||
totalHeight,
|
||||
visibleRange,
|
||||
visibleTop: topSpacerHeight,
|
||||
visibleItems,
|
||||
} = useVirtualScroll(toRef(props, 'data'), {
|
||||
itemHeight: props.virtualRowHeight,
|
||||
bufferSize: props.virtualBufferSize,
|
||||
enabled: toRef(props, 'virtualized'),
|
||||
})
|
||||
|
||||
const renderedRows = computed(() => (props.virtualized ? visibleItems.value : props.data))
|
||||
const bottomSpacerHeight = computed(() => {
|
||||
if (!props.virtualized) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return Math.max(
|
||||
0,
|
||||
totalHeight.value - topSpacerHeight.value - renderedRows.value.length * props.virtualRowHeight,
|
||||
)
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
sort: [column: string, direction: SortDirection]
|
||||
rowClick: [row: T, index: number, event: MouseEvent]
|
||||
}>()
|
||||
|
||||
const selectableRows = computed(() => props.selectionData ?? props.data)
|
||||
const selectableRowIds = computed(
|
||||
() => props.selectionIds ?? selectableRows.value.map((row) => getSelectionId(row)),
|
||||
)
|
||||
const selectedIdSet = computed(() => new Set(selectedIds.value))
|
||||
const selectedSelectableIdCount = computed(() => {
|
||||
let count = 0
|
||||
for (const id of selectableRowIds.value) {
|
||||
if (selectedIdSet.value.has(id)) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
})
|
||||
const allSelected = computed(
|
||||
() =>
|
||||
selectableRowIds.value.length > 0 &&
|
||||
selectedSelectableIdCount.value === selectableRowIds.value.length,
|
||||
)
|
||||
const someSelected = computed(
|
||||
() =>
|
||||
selectedSelectableIdCount.value > 0 &&
|
||||
selectedSelectableIdCount.value < selectableRowIds.value.length,
|
||||
)
|
||||
|
||||
function getRowId(row: T): unknown {
|
||||
return row[props.rowKey as keyof T]
|
||||
}
|
||||
|
||||
function getSelectionId(row: T): unknown {
|
||||
return row[(props.selectionKey ?? props.rowKey) as keyof T]
|
||||
}
|
||||
|
||||
function setListContainer(element: unknown) {
|
||||
listContainer.value = props.virtualized ? (element as HTMLElement | null) : null
|
||||
}
|
||||
|
||||
function getAbsoluteRowIndex(rowIndex: number): number {
|
||||
return props.virtualized ? visibleRange.value.start + rowIndex : rowIndex
|
||||
}
|
||||
|
||||
function getRowRenderKey(row: T, rowIndex: number): PropertyKey {
|
||||
const rowId = getRowId(row)
|
||||
if (typeof rowId === 'string' || typeof rowId === 'number' || typeof rowId === 'symbol') {
|
||||
return rowId
|
||||
}
|
||||
|
||||
return rowIndex
|
||||
}
|
||||
|
||||
function getRowPartRenderKey(row: T, rowIndex: number, part: 'group' | 'row' | 'below'): string {
|
||||
return `${String(getRowRenderKey(row, rowIndex))}-${part}`
|
||||
}
|
||||
|
||||
function isRowBelowVisible(row: T, rowIndex: number): boolean {
|
||||
if (!hasRowBelowSlot.value || props.virtualized) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof props.rowBelowVisible === 'function') {
|
||||
return props.rowBelowVisible(row, rowIndex)
|
||||
}
|
||||
|
||||
return props.rowBelowVisible ?? true
|
||||
}
|
||||
|
||||
function getRowClass(row: T, rowIndex: number): string[] {
|
||||
const baseClass = rowIndex % 2 === 0 ? 'bg-surface-2' : 'bg-surface-1.5'
|
||||
const customClass =
|
||||
typeof props.rowClass === 'function' ? props.rowClass(row, rowIndex) : props.rowClass
|
||||
|
||||
return customClass ? [baseClass, customClass] : [baseClass]
|
||||
}
|
||||
|
||||
function getRowBelowClass(row: T, rowIndex: number): string[] {
|
||||
const classes = [
|
||||
rowIndex % 2 === 0 ? 'bg-surface-2' : 'bg-surface-1.5',
|
||||
'table-row-below',
|
||||
'transition-[filter]',
|
||||
]
|
||||
|
||||
if (isRowClickable(row, rowIndex)) {
|
||||
classes.push('cursor-pointer')
|
||||
}
|
||||
|
||||
return classes
|
||||
}
|
||||
|
||||
function isRowClickable(row: T, rowIndex: number): boolean {
|
||||
return typeof props.rowClickable === 'function'
|
||||
? props.rowClickable(row, rowIndex)
|
||||
: props.rowClickable === true
|
||||
}
|
||||
|
||||
function isNoRowClickTarget(event: MouseEvent): boolean {
|
||||
const target = event.target
|
||||
const currentTarget = event.currentTarget
|
||||
if (!(target instanceof Element) || !(currentTarget instanceof Element)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const noRowClickTarget = target.closest('[data-no-row-click]')
|
||||
return noRowClickTarget !== null && noRowClickTarget !== currentTarget
|
||||
}
|
||||
|
||||
function handleRowClick(row: T, rowIndex: number, event: MouseEvent) {
|
||||
if (!isRowClickable(row, rowIndex) || isNoRowClickTarget(event)) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('rowClick', row, rowIndex, event)
|
||||
}
|
||||
|
||||
function isSelected(row: T): boolean {
|
||||
return selectedIdSet.value.has(getSelectionId(row))
|
||||
}
|
||||
|
||||
function toggleSelection(row: T, selectRow: boolean, event?: MouseEvent) {
|
||||
const id = getSelectionId(row)
|
||||
const rowIndex = selectableRowIds.value.findIndex((selectableId) => selectableId === id)
|
||||
const anchorIndex = selectableRowIds.value.findIndex(
|
||||
(selectableId) => selectableId === selectionAnchorId.value,
|
||||
)
|
||||
|
||||
if (event?.shiftKey && rowIndex !== -1 && anchorIndex !== -1) {
|
||||
const startIndex = Math.min(rowIndex, anchorIndex)
|
||||
const endIndex = Math.max(rowIndex, anchorIndex)
|
||||
const rangeIds = selectableRowIds.value.slice(startIndex, endIndex + 1)
|
||||
|
||||
if (selectRow) {
|
||||
const nextSelectedIds = [...selectedIds.value]
|
||||
const nextSelectedIdSet = new Set(nextSelectedIds)
|
||||
for (const rangeId of rangeIds) {
|
||||
if (!nextSelectedIdSet.has(rangeId)) {
|
||||
nextSelectedIds.push(rangeId)
|
||||
nextSelectedIdSet.add(rangeId)
|
||||
}
|
||||
}
|
||||
selectedIds.value = nextSelectedIds
|
||||
} else {
|
||||
const rangeIdSet = new Set(rangeIds)
|
||||
selectedIds.value = selectedIds.value.filter((selectedId) => !rangeIdSet.has(selectedId))
|
||||
}
|
||||
} else {
|
||||
selectedIds.value = selectRow
|
||||
? [...selectedIds.value, id]
|
||||
: selectedIds.value.filter((selectedId) => selectedId !== id)
|
||||
}
|
||||
|
||||
selectionAnchorId.value = id
|
||||
}
|
||||
|
||||
function toggleSelectAll(selectAll: boolean) {
|
||||
selectionAnchorId.value = undefined
|
||||
if (selectAll) {
|
||||
selectedIds.value = [...selectableRowIds.value]
|
||||
} else {
|
||||
selectedIds.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function handleSort(columnKey: string) {
|
||||
const column = props.columns.find((column) => column.key === columnKey)
|
||||
const defaultDirection = column?.defaultSortDirection ?? 'asc'
|
||||
const newDirection: SortDirection =
|
||||
sortColumn.value === columnKey && sortDirection.value === defaultDirection
|
||||
? getOppositeSortDirection(defaultDirection)
|
||||
: defaultDirection
|
||||
sortColumn.value = columnKey
|
||||
sortDirection.value = newDirection
|
||||
emit('sort', columnKey, newDirection)
|
||||
}
|
||||
|
||||
function getOppositeSortDirection(direction: SortDirection): SortDirection {
|
||||
return direction === 'asc' ? 'desc' : 'asc'
|
||||
}
|
||||
</script>
|
||||
97
packages/ui/src/components/base/Tabs.vue
Normal file
97
packages/ui/src/components/base/Tabs.vue
Normal file
@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="tabs.length > 0"
|
||||
class="inline-flex w-fit items-center overflow-x-auto rounded-xl border border-solid border-surface-5 p-0.5 shadow-sm gap-1 h-[38px]"
|
||||
role="tablist"
|
||||
>
|
||||
<button
|
||||
v-for="(tab, index) in tabs"
|
||||
:key="tab.value"
|
||||
ref="tabButtons"
|
||||
type="button"
|
||||
class="flex min-h-6 shrink-0 cursor-pointer items-center justify-center gap-2 rounded-[10px] border border-solid px-2.5 h-full text-sm font-medium outline-none transition-all active:scale-[0.97] focus-visible:ring-4 focus-visible:ring-brand-shadow"
|
||||
:class="
|
||||
tab.value === value
|
||||
? 'border-brand bg-brand-highlight text-brand'
|
||||
: 'border-transparent bg-transparent text-primary hover:bg-surface-4'
|
||||
"
|
||||
role="tab"
|
||||
:aria-selected="tab.value === value"
|
||||
:tabindex="tab.value === value || (!hasSelectedTab && index === 0) ? 0 : -1"
|
||||
@click="selectTab(tab)"
|
||||
@keydown="onTabKeydown($event, index)"
|
||||
>
|
||||
<component
|
||||
:is="tab.icon"
|
||||
v-if="tab.icon"
|
||||
class="size-5 shrink-0"
|
||||
:class="tab.value === value ? 'text-brand' : 'text-secondary'"
|
||||
/>
|
||||
<span v-if="tab.label" class="text-nowrap">{{ tab.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
export type TabsValue = string | number
|
||||
|
||||
export interface TabsTab {
|
||||
value: TabsValue
|
||||
label: string
|
||||
icon?: Component
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
value: TabsValue
|
||||
tabs: TabsTab[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:value': [value: TabsValue]
|
||||
change: [tab: TabsTab]
|
||||
}>()
|
||||
|
||||
const tabButtons = ref<HTMLButtonElement[]>()
|
||||
|
||||
const hasSelectedTab = computed(() => props.tabs.some((tab) => tab.value === props.value))
|
||||
|
||||
function selectTab(tab: TabsTab) {
|
||||
emit('update:value', tab.value)
|
||||
emit('change', tab)
|
||||
}
|
||||
|
||||
function selectTabAtIndex(index: number) {
|
||||
const tab = props.tabs[index]
|
||||
if (!tab) return
|
||||
|
||||
selectTab(tab)
|
||||
requestAnimationFrame(() => {
|
||||
tabButtons.value?.[index]?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
function onTabKeydown(event: KeyboardEvent, index: number) {
|
||||
if (props.tabs.length === 0) return
|
||||
|
||||
const lastIndex = props.tabs.length - 1
|
||||
let nextIndex: number | undefined
|
||||
|
||||
if (event.key === 'ArrowRight') {
|
||||
nextIndex = index === lastIndex ? 0 : index + 1
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
nextIndex = index === 0 ? lastIndex : index - 1
|
||||
} else if (event.key === 'Home') {
|
||||
nextIndex = 0
|
||||
} else if (event.key === 'End') {
|
||||
nextIndex = lastIndex
|
||||
}
|
||||
|
||||
if (nextIndex === undefined) return
|
||||
|
||||
event.preventDefault()
|
||||
selectTabAtIndex(nextIndex)
|
||||
}
|
||||
</script>
|
||||
21
packages/ui/src/components/base/TagIcon.vue
Normal file
21
packages/ui/src/components/base/TagIcon.vue
Normal file
@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { getCategoryIcon, getLoaderIcon, getTagIcon } from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
tag: string
|
||||
enforceType?: 'loader' | 'category'
|
||||
}>()
|
||||
|
||||
const icon = computed(() =>
|
||||
props.enforceType === 'loader'
|
||||
? getLoaderIcon(props.tag)
|
||||
: props.enforceType === 'category'
|
||||
? getCategoryIcon(props.tag)
|
||||
: getTagIcon(props.tag),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="icon" v-if="icon" />
|
||||
</template>
|
||||
20
packages/ui/src/components/base/TagItem.vue
Normal file
20
packages/ui/src/components/base/TagItem.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<button
|
||||
v-if="action"
|
||||
:class="[baseClass, 'transition-transform active:scale-[0.95] cursor-pointer hover:underline']"
|
||||
@click="action"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
<div v-else :class="baseClass">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
action?: (event: MouseEvent) => void
|
||||
}>()
|
||||
|
||||
const baseClass =
|
||||
'bg-[--_bg-color,var(--color-button-bg)] text-nowrap border-[--_bg-color,var(--surface-5)] border-[1px] border-solid px-2 py-1 leading-none rounded-full font-normal text-sm inline-flex items-center gap-1 text-[--_color,var(--color-secondary)] [&>svg]:shrink-0 [&>svg]:h-4 [&>svg]:w-4'
|
||||
</script>
|
||||
31
packages/ui/src/components/base/TagTagItem.vue
Normal file
31
packages/ui/src/components/base/TagTagItem.vue
Normal file
@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<TagItem :action="action" :style="isLoader ? `--_color: var(--color-platform-${tag})` : ''">
|
||||
<component :is="icon" v-if="icon" />
|
||||
<FormattedTag :tag="tag" />
|
||||
</TagItem>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { getTagIcon } from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { getTagMessage } from '../../utils'
|
||||
import FormattedTag from './FormattedTag.vue'
|
||||
import TagItem from './TagItem.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
tag: string
|
||||
hideNonLoaderIcon?: boolean
|
||||
action?: (event: MouseEvent) => void
|
||||
}>(),
|
||||
{
|
||||
hideNonLoaderIcon: false,
|
||||
action: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const icon = computed(() =>
|
||||
props.hideNonLoaderIcon && !isLoader.value ? undefined : getTagIcon(props.tag),
|
||||
)
|
||||
const isLoader = computed(() => getTagMessage(props.tag, 'loader') !== undefined)
|
||||
</script>
|
||||
436
packages/ui/src/components/base/TeleportOverflowMenu.vue
Normal file
436
packages/ui/src/components/base/TeleportOverflowMenu.vue
Normal file
@ -0,0 +1,436 @@
|
||||
<template>
|
||||
<div data-pyro-telepopover-wrapper class="relative">
|
||||
<button
|
||||
ref="triggerRef"
|
||||
class="teleport-overflow-menu-trigger"
|
||||
:class="btnClass"
|
||||
:aria-expanded="isOpen"
|
||||
:aria-haspopup="true"
|
||||
@mousedown="handleMouseDown"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@click="toggleMenu"
|
||||
>
|
||||
<slot></slot>
|
||||
</button>
|
||||
<Teleport to="#teleports">
|
||||
<Transition
|
||||
enter-active-class="transition duration-125 ease-out"
|
||||
enter-from-class="transform scale-75 opacity-0"
|
||||
enter-to-class="transform scale-100 opacity-100"
|
||||
leave-active-class="transition duration-125 ease-in"
|
||||
leave-from-class="transform scale-100 opacity-100"
|
||||
leave-to-class="transform scale-75 opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
ref="menuRef"
|
||||
data-pyro-telepopover-root
|
||||
class="fixed isolate z-[9999] flex w-fit flex-col gap-2 overflow-hidden rounded-2xl border-[1px] border-solid border-surface-5 bg-bg-raised p-2 shadow-lg"
|
||||
:style="menuStyle"
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
@mousedown.stop
|
||||
@mouseleave="handleMouseLeave"
|
||||
>
|
||||
<template
|
||||
v-for="(option, index) in filteredOptions"
|
||||
:key="isDivider(option) ? `divider-${index}` : option.id"
|
||||
>
|
||||
<div v-if="isDivider(option)" class="h-px w-full bg-surface-5"></div>
|
||||
<ButtonStyled v-else type="transparent" role="menuitem" :color="option.color">
|
||||
<button
|
||||
v-if="typeof option.action === 'function'"
|
||||
:ref="
|
||||
(el) => {
|
||||
if (el) menuItemsRef[index] = el as HTMLElement
|
||||
}
|
||||
"
|
||||
v-tooltip="option.tooltip"
|
||||
:disabled="option.disabled"
|
||||
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
|
||||
:aria-selected="index === selectedIndex"
|
||||
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
|
||||
@click="handleItemClick(option, index)"
|
||||
@focus="selectedIndex = index"
|
||||
@mouseover="handleMouseOver(index)"
|
||||
>
|
||||
<slot :name="option.id">
|
||||
<component :is="option.icon" v-if="option.icon" class="size-5" />
|
||||
{{ option.label ?? option.id }}
|
||||
</slot>
|
||||
</button>
|
||||
<AutoLink
|
||||
v-else-if="typeof option.action === 'string'"
|
||||
:ref="
|
||||
(el) => {
|
||||
if (el) menuItemsRef[index] = el as HTMLElement
|
||||
}
|
||||
"
|
||||
:to="option.action"
|
||||
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
|
||||
:aria-selected="index === selectedIndex"
|
||||
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
|
||||
@click="handleItemClick(option, index)"
|
||||
@focus="selectedIndex = index"
|
||||
@mouseover="handleMouseOver(index)"
|
||||
>
|
||||
<slot :name="option.id">
|
||||
<component :is="option.icon" v-if="option.icon" class="size-5" />
|
||||
{{ option.label ?? option.id }}
|
||||
</slot>
|
||||
</AutoLink>
|
||||
<span v-else>
|
||||
<slot :name="option.id">
|
||||
<component :is="option.icon" v-if="option.icon" class="size-5" />
|
||||
{{ option.label ?? option.id }}
|
||||
</slot>
|
||||
</span>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { AutoLink, ButtonStyled } from '@modrinth/ui'
|
||||
import { onClickOutside, useElementHover } from '@vueuse/core'
|
||||
import { type Component, computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
interface Option {
|
||||
id: string
|
||||
label?: string
|
||||
icon?: Component
|
||||
action?: (() => void) | string
|
||||
shown?: boolean
|
||||
color?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple' | 'medal-promo'
|
||||
disabled?: boolean
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
type Divider = {
|
||||
divider?: boolean
|
||||
shown?: boolean
|
||||
}
|
||||
|
||||
type Item = Option | Divider
|
||||
|
||||
function isDivider(item: Item): item is Divider {
|
||||
return (item as Divider).divider
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
options: Item[]
|
||||
hoverable?: boolean
|
||||
btnClass?: string | string[] | Record<string, boolean>
|
||||
}>(),
|
||||
{
|
||||
hoverable: false,
|
||||
btnClass: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [option: Option]
|
||||
open: []
|
||||
}>()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const selectedIndex = ref(-1)
|
||||
const menuRef = ref<HTMLElement | null>(null)
|
||||
const triggerRef = ref<HTMLElement | null>(null)
|
||||
const isMouseDown = ref(false)
|
||||
const typeAheadBuffer = ref('')
|
||||
const typeAheadTimeout = ref<number | null>(null)
|
||||
const menuItemsRef = ref<HTMLElement[]>([])
|
||||
|
||||
const hoveringTrigger = useElementHover(triggerRef)
|
||||
const hoveringMenu = useElementHover(menuRef)
|
||||
|
||||
const hovering = computed(() => hoveringTrigger.value || hoveringMenu.value)
|
||||
|
||||
const menuStyle = ref({
|
||||
top: '0px',
|
||||
left: '0px',
|
||||
})
|
||||
|
||||
const filteredOptions = computed(() => props.options.filter((option) => option.shown !== false))
|
||||
|
||||
const calculateMenuPosition = () => {
|
||||
if (!triggerRef.value || !menuRef.value) return { top: '0px', left: '0px' }
|
||||
|
||||
const triggerRect = triggerRef.value.getBoundingClientRect()
|
||||
const menuWidth = menuRef.value.offsetWidth
|
||||
const menuHeight = menuRef.value.offsetHeight
|
||||
const margin = 8
|
||||
|
||||
let top: number
|
||||
let left: number
|
||||
|
||||
if (triggerRect.bottom + menuHeight + margin <= window.innerHeight) {
|
||||
top = triggerRect.bottom + margin
|
||||
} else if (triggerRect.top - menuHeight - margin >= 0) {
|
||||
top = triggerRect.top - menuHeight - margin
|
||||
} else {
|
||||
top = Math.max(margin, window.innerHeight - menuHeight - margin)
|
||||
}
|
||||
|
||||
if (triggerRect.right - menuWidth >= margin) {
|
||||
left = triggerRect.right - menuWidth
|
||||
} else {
|
||||
left = Math.max(margin, triggerRect.left)
|
||||
}
|
||||
|
||||
return {
|
||||
top: `${top}px`,
|
||||
left: `${left}px`,
|
||||
}
|
||||
}
|
||||
|
||||
const toggleMenu = (event: MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
if (!props.hoverable) {
|
||||
if (isOpen.value) {
|
||||
closeMenu()
|
||||
} else {
|
||||
openMenu()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const openMenu = () => {
|
||||
isOpen.value = true
|
||||
emit('open')
|
||||
disableBodyScroll()
|
||||
nextTick(() => {
|
||||
menuStyle.value = calculateMenuPosition()
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
focusFirstMenuItem()
|
||||
})
|
||||
}
|
||||
|
||||
const closeMenu = () => {
|
||||
isOpen.value = false
|
||||
selectedIndex.value = -1
|
||||
enableBodyScroll()
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
}
|
||||
|
||||
const selectOption = (option: Option) => {
|
||||
emit('select', option)
|
||||
if (typeof option.action === 'function') {
|
||||
option.action()
|
||||
}
|
||||
closeMenu()
|
||||
}
|
||||
|
||||
const handleMouseDown = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
isMouseDown.value = true
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (props.hoverable) {
|
||||
openMenu()
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (props.hoverable) {
|
||||
setTimeout(() => {
|
||||
if (!hovering.value) {
|
||||
closeMenu()
|
||||
}
|
||||
}, 250)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
if (!isOpen.value || !isMouseDown.value) return
|
||||
|
||||
const menuRect = menuRef.value?.getBoundingClientRect()
|
||||
if (!menuRect) return
|
||||
|
||||
const menuItems = menuRef.value?.querySelectorAll('[role="menuitem"]')
|
||||
if (!menuItems) return
|
||||
|
||||
for (let i = 0; i < menuItems.length; i++) {
|
||||
const itemRect = (menuItems[i] as HTMLElement).getBoundingClientRect()
|
||||
if (
|
||||
event.clientX >= itemRect.left &&
|
||||
event.clientX <= itemRect.right &&
|
||||
event.clientY >= itemRect.top &&
|
||||
event.clientY <= itemRect.bottom
|
||||
) {
|
||||
selectedIndex.value = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleItemClick = (option: Option, index: number) => {
|
||||
if (option.disabled) return
|
||||
selectedIndex.value = index
|
||||
selectOption(option)
|
||||
}
|
||||
|
||||
const handleMouseOver = (index: number) => {
|
||||
selectedIndex.value = index
|
||||
menuItemsRef.value[selectedIndex.value]?.focus?.()
|
||||
}
|
||||
|
||||
const disableBodyScroll = () => {
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
|
||||
const enableBodyScroll = () => {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
|
||||
const focusFirstMenuItem = () => {
|
||||
if (menuItemsRef.value.length > 0) {
|
||||
menuItemsRef.value[0]?.focus?.()
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (!isOpen.value) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
openMenu()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault()
|
||||
selectedIndex.value = (selectedIndex.value + 1) % filteredOptions.value.length
|
||||
menuItemsRef.value[selectedIndex.value]?.focus?.()
|
||||
break
|
||||
case 'ArrowUp':
|
||||
event.preventDefault()
|
||||
selectedIndex.value =
|
||||
(selectedIndex.value - 1 + filteredOptions.value.length) % filteredOptions.value.length
|
||||
menuItemsRef.value[selectedIndex.value]?.focus?.()
|
||||
break
|
||||
case 'Home':
|
||||
event.preventDefault()
|
||||
if (menuItemsRef.value.length > 0) {
|
||||
selectedIndex.value = 0
|
||||
menuItemsRef.value[selectedIndex.value]?.focus?.()
|
||||
}
|
||||
break
|
||||
case 'End':
|
||||
event.preventDefault()
|
||||
if (menuItemsRef.value.length > 0) {
|
||||
selectedIndex.value = filteredOptions.value.length - 1
|
||||
menuItemsRef.value[selectedIndex.value]?.focus?.()
|
||||
}
|
||||
break
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
event.preventDefault()
|
||||
if (selectedIndex.value >= 0) {
|
||||
const option = filteredOptions.value[selectedIndex.value]
|
||||
if (isDivider(option)) break
|
||||
selectOption(option)
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
event.preventDefault()
|
||||
closeMenu()
|
||||
triggerRef.value?.focus?.()
|
||||
break
|
||||
case 'Tab':
|
||||
event.preventDefault()
|
||||
if (menuItemsRef.value.length > 0) {
|
||||
if (event.shiftKey) {
|
||||
selectedIndex.value =
|
||||
(selectedIndex.value - 1 + filteredOptions.value.length) % filteredOptions.value.length
|
||||
} else {
|
||||
selectedIndex.value = (selectedIndex.value + 1) % filteredOptions.value.length
|
||||
}
|
||||
menuItemsRef.value[selectedIndex.value]?.focus?.()
|
||||
}
|
||||
break
|
||||
default:
|
||||
if (event.key.length === 1) {
|
||||
typeAheadBuffer.value += event.key.toLowerCase()
|
||||
const matchIndex = filteredOptions.value.findIndex(
|
||||
(option) =>
|
||||
!isDivider(option) && option.id.toLowerCase().startsWith(typeAheadBuffer.value),
|
||||
)
|
||||
if (matchIndex !== -1) {
|
||||
selectedIndex.value = matchIndex
|
||||
menuItemsRef.value[selectedIndex.value]?.focus?.()
|
||||
}
|
||||
if (typeAheadTimeout.value) {
|
||||
clearTimeout(typeAheadTimeout.value)
|
||||
}
|
||||
typeAheadTimeout.value = setTimeout(() => {
|
||||
typeAheadBuffer.value = ''
|
||||
}, 1000) as unknown as number
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const handleResizeOrScroll = () => {
|
||||
if (isOpen.value) {
|
||||
menuStyle.value = calculateMenuPosition()
|
||||
}
|
||||
}
|
||||
|
||||
const throttle = <T extends unknown[]>(
|
||||
func: (...args: T) => void,
|
||||
limit: number,
|
||||
): ((...args: T) => void) => {
|
||||
let inThrottle: boolean
|
||||
return function (...args: T) {
|
||||
if (!inThrottle) {
|
||||
func(...args)
|
||||
inThrottle = true
|
||||
setTimeout(() => (inThrottle = false), limit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const throttledHandleResizeOrScroll = throttle(handleResizeOrScroll, 100)
|
||||
|
||||
onMounted(() => {
|
||||
triggerRef.value?.addEventListener('keydown', handleKeydown)
|
||||
window.addEventListener('resize', throttledHandleResizeOrScroll)
|
||||
window.addEventListener('scroll', throttledHandleResizeOrScroll)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
triggerRef.value?.removeEventListener('keydown', handleKeydown)
|
||||
window.removeEventListener('resize', throttledHandleResizeOrScroll)
|
||||
window.removeEventListener('scroll', throttledHandleResizeOrScroll)
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
if (typeAheadTimeout.value) {
|
||||
clearTimeout(typeAheadTimeout.value)
|
||||
}
|
||||
enableBodyScroll()
|
||||
})
|
||||
|
||||
watch(isOpen, (newValue) => {
|
||||
if (newValue) {
|
||||
nextTick(() => {
|
||||
menuRef.value?.addEventListener('keydown', handleKeydown)
|
||||
})
|
||||
} else {
|
||||
menuRef.value?.removeEventListener('keydown', handleKeydown)
|
||||
}
|
||||
})
|
||||
|
||||
onClickOutside(menuRef, (event) => {
|
||||
if (!triggerRef.value?.contains(event.target as Node)) {
|
||||
closeMenu()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
1090
packages/ui/src/components/base/TimeFramePicker.vue
Normal file
1090
packages/ui/src/components/base/TimeFramePicker.vue
Normal file
File diff suppressed because it is too large
Load Diff
57
packages/ui/src/components/base/Timeline.vue
Normal file
57
packages/ui/src/components/base/Timeline.vue
Normal file
@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
fadeOutStart?: boolean
|
||||
fadeOutEnd?: boolean
|
||||
}>(),
|
||||
{
|
||||
fadeOutStart: false,
|
||||
fadeOutEnd: false,
|
||||
},
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<div class="relative flex flex-col gap-4 pb-6 isolate">
|
||||
<div class="absolute flex h-full w-4 justify-center">
|
||||
<div
|
||||
class="timeline-indicator"
|
||||
:class="{ 'fade-out-start': fadeOutStart, 'fade-out-end': fadeOutEnd }"
|
||||
/>
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
.timeline-indicator {
|
||||
background-image: linear-gradient(
|
||||
to bottom,
|
||||
var(--timeline-line-color, var(--color-raised-bg)) 66%,
|
||||
rgba(255, 255, 255, 0) 0%
|
||||
);
|
||||
background-size: 100% 30px;
|
||||
background-repeat: repeat-y;
|
||||
margin-top: 1rem;
|
||||
|
||||
height: calc(100% - 1rem);
|
||||
width: 4px;
|
||||
z-index: -1;
|
||||
|
||||
&.fade-out-start {
|
||||
mask-image: linear-gradient(to top, black calc(100% - 15rem), transparent 100%);
|
||||
}
|
||||
|
||||
&.fade-out-end {
|
||||
mask-image: linear-gradient(to bottom, black calc(100% - 15rem), transparent 100%);
|
||||
}
|
||||
|
||||
&.fade-out-start.fade-out-end {
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
black,
|
||||
black calc(100% - 8rem),
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
49
packages/ui/src/components/base/Toggle.vue
Normal file
49
packages/ui/src/components/base/Toggle.vue
Normal file
@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<button
|
||||
:id="id"
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="modelValue"
|
||||
:disabled="disabled"
|
||||
class="group inline-flex shrink-0 touch-manipulation items-center rounded-full m-0 p-1 transition-all duration-200 cursor-pointer border-none"
|
||||
:class="[
|
||||
small ? 'h-5 !w-[40px]' : 'h-6 !w-[48px]',
|
||||
modelValue ? 'bg-brand' : 'bg-button-bg',
|
||||
disabled ? 'opacity-50 cursor-not-allowed' : '',
|
||||
]"
|
||||
@click="toggle"
|
||||
>
|
||||
<span
|
||||
class="rounded-full transition-all duration-200"
|
||||
:class="[
|
||||
small ? 'w-3 h-3' : 'w-4 h-4',
|
||||
modelValue
|
||||
? small
|
||||
? 'translate-x-[20px] bg-black/90'
|
||||
: 'translate-x-[24px] bg-black/90'
|
||||
: 'bg-gray',
|
||||
disabled
|
||||
? ''
|
||||
: small
|
||||
? 'group-hover:w-[14px] group-hover:h-[14px] group-hover:m-[-1px] group-active:w-[10px] group-active:h-[10px] group-active:m-[1px]'
|
||||
: 'group-hover:w-[18px] group-hover:h-[18px] group-hover:m-[-1px] group-active:w-[14px] group-active:h-[14px] group-active:m-[1px]',
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
id?: string
|
||||
disabled?: boolean
|
||||
small?: boolean
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<boolean>()
|
||||
|
||||
function toggle() {
|
||||
if (!props.disabled) {
|
||||
modelValue.value = !modelValue.value
|
||||
}
|
||||
}
|
||||
</script>
|
||||
69
packages/ui/src/components/base/UnsavedChangesPopup.vue
Normal file
69
packages/ui/src/components/base/UnsavedChangesPopup.vue
Normal file
@ -0,0 +1,69 @@
|
||||
<script setup lang="ts" generic="T">
|
||||
import { HistoryIcon, SaveIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import { isEqual } from 'es-toolkit'
|
||||
import { type Component, computed } from 'vue'
|
||||
|
||||
import { defineMessage, type MessageDescriptor, useVIntl } from '../../composables/i18n'
|
||||
import { commonMessages } from '../../utils'
|
||||
import ButtonStyled from './ButtonStyled.vue'
|
||||
import FloatingActionBar from './FloatingActionBar.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'reset' | 'save', event: MouseEvent): void
|
||||
}>()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
canReset?: boolean
|
||||
original: T
|
||||
modified: Partial<T>
|
||||
saving?: boolean
|
||||
text?: MessageDescriptor | string
|
||||
saveLabel?: MessageDescriptor | string
|
||||
savingLabel?: MessageDescriptor | string
|
||||
saveIcon?: Component
|
||||
}>(),
|
||||
{
|
||||
canReset: true,
|
||||
saving: false,
|
||||
text: () =>
|
||||
defineMessage({
|
||||
id: 'ui.component.unsaved-changes-popup.body',
|
||||
defaultMessage: 'You have unsaved changes.',
|
||||
}),
|
||||
saveLabel: () => commonMessages.saveButton,
|
||||
savingLabel: () => commonMessages.savingButton,
|
||||
saveIcon: SaveIcon,
|
||||
},
|
||||
)
|
||||
|
||||
const shown = computed(() =>
|
||||
Object.keys(props.modified).some((key) => !isEqual(props.original[key], props.modified[key])),
|
||||
)
|
||||
|
||||
function localizeIfPossible(message: MessageDescriptor | string) {
|
||||
return typeof message === 'string' ? message : formatMessage(message)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FloatingActionBar :shown="shown">
|
||||
<p class="m-0 font-semibold text-sm md:text-base">{{ localizeIfPossible(text) }}</p>
|
||||
<div class="ml-auto flex gap-2">
|
||||
<ButtonStyled v-if="canReset" type="transparent">
|
||||
<button :disabled="saving" @click="(e) => emit('reset', e)">
|
||||
<HistoryIcon /> {{ formatMessage(commonMessages.resetButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="saving" @click="(e) => emit('save', e)">
|
||||
<SpinnerIcon v-if="saving" class="animate-spin" />
|
||||
<component :is="saveIcon" v-else />
|
||||
{{ localizeIfPossible(saving ? savingLabel : saveLabel) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</FloatingActionBar>
|
||||
</template>
|
||||
35
packages/ui/src/components/base/buttons/Button.vue
Normal file
35
packages/ui/src/components/base/buttons/Button.vue
Normal file
@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import ButtonFrame from './ButtonFrame.vue'
|
||||
import type { ButtonProps } from './types'
|
||||
|
||||
const props = withDefaults(defineProps<ButtonProps>(), {
|
||||
type: 'base',
|
||||
size: 'md',
|
||||
nativeType: 'button',
|
||||
disabled: false,
|
||||
loading: false,
|
||||
})
|
||||
|
||||
const frame = ref<InstanceType<typeof ButtonFrame> | null>(null)
|
||||
const element = computed(() => frame.value?.element ?? null)
|
||||
|
||||
defineExpose({ element })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ButtonFrame
|
||||
ref="frame"
|
||||
as="button"
|
||||
:type="props.type"
|
||||
:color="props.color"
|
||||
:size="props.size"
|
||||
:interaction="props.interaction"
|
||||
:native-type="props.nativeType"
|
||||
:disabled="props.disabled || props.loading"
|
||||
:aria-busy="props.loading || undefined"
|
||||
>
|
||||
<slot />
|
||||
</ButtonFrame>
|
||||
</template>
|
||||
173
packages/ui/src/components/base/buttons/ButtonFrame.vue
Normal file
173
packages/ui/src/components/base/buttons/ButtonFrame.vue
Normal file
@ -0,0 +1,173 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component, CSSProperties } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type {
|
||||
ButtonColor,
|
||||
ButtonInteraction,
|
||||
ButtonNativeType,
|
||||
ButtonSize,
|
||||
ButtonType,
|
||||
} from './types'
|
||||
|
||||
const baseClasses = [
|
||||
// Base
|
||||
'relative inline-flex min-w-0 shrink-0 items-center justify-center',
|
||||
'whitespace-nowrap border-0 no-underline',
|
||||
// Interactions
|
||||
'touch-manipulation cursor-pointer select-none transition-[background-color,color,box-shadow,filter,opacity,transform] duration-150 ease-out',
|
||||
'enabled:active:scale-[0.97]',
|
||||
// Hovering
|
||||
'[&:not(:disabled):not([aria-disabled=true]):hover]:brightness-[--hover-brightness]',
|
||||
// Accessibility
|
||||
'[&:not(:disabled):not([aria-disabled=true]):focus-visible]:brightness-[--hover-brightness] focus-visible:outline-none [&:not(:disabled):not([aria-disabled=true]):focus-visible]:ring-4 [&:not(:disabled):not([aria-disabled=true]):focus-visible]:ring-brand-shadow',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'[&[aria-disabled=true]]:cursor-not-allowed [&[aria-disabled=true]]:opacity-50',
|
||||
].join(' ')
|
||||
|
||||
const sizeClasses: Record<ButtonSize, string> = {
|
||||
xs: 'h-7 gap-1 rounded-lg px-1.5 text-sm font-semibold leading-5 [&>svg]:size-4 [&>svg]:min-h-4 [&>svg]:min-w-4 [&>svg]:shrink-0',
|
||||
sm: 'h-8 gap-1 rounded-[10px] px-1.5 text-sm font-semibold leading-5 [&>svg]:size-4 [&>svg]:min-h-4 [&>svg]:min-w-4 [&>svg]:shrink-0',
|
||||
md: 'h-9 gap-1.5 rounded-xl px-2.5 text-base font-semibold leading-5 [&>svg]:size-5 [&>svg]:min-h-5 [&>svg]:min-w-5 [&>svg]:shrink-0',
|
||||
lg: 'h-10 gap-2 rounded-[14px] px-4 text-base font-semibold leading-5 [&>svg]:size-5 [&>svg]:min-h-5 [&>svg]:min-w-5 [&>svg]:shrink-0',
|
||||
xl: 'h-12 gap-2 rounded-2xl px-3.5 text-base font-extrabold leading-5 [&>svg]:size-6 [&>svg]:min-h-6 [&>svg]:min-w-6 [&>svg]:shrink-0',
|
||||
}
|
||||
|
||||
const iconOnlySizeClasses: Record<ButtonSize, string> = {
|
||||
xs: 'min-w-7 w-7 !px-0',
|
||||
sm: 'min-w-8 w-8 !px-0',
|
||||
md: 'min-w-9 w-9 !px-0',
|
||||
lg: 'min-w-10 w-10 !px-0',
|
||||
xl: 'min-w-12 w-12 !px-0',
|
||||
}
|
||||
|
||||
const typeClasses: Record<ButtonType, string> = {
|
||||
base: 'button-frame--base bg-surface-4 text-contrast [&>svg]:text-primary',
|
||||
colored:
|
||||
'button-frame--colored bg-[--button-color] text-[var(--color-accent-contrast)] [&>svg]:text-inherit',
|
||||
'colored-text':
|
||||
'button-frame--colored-text bg-surface-4 text-[--button-color] [&>svg]:text-inherit',
|
||||
outlined:
|
||||
'button-frame--outlined bg-transparent text-[var(--button-color,var(--color-contrast))] [&>svg]:text-[var(--button-color,var(--color-base))]',
|
||||
quiet: 'button-frame--quiet bg-transparent [&>svg]:text-inherit',
|
||||
}
|
||||
|
||||
const interactionClasses: Record<ButtonInteraction, string> = {
|
||||
surface:
|
||||
'[&:not(:disabled):not([aria-disabled=true]):hover]:bg-surface-4 [&:not(:disabled):not([aria-disabled=true]):focus-visible]:bg-surface-4',
|
||||
filled:
|
||||
'[&:not(:disabled):not([aria-disabled=true]):hover]:!bg-[--button-color] [&:not(:disabled):not([aria-disabled=true]):focus-visible]:!bg-[--button-color] [&:not(:disabled):not([aria-disabled=true]):hover]:!text-[var(--color-accent-contrast)] [&:not(:disabled):not([aria-disabled=true]):focus-visible]:!text-[var(--color-accent-contrast)]',
|
||||
none: '[&:not(:disabled):not([aria-disabled=true]):hover]:!brightness-100 [&:not(:disabled):not([aria-disabled=true]):focus-visible]:!brightness-100',
|
||||
}
|
||||
|
||||
const colorVariables: Record<ButtonColor, string> = {
|
||||
brand: 'var(--color-brand)',
|
||||
red: 'var(--color-red)',
|
||||
orange: 'var(--color-orange)',
|
||||
green: 'var(--color-green)',
|
||||
blue: 'var(--color-blue)',
|
||||
purple: 'var(--color-purple)',
|
||||
medal_promotion: 'var(--medal-promotion-text-orange, var(--color-orange))',
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
as: string | Component
|
||||
type?: ButtonType
|
||||
color?: ButtonColor
|
||||
size?: ButtonSize
|
||||
interaction?: ButtonInteraction
|
||||
iconOnly?: boolean
|
||||
circular?: boolean
|
||||
nativeType?: ButtonNativeType
|
||||
}>(),
|
||||
{
|
||||
type: 'base',
|
||||
size: 'md',
|
||||
interaction: 'surface',
|
||||
iconOnly: false,
|
||||
circular: false,
|
||||
nativeType: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const element = ref<HTMLElement | null>(null)
|
||||
const classes = computed(() => [
|
||||
baseClasses,
|
||||
typeClasses[props.type],
|
||||
props.type === 'quiet' ? interactionClasses[props.interaction] : '',
|
||||
sizeClasses[props.size],
|
||||
props.iconOnly ? iconOnlySizeClasses[props.size] : '',
|
||||
props.circular ? '!rounded-full' : '',
|
||||
])
|
||||
const style = computed((): CSSProperties | undefined => {
|
||||
if ((props.type === 'outlined' || props.type === 'quiet') && !props.color) return undefined
|
||||
if (
|
||||
props.type !== 'colored' &&
|
||||
props.type !== 'colored-text' &&
|
||||
props.type !== 'outlined' &&
|
||||
props.type !== 'quiet'
|
||||
)
|
||||
return undefined
|
||||
|
||||
return {
|
||||
'--button-color': colorVariables[props.color ?? 'brand'],
|
||||
} as CSSProperties
|
||||
})
|
||||
|
||||
defineExpose({ element })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="as"
|
||||
ref="element"
|
||||
data-button
|
||||
:type="props.nativeType"
|
||||
:class="classes"
|
||||
:style="style"
|
||||
>
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.button-frame--base,
|
||||
.button-frame--colored-text {
|
||||
box-shadow:
|
||||
inset 0 0 0 1px var(--surface-5),
|
||||
0 1px 1px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.button-frame--colored {
|
||||
box-shadow:
|
||||
0 0 0 1px color-mix(in srgb, var(--button-color) 30%, transparent),
|
||||
0 2px 4px rgba(0, 0, 0, 0.04),
|
||||
0 5px 8px rgba(0, 0, 0, 0.04),
|
||||
0 10px 18px rgba(0, 0, 0, 0.03),
|
||||
0 24px 48px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.button-frame--colored::before {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 1px;
|
||||
pointer-events: none;
|
||||
content: '';
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0));
|
||||
-webkit-mask:
|
||||
linear-gradient(#000 0 0) content-box,
|
||||
linear-gradient(#000 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
}
|
||||
|
||||
.button-frame--outlined {
|
||||
box-shadow: inset 0 0 0 1px var(--button-color, var(--surface-5));
|
||||
}
|
||||
|
||||
.button-frame--quiet {
|
||||
color: var(--button-color, var(--color-base));
|
||||
}
|
||||
</style>
|
||||
58
packages/ui/src/components/base/buttons/IconButton.vue
Normal file
58
packages/ui/src/components/base/buttons/IconButton.vue
Normal file
@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import ButtonFrame from './ButtonFrame.vue'
|
||||
import type {
|
||||
ButtonColor,
|
||||
ButtonInteraction,
|
||||
ButtonNativeType,
|
||||
ButtonSize,
|
||||
ButtonType,
|
||||
} from './types'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label: string
|
||||
type?: ButtonType
|
||||
color?: ButtonColor
|
||||
size?: ButtonSize
|
||||
interaction?: ButtonInteraction
|
||||
nativeType?: ButtonNativeType
|
||||
circular?: boolean
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
}>(),
|
||||
{
|
||||
type: 'base',
|
||||
size: 'md',
|
||||
nativeType: 'button',
|
||||
circular: true,
|
||||
disabled: false,
|
||||
loading: false,
|
||||
},
|
||||
)
|
||||
|
||||
const frame = ref<InstanceType<typeof ButtonFrame> | null>(null)
|
||||
const element = computed(() => frame.value?.element ?? null)
|
||||
|
||||
defineExpose({ element })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ButtonFrame
|
||||
ref="frame"
|
||||
as="button"
|
||||
icon-only
|
||||
:circular="props.circular"
|
||||
:type="props.type"
|
||||
:color="props.color"
|
||||
:size="props.size"
|
||||
:interaction="props.interaction"
|
||||
:native-type="props.nativeType"
|
||||
:disabled="props.disabled || props.loading"
|
||||
:aria-label="props.label"
|
||||
:aria-busy="props.loading || undefined"
|
||||
>
|
||||
<slot />
|
||||
</ButtonFrame>
|
||||
</template>
|
||||
57
packages/ui/src/components/base/buttons/types.ts
Normal file
57
packages/ui/src/components/base/buttons/types.ts
Normal file
@ -0,0 +1,57 @@
|
||||
export type ButtonType = 'base' | 'colored' | 'colored-text' | 'outlined' | 'quiet'
|
||||
|
||||
export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
||||
|
||||
export type ButtonInteraction = 'surface' | 'filled' | 'none'
|
||||
|
||||
// TODO: Standardized color string enum props across @modrinth/ui
|
||||
export type ButtonColor =
|
||||
| 'brand'
|
||||
| 'red'
|
||||
| 'orange'
|
||||
| 'green'
|
||||
| 'blue'
|
||||
| 'purple'
|
||||
| 'medal_promotion'
|
||||
|
||||
export type ButtonVisualProps = {
|
||||
size?: ButtonSize
|
||||
interaction?: ButtonInteraction
|
||||
} & (
|
||||
| {
|
||||
type?: 'base'
|
||||
color?: never
|
||||
}
|
||||
| {
|
||||
type: 'outlined'
|
||||
color?: ButtonColor
|
||||
}
|
||||
| {
|
||||
type: 'colored'
|
||||
color?: ButtonColor
|
||||
}
|
||||
| {
|
||||
type: 'colored-text'
|
||||
color?: ButtonColor
|
||||
}
|
||||
| {
|
||||
type: 'quiet'
|
||||
color?: ButtonColor
|
||||
}
|
||||
)
|
||||
|
||||
export type ButtonNativeType = 'button' | 'submit' | 'reset'
|
||||
|
||||
export interface ButtonProps {
|
||||
type?: ButtonType
|
||||
color?: ButtonColor
|
||||
size?: ButtonSize
|
||||
interaction?: ButtonInteraction
|
||||
nativeType?: ButtonNativeType
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export interface ButtonElementHandle {
|
||||
element: HTMLElement | null
|
||||
}
|
||||
10
packages/ui/src/components/base/drop-area-lifecycle.test.ts
Normal file
10
packages/ui/src/components/base/drop-area-lifecycle.test.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
test('drop area removes its document listener when unmounted', () => {
|
||||
const source = readFileSync(new URL('./DropArea.vue', import.meta.url), 'utf8')
|
||||
|
||||
assert.match(source, /document\.addEventListener\('dragenter', allowDrag\)/)
|
||||
assert.match(source, /document\.removeEventListener\('dragenter', allowDrag\)/)
|
||||
})
|
||||
76
packages/ui/src/components/base/dropdown-placement.test.ts
Normal file
76
packages/ui/src/components/base/dropdown-placement.test.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { dropdownPlacement, shouldRenderDropdownUp } from './dropdown-placement.ts'
|
||||
|
||||
const baseGeometry = {
|
||||
viewportHeight: 800,
|
||||
controlTop: 400,
|
||||
controlBottom: 440,
|
||||
floatingActionBarClearance: 0,
|
||||
safeGap: 8,
|
||||
expectedMenuHeight: 300,
|
||||
}
|
||||
|
||||
test('dropdown stays down when expected menu fits below', () => {
|
||||
assert.equal(shouldRenderDropdownUp(baseGeometry), false)
|
||||
})
|
||||
|
||||
test('floating action bar clearance moves a colliding dropdown upward', () => {
|
||||
assert.equal(shouldRenderDropdownUp({ ...baseGeometry, floatingActionBarClearance: 180 }), true)
|
||||
})
|
||||
|
||||
test('dropdown chooses side with more space when neither side fits', () => {
|
||||
assert.equal(
|
||||
shouldRenderDropdownUp({
|
||||
...baseGeometry,
|
||||
controlTop: 450,
|
||||
controlBottom: 490,
|
||||
expectedMenuHeight: 500,
|
||||
}),
|
||||
true,
|
||||
)
|
||||
assert.equal(
|
||||
shouldRenderDropdownUp({
|
||||
...baseGeometry,
|
||||
controlTop: 250,
|
||||
controlBottom: 290,
|
||||
expectedMenuHeight: 600,
|
||||
}),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
test('zero floating action bar clearance uses normal viewport space', () => {
|
||||
assert.equal(
|
||||
shouldRenderDropdownUp({
|
||||
...baseGeometry,
|
||||
controlTop: 300,
|
||||
controlBottom: 340,
|
||||
floatingActionBarClearance: 0,
|
||||
}),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
test('placement caps menu height to selected side above the floating bar', () => {
|
||||
assert.deepEqual(
|
||||
dropdownPlacement({
|
||||
...baseGeometry,
|
||||
controlTop: 260,
|
||||
controlBottom: 300,
|
||||
floatingActionBarClearance: 180,
|
||||
}),
|
||||
{ renderUp: false, availableHeight: 312 },
|
||||
)
|
||||
assert.deepEqual(
|
||||
dropdownPlacement({
|
||||
...baseGeometry,
|
||||
controlTop: 450,
|
||||
controlBottom: 490,
|
||||
floatingActionBarClearance: 80,
|
||||
expectedMenuHeight: 500,
|
||||
}),
|
||||
{ renderUp: true, availableHeight: 442 },
|
||||
)
|
||||
})
|
||||
37
packages/ui/src/components/base/dropdown-placement.ts
Normal file
37
packages/ui/src/components/base/dropdown-placement.ts
Normal file
@ -0,0 +1,37 @@
|
||||
export interface DropdownPlacementGeometry {
|
||||
viewportHeight: number
|
||||
controlTop: number
|
||||
controlBottom: number
|
||||
floatingActionBarClearance: number
|
||||
safeGap: number
|
||||
expectedMenuHeight: number
|
||||
}
|
||||
|
||||
export interface DropdownPlacement {
|
||||
renderUp: boolean
|
||||
availableHeight: number
|
||||
}
|
||||
|
||||
export function dropdownPlacement({
|
||||
viewportHeight,
|
||||
controlTop,
|
||||
controlBottom,
|
||||
floatingActionBarClearance,
|
||||
safeGap,
|
||||
expectedMenuHeight,
|
||||
}: DropdownPlacementGeometry): DropdownPlacement {
|
||||
const availableBelow = Math.max(
|
||||
0,
|
||||
viewportHeight - controlBottom - floatingActionBarClearance - safeGap,
|
||||
)
|
||||
const availableAbove = Math.max(0, controlTop - safeGap)
|
||||
const renderUp = availableBelow < expectedMenuHeight && availableAbove > availableBelow
|
||||
return {
|
||||
renderUp,
|
||||
availableHeight: renderUp ? availableAbove : availableBelow,
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldRenderDropdownUp(geometry: DropdownPlacementGeometry): boolean {
|
||||
return dropdownPlacement(geometry).renderUp
|
||||
}
|
||||
117
packages/ui/src/components/base/index.ts
Normal file
117
packages/ui/src/components/base/index.ts
Normal file
@ -0,0 +1,117 @@
|
||||
export { default as Accordion } from './Accordion.vue'
|
||||
export { default as Admonition } from './Admonition.vue'
|
||||
export { default as AppearingProgressBar } from './AppearingProgressBar.vue'
|
||||
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 BaseTerminal } from './BaseTerminal.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 NewButton } from './buttons/Button.vue'
|
||||
export { default as IconButton } from './buttons/IconButton.vue'
|
||||
export type {
|
||||
ButtonColor,
|
||||
ButtonElementHandle,
|
||||
ButtonInteraction,
|
||||
ButtonNativeType,
|
||||
ButtonSize,
|
||||
ButtonType,
|
||||
ButtonVisualProps,
|
||||
} from './buttons/types'
|
||||
export { default as ButtonStyled } from './ButtonStyled.vue'
|
||||
export { default as Card } from './Card.vue'
|
||||
export { default as Checkbox } from './Checkbox.vue'
|
||||
export { default as Chips } from './Chips.vue'
|
||||
export { default as Collapsible } from './Collapsible.vue'
|
||||
export type { CollapsibleAdmonitionItem } from './CollapsibleAdmonition.vue'
|
||||
export { default as CollapsibleAdmonition } from './CollapsibleAdmonition.vue'
|
||||
export { default as CollapsibleRegion } from './CollapsibleRegion.vue'
|
||||
export type { ComboboxOption } from './Combobox.vue'
|
||||
export { default as Combobox } from './Combobox.vue'
|
||||
export { default as ContentPageHeader } from './ContentPageHeader.vue'
|
||||
export { default as CopyCode } from './CopyCode.vue'
|
||||
export { default as DatePicker } from './DatePicker.vue'
|
||||
export { default as DoubleIcon } from './DoubleIcon.vue'
|
||||
export { default as DropArea } from './DropArea.vue'
|
||||
export type { DropdownFilterBarCategory, DropdownFilterBarOption } from './DropdownFilterBar.vue'
|
||||
export { default as DropdownFilterBar } from './DropdownFilterBar.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'
|
||||
export type { FileTreeSelectItem } from './FileTreeSelect.vue'
|
||||
export { default as FileTreeSelect } from './FileTreeSelect.vue'
|
||||
export type { FilterBarOption } from './FilterBar.vue'
|
||||
export { default as FilterBar } from './FilterBar.vue'
|
||||
export type { FilterPillOption } from './FilterPills.vue'
|
||||
export { default as FilterPills } from './FilterPills.vue'
|
||||
export { default as FloatingActionBar } from './FloatingActionBar.vue'
|
||||
export { default as FloatingPanel } from './FloatingPanel.vue'
|
||||
export { default as FormattedTag } from './FormattedTag.vue'
|
||||
export { default as HeadingLink } from './HeadingLink.vue'
|
||||
export { default as HorizontalRule } from './HorizontalRule.vue'
|
||||
export { default as I18nDebugPanel } from './I18nDebugPanel.vue'
|
||||
export { default as IconSelect } from './IconSelect.vue'
|
||||
export { default as InstanceRowCard } from './InstanceRowCard.vue'
|
||||
export { default as IntlFormatted } from './IntlFormatted.vue'
|
||||
export type { JoinedButtonAction } from './JoinedButtons.vue'
|
||||
export { default as JoinedButtons } from './JoinedButtons.vue'
|
||||
export { default as LoadingBar } from './LoadingBar.vue'
|
||||
export { default as LoadingIndicator } from './LoadingIndicator.vue'
|
||||
export { default as ManySelect } from './ManySelect.vue'
|
||||
export { default as MarkdownEditor } from './MarkdownEditor.vue'
|
||||
export { default as MinecraftFormattedText } from './MinecraftFormattedText.vue'
|
||||
export type {
|
||||
MultiSelectItem,
|
||||
MultiSelectOption,
|
||||
MultiSelectSectionHeader,
|
||||
} from './MultiSelect.vue'
|
||||
export { default as MultiSelect } from './MultiSelect.vue'
|
||||
export type { MaybeCtxFn, StageButtonConfig, StageConfigInput } from './MultiStageModal.vue'
|
||||
export { default as MultiStageModal, resolveCtxFn } from './MultiStageModal.vue'
|
||||
export { default as NavTabs } from './NavTabs.vue'
|
||||
export { default as OptionGroup } from './OptionGroup.vue'
|
||||
export type { Option as OverflowMenuOption } from './OverflowMenu.vue'
|
||||
export { default as OverflowMenu } from './OverflowMenu.vue'
|
||||
export { default as Page } from './Page.vue'
|
||||
export { default as Pagination } from './Pagination.vue'
|
||||
export { default as PopoutMenu } from './PopoutMenu.vue'
|
||||
export { default as PreviewSelectButton } from './PreviewSelectButton.vue'
|
||||
export { default as ProgressBar } from './ProgressBar.vue'
|
||||
export { default as ProgressSpinner } from './ProgressSpinner.vue'
|
||||
export { default as RadialHeader } from './RadialHeader.vue'
|
||||
export { default as RadioButtons } from './RadioButtons.vue'
|
||||
export { default as ReadyTransition } from './ReadyTransition.vue'
|
||||
export { default as ScrollablePanel } from './ScrollablePanel.vue'
|
||||
export { default as ScrollToTopButton } from './ScrollToTopButton.vue'
|
||||
export { default as SelectionCard } from './SelectionCard.vue'
|
||||
export { default as ServerNotice } from './ServerNotice.vue'
|
||||
export { default as SettingsLabel } from './SettingsLabel.vue'
|
||||
export { default as SimpleBadge } from './SimpleBadge.vue'
|
||||
export { default as Slider } from './Slider.vue'
|
||||
export { default as SmartClickable } from './SmartClickable.vue'
|
||||
export type { StackedAdmonitionItem, StackedAdmonitionType } from './StackedAdmonitions.vue'
|
||||
export { default as StackedAdmonitions } from './StackedAdmonitions.vue'
|
||||
export { default as StatItem } from './StatItem.vue'
|
||||
export { default as StyledInput } from './StyledInput.vue'
|
||||
export type { SortDirection, TableColumn } from './Table.vue'
|
||||
export { default as Table } from './Table.vue'
|
||||
export type { TabsTab, TabsValue } from './Tabs.vue'
|
||||
export { default as Tabs } from './Tabs.vue'
|
||||
export { default as TagItem } from './TagItem.vue'
|
||||
export { default as TagTagItem } from './TagTagItem.vue'
|
||||
export type {
|
||||
TimeFrameLastUnit,
|
||||
TimeFrameLastUnitOption,
|
||||
TimeFrameMode,
|
||||
TimeFramePickerSelection,
|
||||
TimeFramePreset,
|
||||
} from './TimeFramePicker.vue'
|
||||
export { default as TimeFramePicker } from './TimeFramePicker.vue'
|
||||
export { default as Timeline } from './Timeline.vue'
|
||||
export { default as Toggle } from './Toggle.vue'
|
||||
export { default as UnsavedChangesPopup } from './UnsavedChangesPopup.vue'
|
||||
98
packages/ui/src/components/brand/AnimatedLogo.vue
Normal file
98
packages/ui/src/components/brand/AnimatedLogo.vue
Normal file
@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<div>
|
||||
<svg
|
||||
class="rotate outer"
|
||||
width="100%"
|
||||
height="100%"
|
||||
viewBox="0 0 590 591"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xml:space="preserve"
|
||||
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2"
|
||||
>
|
||||
<g transform="matrix(1,0,0,1,652.392,-0.400578)">
|
||||
<g transform="matrix(4.16667,0,0,4.16667,-735.553,0)">
|
||||
<g transform="matrix(0.24,0,0,0.24,0,0)">
|
||||
<path
|
||||
d="M134.44,316.535C145.027,441.531 249.98,539.829 377.711,539.829C474.219,539.829 557.724,483.712 597.342,402.371L645.949,419.197C599.165,520.543 496.595,590.954 377.711,590.954C221.751,590.954 93.869,469.779 83.161,316.535L134.44,316.535ZM83.946,265.645C99.012,116.762 224.88,0.401 377.711,0.401C540.678,0.401 672.987,132.71 672.987,295.677C672.987,321.817 669.583,347.168 663.194,371.313L614.709,354.529C619.381,335.689 621.862,315.971 621.862,295.677C621.862,160.926 512.461,51.526 377.711,51.526C253.133,51.526 150.223,145.03 135.392,265.645L83.946,265.645Z"
|
||||
style="fill: var(--color-brand)"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<svg
|
||||
class="rotate inner"
|
||||
width="100%"
|
||||
height="100%"
|
||||
viewBox="0 0 590 591"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xml:space="preserve"
|
||||
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2"
|
||||
>
|
||||
<g transform="matrix(1,0,0,1,652.392,-0.400578)">
|
||||
<g transform="matrix(4.16667,0,0,4.16667,-735.553,0)">
|
||||
<g transform="matrix(0.24,0,0,0.24,0,0)">
|
||||
<path
|
||||
d="M376.933,153.568C298.44,153.644 234.735,217.396 234.735,295.909C234.735,374.47 298.516,438.251 377.077,438.251C381.06,438.251 385.005,438.087 388.914,437.764L403.128,487.517C394.611,488.667 385.912,489.261 377.077,489.261C270.363,489.261 183.725,402.623 183.725,295.909C183.725,189.195 270.363,102.557 377.077,102.557C379.723,102.557 382.357,102.611 384.983,102.717L376.933,153.568ZM435.127,111.438C513.515,136.114 570.428,209.418 570.428,295.909C570.428,375.976 521.655,444.742 452.22,474.093L438.063,424.541C486.142,401.687 519.418,352.653 519.418,295.909C519.418,234.923 480.981,182.843 427.029,162.593L435.127,111.438Z"
|
||||
style="fill: var(--color-brand)"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
viewBox="0 0 590 591"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xml:space="preserve"
|
||||
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2"
|
||||
>
|
||||
<g transform="matrix(1,0,0,1,652.392,-0.400578)">
|
||||
<g transform="matrix(4.16667,0,0,4.16667,-735.553,0)">
|
||||
<g transform="matrix(0.24,0,0,0.24,0,0)">
|
||||
<path
|
||||
d="M300.366,311.86L283.216,266.381L336.966,211.169L404.9,196.531L424.57,220.74L393.254,252.46L365.941,261.052L346.425,281.11L355.987,307.719L375.387,328.306L402.745,321.031L422.216,299.648L464.729,286.185L477.395,314.677L433.529,368.46L360.02,391.735L327.058,355.031L138.217,468.344C129.245,456.811 118.829,440.485 112.15,424.792L300.366,311.86Z"
|
||||
style="fill: var(--color-brand)"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
<g transform="matrix(4.16667,0,0,4.16667,-735.553,0)">
|
||||
<g transform="matrix(0.24,0,0,0.24,0,0)">
|
||||
<path
|
||||
d="M655.189,194.555L505.695,234.873C513.927,256.795 516.638,269.674 518.915,283.863L668.152,243.609C665.764,227.675 661.5,211.444 655.189,194.555Z"
|
||||
style="fill: var(--color-brand)"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
div {
|
||||
height: 5rem;
|
||||
|
||||
svg {
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
position: absolute;
|
||||
&.rotate {
|
||||
animation: rotate 4s infinite linear;
|
||||
&.inner {
|
||||
animation: rotate 6s infinite linear reverse;
|
||||
}
|
||||
}
|
||||
@keyframes rotate {
|
||||
0% {
|
||||
transform: rotate(0);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1
packages/ui/src/components/brand/index.ts
Normal file
1
packages/ui/src/components/brand/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default as AnimatedLogo } from './AnimatedLogo.vue'
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<div data-onboarding-id="creation-import" class="flex flex-col gap-4">
|
||||
<div data-onboarding-id="creation-import-methods" class="flex flex-col gap-3">
|
||||
<BigOptionButton
|
||||
data-onboarding-id="creation-import-file"
|
||||
:icon="FileIcon"
|
||||
no-icon-border
|
||||
:title="formatMessage(messages.selectFile)"
|
||||
:description="formatMessage(messages.selectFileDescription)"
|
||||
@click="handleOpenFilePicker"
|
||||
/>
|
||||
<BigOptionButton
|
||||
data-onboarding-id="creation-import-folder"
|
||||
:icon="FolderIcon"
|
||||
no-icon-border
|
||||
:title="formatMessage(messages.selectFolder)"
|
||||
:description="formatMessage(messages.selectFolderDescription)"
|
||||
@click="handleOpenFolderPicker"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span class="text-sm text-secondary">
|
||||
{{ formatMessage(messages.importPrompt) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FileIcon, FolderIcon } from '@modrinth/assets'
|
||||
import { BigOptionButton, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
|
||||
import { injectCreationFlowContext } from '../creation-flow-context'
|
||||
|
||||
const ctx = injectCreationFlowContext()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
selectFile: {
|
||||
id: 'creation-flow.modal.import-instance.select-file',
|
||||
defaultMessage: 'Select file to import',
|
||||
},
|
||||
selectFileDescription: {
|
||||
id: 'creation-flow.modal.import-instance.select-file.description',
|
||||
defaultMessage: 'Import a modpack file or launcher archive',
|
||||
},
|
||||
selectFolder: {
|
||||
id: 'creation-flow.modal.import-instance.select-folder',
|
||||
defaultMessage: 'Select folder to import',
|
||||
},
|
||||
selectFolderDescription: {
|
||||
id: 'creation-flow.modal.import-instance.select-folder.description',
|
||||
defaultMessage: 'Import a launcher folder or .minecraft folder',
|
||||
},
|
||||
importPrompt: {
|
||||
id: 'creation-flow.modal.import-instance.import-prompt',
|
||||
defaultMessage:
|
||||
'Drag & drop launcher folders, modpack files, or .minecraft folders to import an instance in one click',
|
||||
},
|
||||
})
|
||||
|
||||
// ── Native file picker ──
|
||||
async function handleOpenFilePicker() {
|
||||
try {
|
||||
const { open } = await import('@tauri-apps/plugin-dialog')
|
||||
const result = await open({
|
||||
multiple: false,
|
||||
})
|
||||
const filePath = typeof result === 'string' ? result : (result?.path ?? null)
|
||||
if (!filePath) return
|
||||
|
||||
if (ctx.onImportFileReceived) {
|
||||
ctx.onImportFileReceived({
|
||||
file: null,
|
||||
filePath,
|
||||
source: 'file-picker',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback: set path directly on context
|
||||
ctx.modpackFile.value = null
|
||||
ctx.modpackFilePath.value = filePath
|
||||
if (ctx.finishDisabled.value) return
|
||||
if (ctx.flowType === 'instance') {
|
||||
ctx.finish()
|
||||
} else {
|
||||
ctx.modal.value?.setStage('final-config')
|
||||
}
|
||||
} catch {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
// ── Native folder picker ──
|
||||
async function handleOpenFolderPicker() {
|
||||
try {
|
||||
const { open } = await import('@tauri-apps/plugin-dialog')
|
||||
const result = await open({ multiple: false, directory: true })
|
||||
const filePath = typeof result === 'string' ? result : (result?.path ?? null)
|
||||
if (!filePath) return
|
||||
|
||||
if (ctx.onImportFileReceived) {
|
||||
ctx.onImportFileReceived({
|
||||
file: null,
|
||||
filePath,
|
||||
source: 'file-picker',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback: set path directly on context
|
||||
ctx.modpackFile.value = null
|
||||
ctx.modpackFilePath.value = filePath
|
||||
if (ctx.finishDisabled.value) return
|
||||
if (ctx.flowType === 'instance') {
|
||||
ctx.finish()
|
||||
} else {
|
||||
ctx.modal.value?.setStage('final-config')
|
||||
}
|
||||
} catch {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<span class="font-semibold text-contrast">
|
||||
{{ setupTypeTitle }}
|
||||
</span>
|
||||
|
||||
<!-- Instance flow options -->
|
||||
<template v-if="ctx.flowType === 'instance'">
|
||||
<div data-onboarding-id="creation-methods" class="flex flex-col gap-3">
|
||||
<BigOptionButton
|
||||
data-onboarding-id="creation-method-custom"
|
||||
:icon="BoxesIcon"
|
||||
:title="formatMessage(messages.customSetupTitle)"
|
||||
:description="formatMessage(messages.customSetupDescription)"
|
||||
@click="setSetupType('custom')"
|
||||
/>
|
||||
<BigOptionButton
|
||||
data-onboarding-id="creation-method-modpack"
|
||||
:icon="PackageIcon"
|
||||
:title="formatMessage(messages.modpackBaseTitle)"
|
||||
:description="formatMessage(messages.modpackBaseDescription)"
|
||||
@click="setSetupType('modpack')"
|
||||
/>
|
||||
<BigOptionButton
|
||||
data-onboarding-id="creation-method-import"
|
||||
:icon="BoxImportIcon"
|
||||
:title="formatMessage(messages.importInstanceTitle)"
|
||||
:description="formatMessage(messages.importInstanceDescription)"
|
||||
@click="ctx.setImportMode()"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-sm text-secondary">
|
||||
{{ formatMessage(messages.instanceDescription) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- World / Server onboarding flow options -->
|
||||
<template v-else>
|
||||
<div data-onboarding-id="creation-methods" class="flex flex-col gap-3">
|
||||
<BigOptionButton
|
||||
data-onboarding-id="creation-method-modpack"
|
||||
:icon="PackageIcon"
|
||||
:title="formatMessage(messages.modpackBaseTitle)"
|
||||
:description="formatMessage(messages.modpackBaseDescription)"
|
||||
@click="setSetupType('modpack')"
|
||||
/>
|
||||
<BigOptionButton
|
||||
data-onboarding-id="creation-method-custom"
|
||||
:icon="BoxesIcon"
|
||||
:title="formatMessage(messages.customSetupTitle)"
|
||||
:description="formatMessage(messages.customSetupDescription)"
|
||||
@click="setSetupType('custom')"
|
||||
/>
|
||||
<BigOptionButton
|
||||
data-onboarding-id="creation-method-vanilla"
|
||||
:icon="BoxIcon"
|
||||
:title="formatMessage(messages.vanillaMinecraftTitle)"
|
||||
:description="formatMessage(messages.vanillaMinecraftDescription)"
|
||||
@click="setSetupType('vanilla')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { BoxesIcon, BoxIcon, BoxImportIcon, PackageIcon } from '@modrinth/assets'
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useDebugLogger } from '#ui/composables/debug-logger'
|
||||
|
||||
import BigOptionButton from '../../../base/BigOptionButton.vue'
|
||||
import { injectCreationFlowContext } from '../creation-flow-context'
|
||||
|
||||
const debug = useDebugLogger('SetupTypeStage')
|
||||
const ctx = injectCreationFlowContext()
|
||||
const { setSetupType: _setSetupType } = ctx
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
instanceTypeTitle: {
|
||||
id: 'creation-flow.modal.setup-type.title.instance',
|
||||
defaultMessage: 'Choose instance type',
|
||||
},
|
||||
installationTypeTitle: {
|
||||
id: 'creation-flow.modal.setup-type.title.installation',
|
||||
defaultMessage: 'Select installation type',
|
||||
},
|
||||
worldTypeTitle: {
|
||||
id: 'creation-flow.modal.setup-type.title.world',
|
||||
defaultMessage: 'Select world type',
|
||||
},
|
||||
customSetupTitle: {
|
||||
id: 'creation-flow.modal.setup-type.option.custom-setup.title',
|
||||
defaultMessage: 'Custom setup',
|
||||
},
|
||||
customSetupDescription: {
|
||||
id: 'creation-flow.modal.setup-type.option.custom-setup.description',
|
||||
defaultMessage: 'Start from scratch by picking a loader and game version.',
|
||||
},
|
||||
modpackBaseTitle: {
|
||||
id: 'creation-flow.modal.setup-type.option.modpack-base.title',
|
||||
defaultMessage: 'Install modpack',
|
||||
},
|
||||
modpackBaseDescription: {
|
||||
id: 'creation-flow.modal.setup-type.option.modpack-base.description',
|
||||
defaultMessage: 'Browse modpacks on Modrinth or import one from a file.',
|
||||
},
|
||||
importInstanceTitle: {
|
||||
id: 'creation-flow.modal.setup-type.option.import-instance.title',
|
||||
defaultMessage: 'Import instance',
|
||||
},
|
||||
importInstanceDescription: {
|
||||
id: 'creation-flow.modal.setup-type.option.import-instance.description',
|
||||
defaultMessage: 'Import an instance from Prism, CurseForge, or similar.',
|
||||
},
|
||||
instanceDescription: {
|
||||
id: 'creation-flow.modal.setup-type.instance.description',
|
||||
defaultMessage: 'An instance is a Minecraft setup with a specific loader, version, and mods.',
|
||||
},
|
||||
vanillaMinecraftTitle: {
|
||||
id: 'creation-flow.modal.setup-type.option.vanilla-minecraft.title',
|
||||
defaultMessage: 'Vanilla Minecraft',
|
||||
},
|
||||
vanillaMinecraftDescription: {
|
||||
id: 'creation-flow.modal.setup-type.option.vanilla-minecraft.description',
|
||||
defaultMessage: 'Classic Minecraft with no mods or plugins.',
|
||||
},
|
||||
})
|
||||
|
||||
const setupTypeTitle = computed(() => {
|
||||
if (ctx.flowType === 'instance') {
|
||||
return formatMessage(messages.instanceTypeTitle)
|
||||
}
|
||||
if (ctx.flowType === 'server-onboarding' || ctx.flowType === 'reset-server') {
|
||||
return formatMessage(messages.installationTypeTitle)
|
||||
}
|
||||
return formatMessage(messages.worldTypeTitle)
|
||||
})
|
||||
|
||||
function setSetupType(type: 'modpack' | 'custom' | 'vanilla') {
|
||||
debug('selected:', type)
|
||||
_setSetupType(type)
|
||||
}
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user