You've already forked AstralRinth
forked from didirus/AstralRinth
refactor: migrate to common eslint+prettier configs (#4168)
* refactor: migrate to common eslint+prettier configs * fix: prettier frontend * feat: config changes * fix: lint issues * fix: lint * fix: type imports * fix: cyclical import issue * fix: lockfile * fix: missing dep * fix: switch to tabs * fix: continue switch to tabs * fix: rustfmt parity * fix: moderation lint issue * fix: lint issues * fix: ui intl * fix: lint issues * Revert "fix: rustfmt parity" This reverts commit cb99d2376c321d813d4b7fc7e2a213bb30a54711. * feat: revert last rs
This commit is contained in:
@@ -1,29 +1,29 @@
|
||||
<template>
|
||||
<div v-bind="$attrs">
|
||||
<button
|
||||
v-if="!!slots.title"
|
||||
:class="buttonClass ?? 'flex flex-col gap-2 bg-transparent m-0 p-0 border-none'"
|
||||
@click="() => (isOpen ? close() : open())"
|
||||
>
|
||||
<slot name="button" :open="isOpen">
|
||||
<div class="flex items-center gap-1 w-full">
|
||||
<slot name="title" />
|
||||
<DropdownIcon
|
||||
class="ml-auto size-5 transition-transform duration-300 shrink-0"
|
||||
:class="{ 'rotate-180': isOpen }"
|
||||
/>
|
||||
</div>
|
||||
</slot>
|
||||
<slot name="summary" />
|
||||
</button>
|
||||
<div class="accordion-content" :class="{ open: isOpen }">
|
||||
<div>
|
||||
<div :class="contentClass ? contentClass : ''" :inert="!isOpen">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-bind="$attrs">
|
||||
<button
|
||||
v-if="!!slots.title"
|
||||
:class="buttonClass ?? 'flex flex-col gap-2 bg-transparent m-0 p-0 border-none'"
|
||||
@click="() => (isOpen ? close() : open())"
|
||||
>
|
||||
<slot name="button" :open="isOpen">
|
||||
<div class="flex items-center gap-1 w-full">
|
||||
<slot name="title" />
|
||||
<DropdownIcon
|
||||
class="ml-auto size-5 transition-transform duration-300 shrink-0"
|
||||
:class="{ 'rotate-180': isOpen }"
|
||||
/>
|
||||
</div>
|
||||
</slot>
|
||||
<slot name="summary" />
|
||||
</button>
|
||||
<div class="accordion-content" :class="{ open: isOpen }">
|
||||
<div>
|
||||
<div :class="contentClass ? contentClass : ''" :inert="!isOpen">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -31,20 +31,20 @@ import { DropdownIcon } from '@modrinth/assets'
|
||||
import { ref, useSlots } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
openByDefault?: boolean
|
||||
type?: 'standard' | 'outlined' | 'transparent'
|
||||
buttonClass?: string
|
||||
contentClass?: string
|
||||
titleWrapperClass?: string
|
||||
}>(),
|
||||
{
|
||||
type: 'standard',
|
||||
openByDefault: false,
|
||||
buttonClass: null,
|
||||
contentClass: null,
|
||||
titleWrapperClass: null,
|
||||
},
|
||||
defineProps<{
|
||||
openByDefault?: boolean
|
||||
type?: 'standard' | 'outlined' | 'transparent'
|
||||
buttonClass?: string
|
||||
contentClass?: string
|
||||
titleWrapperClass?: string
|
||||
}>(),
|
||||
{
|
||||
type: 'standard',
|
||||
openByDefault: false,
|
||||
buttonClass: null,
|
||||
contentClass: null,
|
||||
titleWrapperClass: null,
|
||||
},
|
||||
)
|
||||
|
||||
const isOpen = ref(props.openByDefault)
|
||||
@@ -53,42 +53,42 @@ const emit = defineEmits(['onOpen', 'onClose'])
|
||||
const slots = useSlots()
|
||||
|
||||
function open() {
|
||||
isOpen.value = true
|
||||
emit('onOpen')
|
||||
isOpen.value = true
|
||||
emit('onOpen')
|
||||
}
|
||||
function close() {
|
||||
isOpen.value = false
|
||||
emit('onClose')
|
||||
isOpen.value = false
|
||||
emit('onClose')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open,
|
||||
close,
|
||||
isOpen,
|
||||
open,
|
||||
close,
|
||||
isOpen,
|
||||
})
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
inheritAttrs: false,
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.accordion-content {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 0.3s ease-in-out;
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
.accordion-content {
|
||||
transition: none !important;
|
||||
}
|
||||
.accordion-content {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.accordion-content.open {
|
||||
grid-template-rows: 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.accordion-content > div {
|
||||
overflow: hidden;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,61 +1,61 @@
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'flex rounded-2xl border-2 border-solid p-4 gap-4 font-semibold text-contrast',
|
||||
typeClasses[type],
|
||||
]"
|
||||
>
|
||||
<component
|
||||
:is="icons[type]"
|
||||
:class="['hidden h-8 w-8 flex-none sm:block', iconClasses[type]]"
|
||||
/>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="font-semibold flex justify-between gap-4">
|
||||
<slot name="header">{{ header }}</slot>
|
||||
</div>
|
||||
<div class="font-normal">
|
||||
<slot>{{ body }}</slot>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-auto w-fit">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
:class="[
|
||||
'flex rounded-2xl border-2 border-solid p-4 gap-4 font-semibold text-contrast',
|
||||
typeClasses[type],
|
||||
]"
|
||||
>
|
||||
<component
|
||||
:is="icons[type]"
|
||||
:class="['hidden h-8 w-8 flex-none sm:block', iconClasses[type]]"
|
||||
/>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="font-semibold flex justify-between gap-4">
|
||||
<slot name="header">{{ header }}</slot>
|
||||
</div>
|
||||
<div class="font-normal">
|
||||
<slot>{{ body }}</slot>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-auto w-fit">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { InfoIcon, IssuesIcon, XCircleIcon } from '@modrinth/assets'
|
||||
|
||||
defineProps({
|
||||
type: {
|
||||
type: String as () => 'info' | 'warning' | 'critical',
|
||||
default: 'info',
|
||||
},
|
||||
header: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
body: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
type: {
|
||||
type: String as () => 'info' | 'warning' | 'critical',
|
||||
default: 'info',
|
||||
},
|
||||
header: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
body: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const typeClasses = {
|
||||
info: 'border-brand-blue bg-bg-blue',
|
||||
warning: 'border-brand-orange bg-bg-orange',
|
||||
critical: 'border-brand-red bg-bg-red',
|
||||
info: 'border-brand-blue bg-bg-blue',
|
||||
warning: 'border-brand-orange bg-bg-orange',
|
||||
critical: 'border-brand-red bg-bg-red',
|
||||
}
|
||||
|
||||
const iconClasses = {
|
||||
info: 'text-brand-blue',
|
||||
warning: 'text-brand-orange',
|
||||
critical: 'text-brand-red',
|
||||
info: 'text-brand-blue',
|
||||
warning: 'text-brand-orange',
|
||||
critical: 'text-brand-red',
|
||||
}
|
||||
|
||||
const icons = {
|
||||
info: InfoIcon,
|
||||
warning: IssuesIcon,
|
||||
critical: XCircleIcon,
|
||||
info: InfoIcon,
|
||||
warning: IssuesIcon,
|
||||
critical: XCircleIcon,
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
<template>
|
||||
<router-link v-if="to.path || to.query || to.startsWith('/')" :to="to" v-bind="$attrs">
|
||||
<slot />
|
||||
</router-link>
|
||||
<a v-else-if="to.startsWith('http')" :href="to" v-bind="$attrs">
|
||||
<slot />
|
||||
</a>
|
||||
<span v-else v-bind="$attrs">
|
||||
<slot />
|
||||
</span>
|
||||
<router-link v-if="to.path || to.query || to.startsWith('/')" :to="to" v-bind="$attrs">
|
||||
<slot />
|
||||
</router-link>
|
||||
<a v-else-if="to.startsWith('http')" :href="to" v-bind="$attrs">
|
||||
<slot />
|
||||
</a>
|
||||
<span v-else v-bind="$attrs">
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
to: any
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
to: any
|
||||
}>()
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
inheritAttrs: false,
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,47 +1,47 @@
|
||||
<template>
|
||||
<img
|
||||
v-if="src"
|
||||
ref="img"
|
||||
class="`experimental-styles-within avatar"
|
||||
:style="`--_size: ${cssSize}`"
|
||||
:class="{
|
||||
circle: circle,
|
||||
'no-shadow': noShadow,
|
||||
raised: raised,
|
||||
pixelated: pixelated,
|
||||
}"
|
||||
:src="src"
|
||||
:alt="alt"
|
||||
:loading="loading"
|
||||
@load="updatePixelated"
|
||||
/>
|
||||
<svg
|
||||
v-else
|
||||
class="`experimental-styles-within avatar"
|
||||
:style="`--_size: ${cssSize}${tint ? `;--_tint:oklch(50% 75% ${tint})` : ''}`"
|
||||
:class="{
|
||||
tint: tint,
|
||||
circle: circle,
|
||||
'no-shadow': noShadow,
|
||||
raised: raised,
|
||||
}"
|
||||
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>
|
||||
<img
|
||||
v-if="src"
|
||||
ref="img"
|
||||
class="`experimental-styles-within avatar"
|
||||
:style="`--_size: ${cssSize}`"
|
||||
:class="{
|
||||
circle: circle,
|
||||
'no-shadow': noShadow,
|
||||
raised: raised,
|
||||
pixelated: pixelated,
|
||||
}"
|
||||
:src="src"
|
||||
:alt="alt"
|
||||
:loading="loading"
|
||||
@load="updatePixelated"
|
||||
/>
|
||||
<svg
|
||||
v-else
|
||||
class="`experimental-styles-within avatar"
|
||||
:style="`--_size: ${cssSize}${tint ? `;--_tint:oklch(50% 75% ${tint})` : ''}`"
|
||||
:class="{
|
||||
tint: tint,
|
||||
circle: circle,
|
||||
'no-shadow': noShadow,
|
||||
raised: raised,
|
||||
}"
|
||||
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>
|
||||
@@ -51,110 +51,110 @@ const pixelated = ref(false)
|
||||
const img = ref(null)
|
||||
|
||||
const props = defineProps({
|
||||
src: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
alt: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: '2rem',
|
||||
},
|
||||
circle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
noShadow: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
loading: {
|
||||
type: String,
|
||||
default: 'eager',
|
||||
},
|
||||
raised: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
tintBy: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
src: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
alt: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: '2rem',
|
||||
},
|
||||
circle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
noShadow: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
loading: {
|
||||
type: String,
|
||||
default: 'eager',
|
||||
},
|
||||
raised: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
tintBy: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const LEGACY_PRESETS = {
|
||||
xxs: '1.25rem',
|
||||
xs: '2.5rem',
|
||||
sm: '3rem',
|
||||
md: '6rem',
|
||||
lg: '9rem',
|
||||
xxs: '1.25rem',
|
||||
xs: '2.5rem',
|
||||
sm: '3rem',
|
||||
md: '6rem',
|
||||
lg: '9rem',
|
||||
}
|
||||
|
||||
const cssSize = computed(() => LEGACY_PRESETS[props.size] ?? props.size)
|
||||
|
||||
function updatePixelated() {
|
||||
if (img.value && img.value.naturalWidth && img.value.naturalWidth < 32) {
|
||||
pixelated.value = true
|
||||
} else {
|
||||
pixelated.value = false
|
||||
}
|
||||
if (img.value && img.value.naturalWidth && img.value.naturalWidth < 32) {
|
||||
pixelated.value = true
|
||||
} else {
|
||||
pixelated.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const tint = computed(() => {
|
||||
if (props.tintBy) {
|
||||
return hash(props.tintBy) % 360
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
if (props.tintBy) {
|
||||
return hash(props.tintBy) % 360
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
function hash(str) {
|
||||
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
|
||||
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 {
|
||||
@apply min-w-[--_size] min-h-[--_size] w-[--_size] h-[--_size];
|
||||
--_size: 2rem;
|
||||
@apply min-w-[--_size] min-h-[--_size] w-[--_size] h-[--_size];
|
||||
--_size: 2rem;
|
||||
|
||||
border: 1px solid var(--color-button-border);
|
||||
background-color: var(--color-button-bg);
|
||||
object-fit: contain;
|
||||
border-radius: calc(16 / 96 * var(--_size));
|
||||
position: relative;
|
||||
border: 1px solid var(--color-button-border);
|
||||
background-color: var(--color-button-bg);
|
||||
object-fit: contain;
|
||||
border-radius: calc(16 / 96 * var(--_size));
|
||||
position: relative;
|
||||
|
||||
&.circle {
|
||||
border-radius: 50%;
|
||||
}
|
||||
&.circle {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&:not(.no-shadow) {
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
&:not(.no-shadow) {
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
&.no-shadow {
|
||||
box-shadow: none;
|
||||
}
|
||||
&.no-shadow {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
&.pixelated {
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
&.pixelated {
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
&.raised {
|
||||
background-color: var(--color-raised-bg);
|
||||
}
|
||||
&.raised {
|
||||
background-color: var(--color-raised-bg);
|
||||
}
|
||||
|
||||
&.tint {
|
||||
background-color: color-mix(in oklch, var(--color-button-bg) 100%, var(--_tint) 5%);
|
||||
}
|
||||
&.tint {
|
||||
background-color: color-mix(in oklch, var(--color-button-bg) 100%, var(--_tint) 5%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,247 +1,247 @@
|
||||
<template>
|
||||
<span :class="'version-badge ' + color + ' type--' + type">
|
||||
<template v-if="color"> <span class="circle" /> {{ capitalizeString(type) }}</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>
|
||||
<!-- 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'">
|
||||
<ListIcon 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'">
|
||||
<EyeOffIcon aria-hidden="true" /> {{ formatMessage(messages.unlistedLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'withheld'">
|
||||
<EyeOffIcon 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>
|
||||
<!-- Project statuses -->
|
||||
<template v-else-if="type === 'approved'">
|
||||
<ListIcon 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'">
|
||||
<EyeOffIcon aria-hidden="true" /> {{ formatMessage(messages.unlistedLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'withheld'">
|
||||
<EyeOffIcon 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>
|
||||
<!-- 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>
|
||||
<!-- 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>
|
||||
<!-- Report status -->
|
||||
<template v-else-if="type === 'closed'">
|
||||
<XIcon aria-hidden="true" /> {{ formatMessage(messages.closedLabel) }}
|
||||
</template>
|
||||
|
||||
<!-- Other -->
|
||||
<template v-else> <span class="circle" /> {{ capitalizeString(type) }} </template>
|
||||
</span>
|
||||
<!-- Other -->
|
||||
<template v-else> <span class="circle" /> {{ capitalizeString(type) }} </template>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ModrinthIcon,
|
||||
ScaleIcon,
|
||||
BoxIcon,
|
||||
ListIcon,
|
||||
EyeOffIcon,
|
||||
FileTextIcon,
|
||||
XIcon,
|
||||
ArchiveIcon,
|
||||
UpdatedIcon,
|
||||
CheckIcon,
|
||||
LockIcon,
|
||||
CalendarIcon,
|
||||
ArchiveIcon,
|
||||
BoxIcon,
|
||||
CalendarIcon,
|
||||
CheckIcon,
|
||||
EyeOffIcon,
|
||||
FileTextIcon,
|
||||
ListIcon,
|
||||
LockIcon,
|
||||
ModrinthIcon,
|
||||
ScaleIcon,
|
||||
UpdatedIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { capitalizeString } from '@modrinth/utils'
|
||||
import { useVIntl, defineMessages } from '@vintl/vintl'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
|
||||
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: 'Listed',
|
||||
},
|
||||
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',
|
||||
},
|
||||
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',
|
||||
},
|
||||
withheldLabel: {
|
||||
id: 'omorphia.component.badge.label.withheld',
|
||||
defaultMessage: 'Withheld',
|
||||
},
|
||||
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: 'Listed',
|
||||
},
|
||||
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',
|
||||
},
|
||||
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',
|
||||
},
|
||||
withheldLabel: {
|
||||
id: 'omorphia.component.badge.label.withheld',
|
||||
defaultMessage: 'Withheld',
|
||||
},
|
||||
})
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
defineProps<{
|
||||
type: string
|
||||
color?: string
|
||||
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);
|
||||
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);
|
||||
}
|
||||
.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;
|
||||
}
|
||||
svg {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
&.type--closed,
|
||||
&.type--withheld,
|
||||
&.type--rejected,
|
||||
&.type--returned,
|
||||
&.type--failed,
|
||||
&.red {
|
||||
--badge-color: var(--color-red);
|
||||
}
|
||||
&.type--closed,
|
||||
&.type--withheld,
|
||||
&.type--rejected,
|
||||
&.type--returned,
|
||||
&.type--failed,
|
||||
&.red {
|
||||
--badge-color: var(--color-red);
|
||||
}
|
||||
|
||||
&.type--pending,
|
||||
&.type--moderator,
|
||||
&.type--processing,
|
||||
&.type--scheduled,
|
||||
&.orange {
|
||||
--badge-color: var(--color-orange);
|
||||
}
|
||||
&.type--pending,
|
||||
&.type--moderator,
|
||||
&.type--processing,
|
||||
&.type--scheduled,
|
||||
&.orange {
|
||||
--badge-color: var(--color-orange);
|
||||
}
|
||||
|
||||
&.type--accepted,
|
||||
&.type--admin,
|
||||
&.type--processed,
|
||||
&.type--approved-general,
|
||||
&.green {
|
||||
--badge-color: var(--color-green);
|
||||
}
|
||||
&.type--accepted,
|
||||
&.type--admin,
|
||||
&.type--processed,
|
||||
&.type--approved-general,
|
||||
&.green {
|
||||
--badge-color: var(--color-green);
|
||||
}
|
||||
|
||||
&.type--creator,
|
||||
&.type--approved,
|
||||
&.blue {
|
||||
--badge-color: var(--color-blue);
|
||||
}
|
||||
&.type--creator,
|
||||
&.type--approved,
|
||||
&.blue {
|
||||
--badge-color: var(--color-blue);
|
||||
}
|
||||
|
||||
&.type--unlisted,
|
||||
&.purple {
|
||||
--badge-color: var(--color-purple);
|
||||
}
|
||||
&.type--unlisted,
|
||||
&.purple {
|
||||
--badge-color: var(--color-purple);
|
||||
}
|
||||
|
||||
&.type--private,
|
||||
&.gray {
|
||||
--badge-color: var(--color-gray);
|
||||
}
|
||||
&.type--private,
|
||||
&.gray {
|
||||
--badge-color: var(--color-gray);
|
||||
}
|
||||
|
||||
&::first-letter {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
&::first-letter {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,122 +3,122 @@ import { ExternalIcon, UnknownIcon } from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
link: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
external: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
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,
|
||||
},
|
||||
link: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
external: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
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,
|
||||
},
|
||||
})
|
||||
|
||||
const accentedButton = computed(() =>
|
||||
['danger', 'primary', 'red', 'orange', 'green', 'blue', 'purple', 'gray'].includes(props.color),
|
||||
['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,
|
||||
}
|
||||
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,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-link
|
||||
v-if="link && link.startsWith('/')"
|
||||
class="btn"
|
||||
:class="classes"
|
||||
:to="link"
|
||||
:target="external ? '_blank' : '_self'"
|
||||
@click="
|
||||
(event) => {
|
||||
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="link"
|
||||
:target="external ? '_blank' : '_self'"
|
||||
@click="
|
||||
(event) => {
|
||||
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" @click="action">
|
||||
<slot />
|
||||
<UnknownIcon v-if="!$slots.default" />
|
||||
</button>
|
||||
<router-link
|
||||
v-if="link && link.startsWith('/')"
|
||||
class="btn"
|
||||
:class="classes"
|
||||
:to="link"
|
||||
:target="external ? '_blank' : '_self'"
|
||||
@click="
|
||||
(event) => {
|
||||
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="link"
|
||||
:target="external ? '_blank' : '_self'"
|
||||
@click="
|
||||
(event) => {
|
||||
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" @click="action">
|
||||
<slot />
|
||||
<UnknownIcon v-if="!$slots.default" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:where(button) {
|
||||
background: none;
|
||||
color: var(--color-base);
|
||||
background: none;
|
||||
color: var(--color-base);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,230 +2,230 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
color?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
|
||||
size?: 'standard' | 'large' | 'small'
|
||||
circular?: boolean
|
||||
type?: 'standard' | 'outlined' | 'transparent' | 'highlight' | 'highlight-colored-text'
|
||||
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,
|
||||
},
|
||||
defineProps<{
|
||||
color?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
|
||||
size?: 'standard' | 'large' | 'small'
|
||||
circular?: boolean
|
||||
type?: 'standard' | 'outlined' | 'transparent' | 'highlight' | 'highlight-colored-text'
|
||||
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 'blue':
|
||||
return 'var(--color-blue-highlight)'
|
||||
case 'purple':
|
||||
return 'var(--color-purple-highlight)'
|
||||
case 'standard':
|
||||
default:
|
||||
return null
|
||||
}
|
||||
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 '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 'standard':
|
||||
default:
|
||||
return null
|
||||
}
|
||||
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 'standard':
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const height = computed(() => {
|
||||
if (props.size === 'large') {
|
||||
return '3rem'
|
||||
} else if (props.size === 'small') {
|
||||
return '1.5rem'
|
||||
}
|
||||
return '2.25rem'
|
||||
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'
|
||||
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)`
|
||||
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'
|
||||
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'
|
||||
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'
|
||||
if (props.size === 'large') {
|
||||
return '800'
|
||||
}
|
||||
return '600'
|
||||
})
|
||||
|
||||
const radius = computed(() => {
|
||||
if (props.circular) {
|
||||
return '99999px'
|
||||
}
|
||||
if (props.circular) {
|
||||
return '99999px'
|
||||
}
|
||||
|
||||
if (props.size === 'large') {
|
||||
return '1rem'
|
||||
} else if (props.size === 'small') {
|
||||
return '0.5rem'
|
||||
}
|
||||
return '0.75rem'
|
||||
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'
|
||||
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',
|
||||
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
|
||||
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(() => {
|
||||
if (props.highlighted) {
|
||||
const colors = {
|
||||
bg:
|
||||
props.highlightedStyle === 'main-nav-primary'
|
||||
? 'var(--color-brand-highlight)'
|
||||
: 'var(--color-button-bg)',
|
||||
text: 'var(--color-contrast)',
|
||||
icon:
|
||||
props.highlightedStyle === 'main-nav-primary'
|
||||
? 'var(--color-brand)'
|
||||
: 'var(--color-contrast)',
|
||||
}
|
||||
const hoverColors = JSON.parse(JSON.stringify(colors))
|
||||
return `--_bg: ${colors.bg}; --_text: ${colors.text}; --_icon: ${colors.icon}; --_hover-bg: ${hoverColors.bg}; --_hover-text: ${hoverColors.text}; --_hover-icon: ${hoverColors.icon};`
|
||||
}
|
||||
if (props.highlighted) {
|
||||
const colors = {
|
||||
bg:
|
||||
props.highlightedStyle === 'main-nav-primary'
|
||||
? 'var(--color-brand-highlight)'
|
||||
: 'var(--color-button-bg)',
|
||||
text: 'var(--color-contrast)',
|
||||
icon:
|
||||
props.highlightedStyle === 'main-nav-primary'
|
||||
? 'var(--color-brand)'
|
||||
: 'var(--color-contrast)',
|
||||
}
|
||||
const hoverColors = JSON.parse(JSON.stringify(colors))
|
||||
return `--_bg: ${colors.bg}; --_text: ${colors.text}; --_icon: ${colors.icon}; --_hover-bg: ${hoverColors.bg}; --_hover-text: ${hoverColors.text}; --_hover-icon: ${hoverColors.icon};`
|
||||
}
|
||||
|
||||
let colors = {
|
||||
bg: 'var(--color-button-bg)',
|
||||
text: 'var(--color-base)',
|
||||
}
|
||||
let hoverColors = JSON.parse(JSON.stringify(colors))
|
||||
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') {
|
||||
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 {
|
||||
colors = setColorFill(colors, props.colorFill === 'auto' ? 'background' : props.colorFill)
|
||||
hoverColors = setColorFill(
|
||||
hoverColors,
|
||||
props.hoverColorFill === 'auto' ? 'background' : props.hoverColorFill,
|
||||
)
|
||||
}
|
||||
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 {
|
||||
colors = setColorFill(colors, props.colorFill === 'auto' ? 'background' : props.colorFill)
|
||||
hoverColors = setColorFill(
|
||||
hoverColors,
|
||||
props.hoverColorFill === 'auto' ? 'background' : props.hoverColorFill,
|
||||
)
|
||||
}
|
||||
|
||||
return `--_bg: ${colors.bg}; --_text: ${colors.text}; --_hover-bg: ${hoverColors.bg}; --_hover-text: ${hoverColors.text};`
|
||||
return `--_bg: ${colors.bg}; --_text: ${colors.text}; --_hover-bg: ${hoverColors.bg}; --_hover-text: ${hoverColors.text};`
|
||||
})
|
||||
|
||||
const fontSize = computed(() => {
|
||||
if (props.size === 'small') {
|
||||
return 'text-sm'
|
||||
}
|
||||
return 'text-base'
|
||||
if (props.size === 'small') {
|
||||
return 'text-sm'
|
||||
}
|
||||
return 'text-base'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="btn-wrapper"
|
||||
:class="[{ outline: type === 'outlined' }, fontSize]"
|
||||
:style="`${colorVariables}--_height:${height};--_width:${width};--_radius: ${radius};--_padding-x:${paddingX};--_padding-y:${paddingY};--_gap:${gap};--_font-weight:${fontWeight};--_icon-size:${iconSize};`"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
<div
|
||||
class="btn-wrapper"
|
||||
:class="[{ outline: type === 'outlined' }, fontSize]"
|
||||
:style="`${colorVariables}--_height:${height};--_width:${width};--_radius: ${radius};--_padding-x:${paddingX};--_padding-y:${paddingY};--_gap:${gap};--_font-weight:${fontWeight};--_icon-size:${iconSize};`"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.btn-wrapper {
|
||||
display: contents;
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* Searches up to 4 children deep for valid button */
|
||||
@@ -234,43 +234,43 @@ const fontSize = computed(() => {
|
||||
.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 cursor-pointer flex-row items-center justify-center border-solid border-2 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;
|
||||
transition:
|
||||
scale 0.125s ease-in-out,
|
||||
background-color 0.25s ease-in-out,
|
||||
color 0.25s ease-in-out;
|
||||
:slotted(*)
|
||||
> *:first-child
|
||||
> *:first-child
|
||||
> :is(button, a, .button-like):first-child {
|
||||
@apply flex cursor-pointer flex-row items-center justify-center border-solid border-2 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;
|
||||
transition:
|
||||
scale 0.125s ease-in-out,
|
||||
background-color 0.25s ease-in-out,
|
||||
color 0.25s ease-in-out;
|
||||
|
||||
svg:first-child {
|
||||
color: var(--_icon, var(--_text));
|
||||
transition: color 0.25s ease-in-out;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
svg:first-child {
|
||||
color: var(--_icon, var(--_text));
|
||||
transition: color 0.25s ease-in-out;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&[disabled],
|
||||
&[disabled='true'],
|
||||
&.disabled,
|
||||
&.looks-disabled {
|
||||
@apply opacity-50;
|
||||
}
|
||||
&[disabled],
|
||||
&[disabled='true'],
|
||||
&.disabled,
|
||||
&.looks-disabled {
|
||||
@apply opacity-50;
|
||||
}
|
||||
|
||||
&[disabled],
|
||||
&[disabled='true'],
|
||||
&.disabled {
|
||||
@apply cursor-not-allowed;
|
||||
}
|
||||
&[disabled],
|
||||
&[disabled='true'],
|
||||
&.disabled {
|
||||
@apply cursor-not-allowed;
|
||||
}
|
||||
|
||||
&:not([disabled]):not([disabled='true']):not(.disabled) {
|
||||
@apply active:scale-95 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];
|
||||
&:not([disabled]):not([disabled='true']):not(.disabled) {
|
||||
@apply active:scale-95 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));
|
||||
}
|
||||
}
|
||||
&:hover svg:first-child,
|
||||
&:focus-visible svg:first-child {
|
||||
color: var(--_hover-icon, var(--_hover-text));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-wrapper.outline :deep(:is(button, a, .button-like):first-child),
|
||||
@@ -278,11 +278,11 @@ const fontSize = computed(() => {
|
||||
.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-current;
|
||||
:slotted(*)
|
||||
> *:first-child
|
||||
> *:first-child
|
||||
> :is(button, a, .button-like):first-child {
|
||||
@apply border-current;
|
||||
}
|
||||
|
||||
/*noinspection CssUnresolvedCustomProperty*/
|
||||
@@ -290,45 +290,45 @@ const fontSize = computed(() => {
|
||||
.btn-wrapper :slotted(:is(button, a, .button-like):first-child) > svg:first-child,
|
||||
.btn-wrapper :slotted(*) > :is(button, a, .button-like):first-child > svg:first-child,
|
||||
.btn-wrapper
|
||||
:slotted(*)
|
||||
> *:first-child
|
||||
> :is(button, a, .button-like):first-child
|
||||
> svg:first-child,
|
||||
:slotted(*)
|
||||
> *:first-child
|
||||
> :is(button, a, .button-like):first-child
|
||||
> svg:first-child,
|
||||
.btn-wrapper
|
||||
:slotted(*)
|
||||
> *:first-child
|
||||
> *:first-child
|
||||
> :is(button, a, .button-like):first-child
|
||||
> svg:first-child {
|
||||
min-width: var(--_icon-size, 1rem);
|
||||
min-height: var(--_icon-size, 1rem);
|
||||
:slotted(*)
|
||||
> *:first-child
|
||||
> *:first-child
|
||||
> :is(button, a, .button-like):first-child
|
||||
> svg:first-child {
|
||||
min-width: var(--_icon-size, 1rem);
|
||||
min-height: var(--_icon-size, 1rem);
|
||||
}
|
||||
|
||||
.joined-buttons {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
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;
|
||||
}
|
||||
}
|
||||
> .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;
|
||||
}
|
||||
}
|
||||
> :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 */
|
||||
|
||||
@@ -1,61 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { DropdownIcon } from '@modrinth/assets'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
import Button from './Button.vue'
|
||||
|
||||
const props = defineProps({
|
||||
collapsible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
defaultCollapsed: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
noAutoBody: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
collapsible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
defaultCollapsed: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
noAutoBody: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const state = reactive({
|
||||
collapsed: props.defaultCollapsed,
|
||||
collapsed: props.defaultCollapsed,
|
||||
})
|
||||
|
||||
function toggleCollapsed() {
|
||||
state.collapsed = !state.collapsed
|
||||
state.collapsed = !state.collapsed
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<div v-if="!!$slots.header || collapsible" class="header">
|
||||
<slot name="header"></slot>
|
||||
<div v-if="collapsible" class="btn-group">
|
||||
<Button :action="toggleCollapsed">
|
||||
<DropdownIcon :style="{ transform: `rotate(${state.collapsed ? 0 : 180}deg)` }" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<slot v-if="!state.collapsed" />
|
||||
</div>
|
||||
<div class="card">
|
||||
<div v-if="!!$slots.header || collapsible" class="header">
|
||||
<slot name="header"></slot>
|
||||
<div v-if="collapsible" class="btn-group">
|
||||
<Button :action="toggleCollapsed">
|
||||
<DropdownIcon :style="{ transform: `rotate(${state.collapsed ? 0 : 180}deg)` }" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<slot v-if="!state.collapsed" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.header {
|
||||
display: flex;
|
||||
display: flex;
|
||||
|
||||
:deep(h1, h2, h3, h4) {
|
||||
margin-block: 0;
|
||||
}
|
||||
:deep(h1, h2, h3, h4) {
|
||||
margin-block: 0;
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: var(--gap-lg);
|
||||
}
|
||||
&:not(:last-child) {
|
||||
margin-bottom: var(--gap-lg);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
margin-left: auto;
|
||||
margin-right: 0;
|
||||
margin-left: auto;
|
||||
margin-right: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,145 +1,145 @@
|
||||
<template>
|
||||
<div
|
||||
class="checkbox-outer button-within"
|
||||
:class="{ disabled }"
|
||||
role="presentation"
|
||||
@click="toggle"
|
||||
>
|
||||
<button
|
||||
class="checkbox border-none"
|
||||
role="checkbox"
|
||||
:disabled="disabled"
|
||||
:class="{ checked: modelValue, collapsing: collapsingToggleStyle }"
|
||||
:aria-label="description"
|
||||
:aria-checked="modelValue"
|
||||
>
|
||||
<MinusIcon v-if="indeterminate" aria-hidden="true" />
|
||||
<CheckIcon v-else-if="modelValue && !collapsingToggleStyle" aria-hidden="true" />
|
||||
<DropdownIcon v-else-if="collapsingToggleStyle" aria-hidden="true" />
|
||||
</button>
|
||||
<!-- aria-hidden is set so screenreaders only use the <button>'s aria-label -->
|
||||
<p v-if="label" aria-hidden="true" class="checkbox-label">
|
||||
{{ label }}
|
||||
</p>
|
||||
<slot v-else />
|
||||
</div>
|
||||
<div
|
||||
class="checkbox-outer button-within"
|
||||
:class="{ disabled }"
|
||||
role="presentation"
|
||||
@click="toggle"
|
||||
>
|
||||
<button
|
||||
class="checkbox border-none"
|
||||
role="checkbox"
|
||||
:disabled="disabled"
|
||||
:class="{ checked: modelValue, collapsing: collapsingToggleStyle }"
|
||||
:aria-label="description"
|
||||
:aria-checked="modelValue"
|
||||
>
|
||||
<MinusIcon v-if="indeterminate" aria-hidden="true" />
|
||||
<CheckIcon v-else-if="modelValue && !collapsingToggleStyle" aria-hidden="true" />
|
||||
<DropdownIcon v-else-if="collapsingToggleStyle" aria-hidden="true" />
|
||||
</button>
|
||||
<!-- aria-hidden is set so screenreaders only use the <button>'s aria-label -->
|
||||
<p v-if="label" aria-hidden="true" class="checkbox-label">
|
||||
{{ label }}
|
||||
</p>
|
||||
<slot v-else />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, DropdownIcon, MinusIcon } from '@modrinth/assets'
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [boolean]
|
||||
'update:modelValue': [boolean]
|
||||
}>()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
description?: string
|
||||
modelValue: boolean
|
||||
clickEvent?: () => void
|
||||
collapsingToggleStyle?: boolean
|
||||
indeterminate?: boolean
|
||||
}>(),
|
||||
{
|
||||
label: '',
|
||||
disabled: false,
|
||||
description: '',
|
||||
modelValue: false,
|
||||
clickEvent: () => {},
|
||||
collapsingToggleStyle: false,
|
||||
indeterminate: false,
|
||||
},
|
||||
defineProps<{
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
description?: string
|
||||
modelValue: boolean
|
||||
clickEvent?: () => void
|
||||
collapsingToggleStyle?: boolean
|
||||
indeterminate?: boolean
|
||||
}>(),
|
||||
{
|
||||
label: '',
|
||||
disabled: false,
|
||||
description: '',
|
||||
modelValue: false,
|
||||
clickEvent: () => {},
|
||||
collapsingToggleStyle: false,
|
||||
indeterminate: false,
|
||||
},
|
||||
)
|
||||
|
||||
function toggle() {
|
||||
if (!props.disabled) {
|
||||
emit('update:modelValue', !props.modelValue)
|
||||
}
|
||||
if (!props.disabled) {
|
||||
emit('update:modelValue', !props.modelValue)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.checkbox-outer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
|
||||
p {
|
||||
user-select: none;
|
||||
padding: 0.2rem 0;
|
||||
margin: 0;
|
||||
}
|
||||
p {
|
||||
user-select: none;
|
||||
padding: 0.2rem 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
&.disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
|
||||
min-width: 1rem;
|
||||
min-height: 1rem;
|
||||
min-width: 1rem;
|
||||
min-height: 1rem;
|
||||
|
||||
padding: 0;
|
||||
margin: 0 0.5rem 0 0;
|
||||
padding: 0;
|
||||
margin: 0 0.5rem 0 0;
|
||||
|
||||
color: var(--color-contrast);
|
||||
background-color: var(--color-button-bg);
|
||||
border-radius: var(--radius-xs);
|
||||
box-shadow:
|
||||
var(--shadow-inset-sm),
|
||||
0 0 0 0 transparent;
|
||||
color: var(--color-contrast);
|
||||
background-color: var(--color-button-bg);
|
||||
border-radius: var(--radius-xs);
|
||||
box-shadow:
|
||||
var(--shadow-inset-sm),
|
||||
0 0 0 0 transparent;
|
||||
|
||||
&.checked {
|
||||
background-color: var(--color-brand);
|
||||
&.checked {
|
||||
background-color: var(--color-brand);
|
||||
|
||||
svg {
|
||||
color: var(--color-accent-contrast);
|
||||
}
|
||||
}
|
||||
svg {
|
||||
color: var(--color-accent-contrast);
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
color: var(--color-secondary);
|
||||
stroke-width: 0.2rem;
|
||||
height: 0.8rem;
|
||||
width: 0.8rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
svg {
|
||||
color: var(--color-secondary);
|
||||
stroke-width: 0.2rem;
|
||||
height: 0.8rem;
|
||||
width: 0.8rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&.collapsing {
|
||||
background-color: transparent !important;
|
||||
box-shadow: none;
|
||||
&.collapsing {
|
||||
background-color: transparent !important;
|
||||
box-shadow: none;
|
||||
|
||||
svg {
|
||||
color: inherit;
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
transition: transform 0.25s ease-in-out;
|
||||
svg {
|
||||
color: inherit;
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
transition: transform 0.25s ease-in-out;
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion) {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.checked {
|
||||
svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
&.checked {
|
||||
svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
box-shadow: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
&:disabled {
|
||||
box-shadow: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
color: var(--color-base);
|
||||
color: var(--color-base);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,80 +1,81 @@
|
||||
<template>
|
||||
<div class="chips">
|
||||
<Button
|
||||
v-for="item in items"
|
||||
:key="formatLabel(item)"
|
||||
class="btn"
|
||||
:class="{ selected: selected === item, capitalize: capitalize }"
|
||||
@click="toggleItem(item)"
|
||||
>
|
||||
<CheckIcon v-if="selected === item" />
|
||||
<span>{{ formatLabel(item) }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="chips">
|
||||
<Button
|
||||
v-for="item in items"
|
||||
:key="formatLabel(item)"
|
||||
class="btn"
|
||||
:class="{ selected: selected === item, capitalize: capitalize }"
|
||||
@click="toggleItem(item)"
|
||||
>
|
||||
<CheckIcon v-if="selected === item" />
|
||||
<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
|
||||
}>(),
|
||||
{
|
||||
neverEmpty: true,
|
||||
// Intentional any type, as this default should only be used for primitives (string or number)
|
||||
formatLabel: (item) => item.toString(),
|
||||
capitalize: true,
|
||||
},
|
||||
defineProps<{
|
||||
items: T[]
|
||||
formatLabel?: (item: T) => string
|
||||
neverEmpty?: boolean
|
||||
capitalize?: boolean
|
||||
}>(),
|
||||
{
|
||||
neverEmpty: true,
|
||||
// Intentional any type, as this default should only be used for primitives (string or number)
|
||||
formatLabel: (item) => item.toString(),
|
||||
capitalize: true,
|
||||
},
|
||||
)
|
||||
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]
|
||||
selected.value = props.items[0]
|
||||
}
|
||||
|
||||
function toggleItem(item: T) {
|
||||
if (selected.value === item && !props.neverEmpty) {
|
||||
selected.value = null
|
||||
} else {
|
||||
selected.value = item
|
||||
}
|
||||
if (selected.value === item && !props.neverEmpty) {
|
||||
selected.value = null
|
||||
} else {
|
||||
selected.value = item
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.chips {
|
||||
display: flex;
|
||||
grid-gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
display: flex;
|
||||
grid-gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.btn {
|
||||
&.capitalize {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.btn {
|
||||
&.capitalize {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
svg {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 0.25rem solid #ea80ff;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 0.25rem solid #ea80ff;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.selected {
|
||||
color: var(--color-contrast);
|
||||
background-color: var(--color-brand-highlight);
|
||||
box-shadow:
|
||||
inset 0 0 0 transparent,
|
||||
0 0 0 2px var(--color-brand);
|
||||
}
|
||||
.selected {
|
||||
color: var(--color-contrast);
|
||||
background-color: var(--color-brand-highlight);
|
||||
box-shadow:
|
||||
inset 0 0 0 transparent,
|
||||
0 0 0 2px var(--color-brand);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
<template>
|
||||
<div class="accordion-content" :class="(baseClass ?? ``) + (collapsed ? `` : ` open`)">
|
||||
<div v-bind="$attrs" :inert="collapsed">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
<div class="accordion-content" :class="(baseClass ?? ``) + (collapsed ? `` : ` open`)">
|
||||
<div v-bind="$attrs" :inert="collapsed">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
baseClass?: string
|
||||
collapsed: boolean
|
||||
baseClass?: string
|
||||
collapsed: boolean
|
||||
}>()
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
inheritAttrs: false,
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.accordion-content {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 0.3s ease-in-out;
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
.accordion-content {
|
||||
transition: none !important;
|
||||
}
|
||||
.accordion-content {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.accordion-content.open {
|
||||
grid-template-rows: 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.accordion-content > div {
|
||||
overflow: hidden;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,97 +1,98 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative overflow-hidden rounded-xl border-[2px] border-solid border-divider shadow-lg"
|
||||
:class="{ 'max-h-32': isCollapsed }"
|
||||
>
|
||||
<div
|
||||
class="px-4 pt-4"
|
||||
:class="{
|
||||
'content-disabled pb-16': isCollapsed,
|
||||
'pb-4': !isCollapsed,
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
<div
|
||||
class="relative overflow-hidden rounded-xl border-[2px] border-solid border-divider shadow-lg"
|
||||
:class="{ 'max-h-32': isCollapsed }"
|
||||
>
|
||||
<div
|
||||
class="px-4 pt-4"
|
||||
:class="{
|
||||
'content-disabled pb-16': isCollapsed,
|
||||
'pb-4': !isCollapsed,
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isCollapsed"
|
||||
class="pointer-events-none absolute inset-0 bg-gradient-to-b from-transparent to-button-bg"
|
||||
></div>
|
||||
<div
|
||||
v-if="isCollapsed"
|
||||
class="pointer-events-none absolute inset-0 bg-gradient-to-b from-transparent to-button-bg"
|
||||
></div>
|
||||
|
||||
<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="toggleCollapsed">
|
||||
<ExpandIcon v-if="isCollapsed" />
|
||||
<CollapseIcon v-else />
|
||||
{{ isCollapsed ? expandText : collapseText }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<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="toggleCollapsed">
|
||||
<ExpandIcon v-if="isCollapsed" />
|
||||
<CollapseIcon v-else />
|
||||
{{ isCollapsed ? expandText : collapseText }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ButtonStyled from './ButtonStyled.vue'
|
||||
import { ExpandIcon, CollapseIcon } from '@modrinth/assets'
|
||||
import { CollapseIcon, ExpandIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import ButtonStyled from './ButtonStyled.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
initiallyCollapsed?: boolean
|
||||
expandText?: string
|
||||
collapseText?: string
|
||||
}>(),
|
||||
{
|
||||
initiallyCollapsed: true,
|
||||
expandText: 'Expand',
|
||||
collapseText: 'Collapse',
|
||||
},
|
||||
defineProps<{
|
||||
initiallyCollapsed?: boolean
|
||||
expandText?: string
|
||||
collapseText?: string
|
||||
}>(),
|
||||
{
|
||||
initiallyCollapsed: true,
|
||||
expandText: 'Expand',
|
||||
collapseText: 'Collapse',
|
||||
},
|
||||
)
|
||||
|
||||
const isCollapsed = ref(props.initiallyCollapsed)
|
||||
|
||||
function toggleCollapsed() {
|
||||
isCollapsed.value = !isCollapsed.value
|
||||
isCollapsed.value = !isCollapsed.value
|
||||
}
|
||||
|
||||
function setCollapsed(value: boolean) {
|
||||
isCollapsed.value = value
|
||||
isCollapsed.value = value
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
isCollapsed,
|
||||
setCollapsed,
|
||||
toggleCollapsed,
|
||||
isCollapsed,
|
||||
setCollapsed,
|
||||
toggleCollapsed,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.content-disabled {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
|
||||
:deep(*) {
|
||||
pointer-events: none !important;
|
||||
user-select: none !important;
|
||||
-webkit-user-select: none !important;
|
||||
-moz-user-select: none !important;
|
||||
-ms-user-select: none !important;
|
||||
}
|
||||
:deep(*) {
|
||||
pointer-events: none !important;
|
||||
user-select: none !important;
|
||||
-webkit-user-select: none !important;
|
||||
-moz-user-select: none !important;
|
||||
-ms-user-select: none !important;
|
||||
}
|
||||
|
||||
:deep(button),
|
||||
:deep(input),
|
||||
:deep(textarea),
|
||||
:deep(select),
|
||||
:deep(a),
|
||||
:deep([tabindex]) {
|
||||
tabindex: -1 !important;
|
||||
}
|
||||
:deep(button),
|
||||
:deep(input),
|
||||
:deep(textarea),
|
||||
:deep(select),
|
||||
:deep(a),
|
||||
:deep([tabindex]) {
|
||||
tabindex: -1 !important;
|
||||
}
|
||||
|
||||
:deep(*:focus) {
|
||||
outline: none !important;
|
||||
}
|
||||
:deep(*:focus) {
|
||||
outline: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
<template>
|
||||
<div
|
||||
class="grid grid-cols-1 gap-x-8 gap-y-6 border-0 border-b border-solid border-divider pb-4 lg:grid-cols-[1fr_auto]"
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<slot name="icon" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h1 class="m-0 text-2xl font-extrabold leading-none text-contrast">
|
||||
<slot name="title" />
|
||||
</h1>
|
||||
<slot name="title-suffix" />
|
||||
</div>
|
||||
<p v-if="$slots.summary" class="m-0 line-clamp-2 max-w-[40rem] empty:hidden">
|
||||
<slot name="summary" />
|
||||
</p>
|
||||
<div v-if="$slots.stats" class="mt-auto flex flex-wrap gap-4 empty:hidden">
|
||||
<slot name="stats" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 items-center">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="grid grid-cols-1 gap-x-8 gap-y-6 border-0 border-b border-solid border-divider pb-4 lg:grid-cols-[1fr_auto]"
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<slot name="icon" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h1 class="m-0 text-2xl font-extrabold leading-none text-contrast">
|
||||
<slot name="title" />
|
||||
</h1>
|
||||
<slot name="title-suffix" />
|
||||
</div>
|
||||
<p v-if="$slots.summary" class="m-0 line-clamp-2 max-w-[40rem] empty:hidden">
|
||||
<slot name="summary" />
|
||||
</p>
|
||||
<div v-if="$slots.stats" class="mt-auto flex flex-wrap gap-4 empty:hidden">
|
||||
<slot name="stats" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 items-center">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
<template>
|
||||
<button class="code" :class="{ copied }" :title="formatMessage(copiedMessage)" @click="copyText">
|
||||
<span>{{ text }}</span>
|
||||
<CheckIcon v-if="copied" />
|
||||
<ClipboardCopyIcon v-else />
|
||||
</button>
|
||||
<button class="code" :class="{ copied }" :title="formatMessage(copiedMessage)" @click="copyText">
|
||||
<span>{{ text }}</span>
|
||||
<CheckIcon v-if="copied" />
|
||||
<ClipboardCopyIcon v-else />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useVIntl, defineMessage } from '@vintl/vintl'
|
||||
import { CheckIcon, ClipboardCopyIcon } from '@modrinth/assets'
|
||||
import { defineMessage, useVIntl } from '@vintl/vintl'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const copiedMessage = defineMessage({
|
||||
id: 'omorphia.component.copy.action.copy',
|
||||
defaultMessage: 'Copy code to clipboard',
|
||||
id: 'omorphia.component.copy.action.copy',
|
||||
defaultMessage: 'Copy code to clipboard',
|
||||
})
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
@@ -22,46 +22,46 @@ const props = defineProps<{ text: string }>()
|
||||
const copied = ref(false)
|
||||
|
||||
async function copyText() {
|
||||
await navigator.clipboard.writeText(props.text)
|
||||
copied.value = true
|
||||
await navigator.clipboard.writeText(props.text)
|
||||
copied.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.code {
|
||||
color: var(--color-text);
|
||||
display: inline-flex;
|
||||
grid-gap: 0.5rem;
|
||||
font-family: var(--mono-font);
|
||||
font-size: var(--font-size-sm);
|
||||
margin: 0;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background-color: var(--color-button-bg);
|
||||
width: fit-content;
|
||||
border-radius: 10px;
|
||||
user-select: text;
|
||||
transition:
|
||||
opacity 0.5s ease-in-out,
|
||||
filter 0.2s ease-in-out,
|
||||
transform 0.05s ease-in-out,
|
||||
outline 0.2s ease-in-out;
|
||||
color: var(--color-text);
|
||||
display: inline-flex;
|
||||
grid-gap: 0.5rem;
|
||||
font-family: var(--mono-font);
|
||||
font-size: var(--font-size-sm);
|
||||
margin: 0;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background-color: var(--color-button-bg);
|
||||
width: fit-content;
|
||||
border-radius: 10px;
|
||||
user-select: text;
|
||||
transition:
|
||||
opacity 0.5s ease-in-out,
|
||||
filter 0.2s ease-in-out,
|
||||
transform 0.05s ease-in-out,
|
||||
outline 0.2s ease-in-out;
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
transition: none !important;
|
||||
}
|
||||
@media (prefers-reduced-motion) {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
svg {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
filter: brightness(0.85);
|
||||
}
|
||||
&:hover {
|
||||
filter: brightness(0.85);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
filter: brightness(0.8);
|
||||
}
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
filter: brightness(0.8);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
<template>
|
||||
<div class="double-icon">
|
||||
<slot name="primary" />
|
||||
<div class="secondary">
|
||||
<slot name="secondary" />
|
||||
</div>
|
||||
</div>
|
||||
<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;
|
||||
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;
|
||||
.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;
|
||||
}
|
||||
}
|
||||
svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
<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 />
|
||||
<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 { ref, onMounted } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
accept: string
|
||||
}>(),
|
||||
{
|
||||
accept: '*',
|
||||
},
|
||||
defineProps<{
|
||||
accept: string
|
||||
}>(),
|
||||
{
|
||||
accept: '*',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits(['change'])
|
||||
@@ -30,71 +30,71 @@ const dropAreaRef = ref<HTMLDivElement>()
|
||||
const fileAllowed = ref(false)
|
||||
|
||||
const hideDropArea = () => {
|
||||
if (dropAreaRef.value) {
|
||||
dropAreaRef.value.style.visibility = 'hidden'
|
||||
}
|
||||
if (dropAreaRef.value) {
|
||||
dropAreaRef.value.style.visibility = 'hidden'
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
hideDropArea()
|
||||
if (event.dataTransfer && event.dataTransfer.files && fileAllowed.value) {
|
||||
emit('change', event.dataTransfer.files)
|
||||
}
|
||||
hideDropArea()
|
||||
if (event.dataTransfer && event.dataTransfer.files && fileAllowed.value) {
|
||||
emit('change', event.dataTransfer.files)
|
||||
}
|
||||
}
|
||||
|
||||
const allowDrag = (event: DragEvent) => {
|
||||
const file = event.dataTransfer?.items[0]
|
||||
if (
|
||||
file &&
|
||||
props.accept
|
||||
.split(',')
|
||||
.reduce((acc, t) => acc || file.type.startsWith(t) || file.type === t || t === '*', false)
|
||||
) {
|
||||
fileAllowed.value = true
|
||||
event.dataTransfer.dropEffect = 'copy'
|
||||
event.preventDefault()
|
||||
if (dropAreaRef.value) {
|
||||
dropAreaRef.value.style.visibility = 'visible'
|
||||
}
|
||||
} else {
|
||||
fileAllowed.value = false
|
||||
hideDropArea()
|
||||
}
|
||||
const file = event.dataTransfer?.items[0]
|
||||
if (
|
||||
file &&
|
||||
props.accept
|
||||
.split(',')
|
||||
.reduce((acc, t) => acc || file.type.startsWith(t) || file.type === t || t === '*', false)
|
||||
) {
|
||||
fileAllowed.value = true
|
||||
event.dataTransfer.dropEffect = 'copy'
|
||||
event.preventDefault()
|
||||
if (dropAreaRef.value) {
|
||||
dropAreaRef.value.style.visibility = 'visible'
|
||||
}
|
||||
} else {
|
||||
fileAllowed.value = false
|
||||
hideDropArea()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('dragenter', allowDrag)
|
||||
document.addEventListener('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);
|
||||
}
|
||||
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;
|
||||
}
|
||||
@media (prefers-reduced-motion) {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,69 +1,69 @@
|
||||
<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 && !renderUp && !disabled,
|
||||
'render-up': dropdownVisible && renderUp && !disabled,
|
||||
}"
|
||||
@click="toggleDropdown"
|
||||
>
|
||||
<div>
|
||||
<slot :selected="selectedOption">
|
||||
<span>
|
||||
{{ selectedOption }}
|
||||
</span>
|
||||
</slot>
|
||||
</div>
|
||||
<DropdownIcon class="arrow" :class="{ rotate: dropdownVisible }" />
|
||||
</div>
|
||||
<div class="options-wrapper" :class="{ down: !renderUp, up: renderUp }">
|
||||
<transition name="options">
|
||||
<div
|
||||
v-show="dropdownVisible"
|
||||
class="options"
|
||||
role="listbox"
|
||||
:class="{ down: !renderUp, up: renderUp }"
|
||||
>
|
||||
<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>
|
||||
<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 && !renderUp && !disabled,
|
||||
'render-up': dropdownVisible && renderUp && !disabled,
|
||||
}"
|
||||
@click="toggleDropdown"
|
||||
>
|
||||
<div>
|
||||
<slot :selected="selectedOption">
|
||||
<span>
|
||||
{{ selectedOption }}
|
||||
</span>
|
||||
</slot>
|
||||
</div>
|
||||
<DropdownIcon class="arrow" :class="{ rotate: dropdownVisible }" />
|
||||
</div>
|
||||
<div class="options-wrapper" :class="{ down: !renderUp, up: renderUp }">
|
||||
<transition name="options">
|
||||
<div
|
||||
v-show="dropdownVisible"
|
||||
class="options"
|
||||
role="listbox"
|
||||
:class="{ down: !renderUp, up: renderUp }"
|
||||
>
|
||||
<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>
|
||||
@@ -71,46 +71,46 @@ import { DropdownIcon } from '@modrinth/assets'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
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,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
displayName: {
|
||||
type: Function,
|
||||
default: undefined,
|
||||
},
|
||||
maxVisibleOptions: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
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,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
displayName: {
|
||||
type: Function,
|
||||
default: undefined,
|
||||
},
|
||||
maxVisibleOptions: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
})
|
||||
|
||||
function getOptionLabel(option) {
|
||||
return props.displayName?.(option) ?? option
|
||||
return props.displayName?.(option) ?? option
|
||||
}
|
||||
|
||||
const emit = defineEmits(['input', 'change', 'update:modelValue'])
|
||||
@@ -122,230 +122,230 @@ const dropdown = ref(null)
|
||||
const optionElements = ref(null)
|
||||
|
||||
const selectedOption = computed(() => {
|
||||
return getOptionLabel(selectedValue.value) ?? props.placeholder ?? 'Select an option'
|
||||
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
|
||||
},
|
||||
get() {
|
||||
return props.modelValue || selectedValue.value
|
||||
},
|
||||
set(newValue) {
|
||||
emit('update:modelValue', newValue)
|
||||
selectedValue.value = newValue
|
||||
},
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
selectedValue.value = newValue
|
||||
},
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
selectedValue.value = newValue
|
||||
},
|
||||
)
|
||||
|
||||
const toggleDropdown = () => {
|
||||
if (!props.disabled) {
|
||||
dropdownVisible.value = !dropdownVisible.value
|
||||
dropdown.value.focus()
|
||||
}
|
||||
if (!props.disabled) {
|
||||
dropdownVisible.value = !dropdownVisible.value
|
||||
dropdown.value.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const selectOption = (option, index) => {
|
||||
radioValue.value = option
|
||||
emit('change', { option, index })
|
||||
dropdownVisible.value = false
|
||||
radioValue.value = option
|
||||
emit('change', { option, index })
|
||||
dropdownVisible.value = false
|
||||
}
|
||||
|
||||
const onFocus = () => {
|
||||
if (!props.disabled) {
|
||||
focusedOptionIndex.value = props.options.findIndex((option) => option === selectedValue.value)
|
||||
dropdownVisible.value = true
|
||||
}
|
||||
if (!props.disabled) {
|
||||
focusedOptionIndex.value = props.options.findIndex((option) => option === selectedValue.value)
|
||||
dropdownVisible.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const onBlur = (event) => {
|
||||
if (!isChildOfDropdown(event.relatedTarget)) {
|
||||
dropdownVisible.value = false
|
||||
}
|
||||
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()
|
||||
}
|
||||
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()
|
||||
}
|
||||
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
|
||||
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;
|
||||
height: 40px;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 20rem;
|
||||
height: 40px;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
}
|
||||
&:focus {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.selected {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
.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;
|
||||
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;
|
||||
transition: 0.05s;
|
||||
|
||||
&:not(.render-down):not(.render-up) {
|
||||
transition-delay: 0.2s;
|
||||
}
|
||||
&:not(.render-down):not(.render-up) {
|
||||
transition-delay: 0.2s;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
cursor: not-allowed;
|
||||
filter: grayscale(50%);
|
||||
opacity: 0.5;
|
||||
}
|
||||
&.disabled {
|
||||
cursor: not-allowed;
|
||||
filter: grayscale(50%);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&.render-up {
|
||||
border-radius: 0 0 var(--radius-md) var(--radius-md);
|
||||
}
|
||||
&.render-up {
|
||||
border-radius: 0 0 var(--radius-md) var(--radius-md);
|
||||
}
|
||||
|
||||
&.render-down {
|
||||
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||
}
|
||||
&.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;
|
||||
}
|
||||
&:focus {
|
||||
outline: 0;
|
||||
filter: brightness(1.25);
|
||||
transition: filter 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
transition: transform 0.2s ease;
|
||||
.arrow {
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
&.rotate {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
&.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;
|
||||
.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;
|
||||
.option {
|
||||
background-color: var(--color-button-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--gap-md);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
> label {
|
||||
cursor: pointer;
|
||||
}
|
||||
> label {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
filter: brightness(0.85);
|
||||
transition: filter 0.2s ease-in-out;
|
||||
}
|
||||
&: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;
|
||||
}
|
||||
&: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;
|
||||
}
|
||||
&.selected-option {
|
||||
background-color: var(--color-brand);
|
||||
color: var(--color-accent-contrast);
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
input {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
input {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.options-enter-active,
|
||||
.options-leave-active {
|
||||
transition: transform 0.2s ease;
|
||||
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%);
|
||||
}
|
||||
// this is not 100% due to a safari bug
|
||||
&.up {
|
||||
transform: translateY(99.999%);
|
||||
}
|
||||
|
||||
&.down {
|
||||
transform: translateY(-99.999%);
|
||||
}
|
||||
&.down {
|
||||
transform: translateY(-99.999%);
|
||||
}
|
||||
}
|
||||
|
||||
.options-enter-to,
|
||||
.options-leave-from {
|
||||
&.up {
|
||||
transform: translateY(0%);
|
||||
}
|
||||
&.up {
|
||||
transform: translateY(0%);
|
||||
}
|
||||
}
|
||||
|
||||
.options-wrapper {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
z-index: 9;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
z-index: 9;
|
||||
|
||||
&.up {
|
||||
top: 0;
|
||||
transform: translateY(-99.999%);
|
||||
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||
}
|
||||
&.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);
|
||||
}
|
||||
&.down {
|
||||
border-radius: 0 0 var(--radius-md) var(--radius-md);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,114 +1,114 @@
|
||||
<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>
|
||||
<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 { GlobeIcon, ClientIcon, ServerIcon, InfoIcon } from '@modrinth/assets'
|
||||
import { useVIntl, defineMessages } from '@vintl/vintl'
|
||||
import { ClientIcon, GlobeIcon, InfoIcon, ServerIcon } from '@modrinth/assets'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
|
||||
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',
|
||||
},
|
||||
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: () => [],
|
||||
},
|
||||
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;
|
||||
}
|
||||
display: flex;
|
||||
color: var(--color-text) !important;
|
||||
font-weight: bold;
|
||||
font-size: 1rem;
|
||||
align-items: center;
|
||||
svg {
|
||||
margin-right: 0.2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,120 +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 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-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 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>
|
||||
<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>
|
||||
<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 { ref } from 'vue'
|
||||
import ButtonStyled from './ButtonStyled.vue'
|
||||
import { CopyIcon, CheckIcon } from '@modrinth/assets'
|
||||
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
|
||||
}
|
||||
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
|
||||
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')
|
||||
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)
|
||||
await navigator.clipboard.writeText(formattedErrorInfo)
|
||||
infoCopied.value = true
|
||||
setTimeout(() => {
|
||||
infoCopied.value = false
|
||||
}, 2000)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<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>
|
||||
<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>
|
||||
@@ -17,91 +17,91 @@ import { fileIsValid } from '@modrinth/utils'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
prompt: {
|
||||
type: String,
|
||||
default: 'Select file',
|
||||
},
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
accept: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
/**
|
||||
* The max file size in bytes
|
||||
*/
|
||||
maxSize: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
showIcon: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
shouldAlwaysReset: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
longStyle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['change'],
|
||||
data() {
|
||||
return {
|
||||
files: [],
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
addFiles(files, shouldNotReset) {
|
||||
if (!shouldNotReset || this.shouldAlwaysReset) {
|
||||
this.files = files
|
||||
}
|
||||
const validationOptions = { maxSize: this.maxSize, alertOnInvalid: true }
|
||||
this.files = [...this.files].filter((file) => fileIsValid(file, validationOptions))
|
||||
if (this.files.length > 0) {
|
||||
this.$emit('change', this.files)
|
||||
}
|
||||
},
|
||||
handleDrop(e) {
|
||||
this.addFiles(e.dataTransfer.files)
|
||||
},
|
||||
handleChange(e) {
|
||||
this.addFiles(e.target.files)
|
||||
},
|
||||
},
|
||||
props: {
|
||||
prompt: {
|
||||
type: String,
|
||||
default: 'Select file',
|
||||
},
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
accept: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
/**
|
||||
* The max file size in bytes
|
||||
*/
|
||||
maxSize: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
showIcon: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
shouldAlwaysReset: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
longStyle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['change'],
|
||||
data() {
|
||||
return {
|
||||
files: [],
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
addFiles(files, shouldNotReset) {
|
||||
if (!shouldNotReset || this.shouldAlwaysReset) {
|
||||
this.files = files
|
||||
}
|
||||
const validationOptions = { maxSize: this.maxSize, alertOnInvalid: true }
|
||||
this.files = [...this.files].filter((file) => fileIsValid(file, validationOptions))
|
||||
if (this.files.length > 0) {
|
||||
this.$emit('change', this.files)
|
||||
}
|
||||
},
|
||||
handleDrop(e) {
|
||||
this.addFiles(e.dataTransfer.files)
|
||||
},
|
||||
handleChange(e) {
|
||||
this.addFiles(e.target.files)
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
label {
|
||||
flex-direction: unset;
|
||||
max-height: unset;
|
||||
svg {
|
||||
height: 1rem;
|
||||
}
|
||||
input {
|
||||
display: none;
|
||||
}
|
||||
&.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 0.3rem var(--color-contrast);
|
||||
cursor: pointer;
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
flex-direction: unset;
|
||||
max-height: unset;
|
||||
svg {
|
||||
height: 1rem;
|
||||
}
|
||||
input {
|
||||
display: none;
|
||||
}
|
||||
&.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 0.3rem var(--color-contrast);
|
||||
cursor: pointer;
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,56 +1,56 @@
|
||||
<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>
|
||||
<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 '@vintl/vintl'
|
||||
import { watch } from 'vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
export type FilterBarOption = {
|
||||
id: string
|
||||
message: MessageDescriptor
|
||||
id: string
|
||||
message: MessageDescriptor
|
||||
}
|
||||
|
||||
const selectedFilters = defineModel<string[]>({ required: true })
|
||||
|
||||
const props = defineProps<{
|
||||
options: FilterBarOption[]
|
||||
showAllOptions?: boolean
|
||||
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)
|
||||
}
|
||||
}
|
||||
},
|
||||
() => 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)
|
||||
}
|
||||
if (selectedFilters.value.includes(option)) {
|
||||
selectedFilters.value.splice(selectedFilters.value.indexOf(option), 1)
|
||||
} else {
|
||||
selectedFilters.value.push(option)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,20 +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>
|
||||
<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 AutoLink from './AutoLink.vue'
|
||||
import { ChevronRightIcon } from '@modrinth/assets'
|
||||
|
||||
import AutoLink from './AutoLink.vue'
|
||||
|
||||
defineProps<{
|
||||
to: unknown
|
||||
to: unknown
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -1,113 +1,113 @@
|
||||
<template>
|
||||
<div class="w-full flex items-center justify-center flex-col gap-2">
|
||||
<div class="title">Loading</div>
|
||||
<div class="placeholder"></div>
|
||||
<div class="placeholder"></div>
|
||||
<div class="placeholder"></div>
|
||||
</div>
|
||||
<div class="w-full flex items-center justify-center flex-col gap-2">
|
||||
<div class="title">Loading</div>
|
||||
<div class="placeholder"></div>
|
||||
<div class="placeholder"></div>
|
||||
<div class="placeholder"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup></script>
|
||||
<style scoped>
|
||||
.title {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
font-weight: bold;
|
||||
color: var(--color-contrast);
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
font-weight: bold;
|
||||
color: var(--color-contrast);
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
animation: dots 2s infinite;
|
||||
}
|
||||
&::after {
|
||||
content: '';
|
||||
animation: dots 2s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dots {
|
||||
25% {
|
||||
content: '';
|
||||
}
|
||||
50% {
|
||||
content: '.';
|
||||
}
|
||||
75% {
|
||||
content: '..';
|
||||
}
|
||||
0%,
|
||||
100% {
|
||||
content: '...';
|
||||
}
|
||||
25% {
|
||||
content: '';
|
||||
}
|
||||
50% {
|
||||
content: '.';
|
||||
}
|
||||
75% {
|
||||
content: '..';
|
||||
}
|
||||
0%,
|
||||
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;
|
||||
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;
|
||||
}
|
||||
&::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(2)::before {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
&:nth-child(3)::before {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
&:nth-child(3)::before {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
&:nth-child(4)::before {
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
&:nth-child(4)::before {
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
&:nth-child(2) {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
&:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
&:nth-child(4) {
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
&: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;
|
||||
}
|
||||
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%);
|
||||
}
|
||||
from {
|
||||
transform: translateX(-80%);
|
||||
}
|
||||
50%,
|
||||
to {
|
||||
transform: translateX(80%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,113 +1,114 @@
|
||||
<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>
|
||||
<div v-if="search" class="iconified-input mb-2 w-full">
|
||||
<label for="search-input" hidden>Search...</label>
|
||||
<SearchIcon aria-hidden="true" />
|
||||
<input
|
||||
id="search-input"
|
||||
ref="searchInput"
|
||||
v-model="searchQuery"
|
||||
placeholder="Search..."
|
||||
type="text"
|
||||
@keydown.enter="
|
||||
() => {
|
||||
toggleOption(filteredOptions[0])
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
<div v-if="search" class="iconified-input mb-2 w-full">
|
||||
<label for="search-input" hidden>Search...</label>
|
||||
<SearchIcon aria-hidden="true" />
|
||||
<input
|
||||
id="search-input"
|
||||
ref="searchInput"
|
||||
v-model="searchQuery"
|
||||
placeholder="Search..."
|
||||
type="text"
|
||||
@keydown.enter="
|
||||
() => {
|
||||
toggleOption(filteredOptions[0])
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<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 { ButtonStyled, PopoutMenu, Button } from '../index'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { Button, ButtonStyled, PopoutMenu } 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: '',
|
||||
},
|
||||
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)
|
||||
return props.displayName?.(option) ?? (option as string)
|
||||
}
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
@@ -117,33 +118,33 @@ 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
|
||||
},
|
||||
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()),
|
||||
)
|
||||
return props.options.filter(
|
||||
(x) =>
|
||||
!searchQuery.value ||
|
||||
getOptionLabel(x).toLowerCase().includes(searchQuery.value.toLowerCase()),
|
||||
)
|
||||
})
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
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]
|
||||
}
|
||||
if (manyValues.value.includes(id)) {
|
||||
manyValues.value = manyValues.value.filter((x) => x !== id)
|
||||
} else {
|
||||
manyValues.value = [...manyValues.value, id]
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,125 +1,126 @@
|
||||
<template>
|
||||
<PopoutMenu
|
||||
ref="dropdown"
|
||||
v-bind="$attrs"
|
||||
:disabled="disabled"
|
||||
:dropdown-id="dropdownId"
|
||||
:tooltip="tooltip"
|
||||
>
|
||||
<slot></slot>
|
||||
<template #menu>
|
||||
<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-3 my-2 bg-button-bg"
|
||||
></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"
|
||||
:external="option.external ? option.external : false"
|
||||
:disabled="option.disabled"
|
||||
@click="
|
||||
() => {
|
||||
if (option.link && !option.remainOnClick) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<template v-if="!$slots[option.id]">{{ option.id }}</template>
|
||||
<slot :name="option.id"></slot>
|
||||
</Button>
|
||||
</template>
|
||||
</template>
|
||||
</PopoutMenu>
|
||||
<PopoutMenu
|
||||
ref="dropdown"
|
||||
v-bind="$attrs"
|
||||
:disabled="disabled"
|
||||
:dropdown-id="dropdownId"
|
||||
:tooltip="tooltip"
|
||||
>
|
||||
<slot></slot>
|
||||
<template #menu>
|
||||
<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-3 my-2 bg-button-bg"
|
||||
></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"
|
||||
:external="option.external ? option.external : false"
|
||||
:disabled="option.disabled"
|
||||
@click="
|
||||
() => {
|
||||
if (option.link && !option.remainOnClick) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<template v-if="!$slots[option.id]">{{ option.id }}</template>
|
||||
<slot :name="option.id"></slot>
|
||||
</Button>
|
||||
</template>
|
||||
</template>
|
||||
</PopoutMenu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type Ref, ref } from 'vue'
|
||||
|
||||
import Button from './Button.vue'
|
||||
import PopoutMenu from './PopoutMenu.vue'
|
||||
|
||||
interface BaseOption {
|
||||
shown?: boolean
|
||||
shown?: boolean
|
||||
}
|
||||
|
||||
interface Divider extends BaseOption {
|
||||
divider?: boolean
|
||||
divider?: boolean
|
||||
}
|
||||
|
||||
interface Item extends BaseOption {
|
||||
id: string
|
||||
action?: (event?: MouseEvent) => void
|
||||
link?: string
|
||||
external?: boolean
|
||||
color?:
|
||||
| 'primary'
|
||||
| 'danger'
|
||||
| 'secondary'
|
||||
| 'highlight'
|
||||
| 'red'
|
||||
| 'orange'
|
||||
| 'green'
|
||||
| 'blue'
|
||||
| 'purple'
|
||||
hoverFilled?: boolean
|
||||
hoverFilledOnly?: boolean
|
||||
remainOnClick?: boolean
|
||||
disabled?: boolean
|
||||
tooltip?: string
|
||||
id: string
|
||||
action?: (event?: MouseEvent) => void
|
||||
link?: 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
|
||||
}>(),
|
||||
{
|
||||
options: () => [],
|
||||
disabled: false,
|
||||
dropdownId: undefined,
|
||||
tooltip: undefined,
|
||||
},
|
||||
defineProps<{
|
||||
options: Option[]
|
||||
disabled?: boolean
|
||||
dropdownId?: string
|
||||
tooltip?: string
|
||||
}>(),
|
||||
{
|
||||
options: () => [],
|
||||
disabled: false,
|
||||
dropdownId: undefined,
|
||||
tooltip: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const dropdown: Ref<InstanceType<typeof PopoutMenu> | null> = ref(null)
|
||||
|
||||
const close = () => {
|
||||
dropdown.value?.hide()
|
||||
dropdown.value?.hide()
|
||||
}
|
||||
|
||||
const open = () => {
|
||||
dropdown.value?.show()
|
||||
dropdown.value?.show()
|
||||
}
|
||||
|
||||
function isDivider(option: BaseOption): option is Divider {
|
||||
return 'divider' in option
|
||||
return 'divider' in option
|
||||
}
|
||||
|
||||
defineExpose({ open, close })
|
||||
@@ -127,15 +128,15 @@ defineExpose({ open, close })
|
||||
|
||||
<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;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
box-shadow: none;
|
||||
--text-color: var(--color-base);
|
||||
--background-color: transparent;
|
||||
justify-content: flex-start;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: var(--gap-xs);
|
||||
}
|
||||
&:not(:last-child) {
|
||||
margin-bottom: var(--gap-xs);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,121 +1,121 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
collapsible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
rightSidebar: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
collapsible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
rightSidebar: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="omorphia__page"
|
||||
: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">
|
||||
<slot name="sidebar" />
|
||||
</div>
|
||||
<div class="content">
|
||||
<slot />
|
||||
</div>
|
||||
<div v-if="!!$slots.footer" class="footer">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="omorphia__page"
|
||||
: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">
|
||||
<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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 0.75rem;
|
||||
|
||||
.header {
|
||||
grid-area: header;
|
||||
}
|
||||
.header {
|
||||
grid-area: header;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
grid-area: sidebar;
|
||||
}
|
||||
.sidebar {
|
||||
grid-area: sidebar;
|
||||
}
|
||||
|
||||
.footer {
|
||||
grid-area: footer;
|
||||
}
|
||||
.footer {
|
||||
grid-area: footer;
|
||||
}
|
||||
|
||||
.content {
|
||||
grid-area: content;
|
||||
}
|
||||
.content {
|
||||
grid-area: content;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.omorphia__page {
|
||||
margin: 0 auto;
|
||||
max-width: 80rem;
|
||||
column-gap: 0.75rem;
|
||||
.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-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;
|
||||
}
|
||||
&.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;
|
||||
&.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;
|
||||
}
|
||||
}
|
||||
&.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
.content {
|
||||
max-width: calc(60rem - 0.75rem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
min-width: 20rem;
|
||||
width: 20rem;
|
||||
}
|
||||
.sidebar {
|
||||
min-width: 20rem;
|
||||
width: 20rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 80rem) {
|
||||
.omorphia__page.has-sidebar {
|
||||
.content {
|
||||
width: calc(60rem - 0.75rem);
|
||||
}
|
||||
}
|
||||
.omorphia__page.has-sidebar {
|
||||
.content {
|
||||
width: calc(60rem - 0.75rem);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,118 +1,119 @@
|
||||
<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 === '-'">
|
||||
<GapIcon />
|
||||
</div>
|
||||
<ButtonStyled
|
||||
v-else
|
||||
circular
|
||||
:color="page === item ? 'brand' : 'standard'"
|
||||
:type="page === item ? 'standard' : 'transparent'"
|
||||
>
|
||||
<a
|
||||
v-if="linkFunction"
|
||||
:href="linkFunction(item)"
|
||||
@click.prevent="page !== item ? switchPage(item) : null"
|
||||
>
|
||||
{{ item }}
|
||||
</a>
|
||||
<button v-else @click="page !== item ? switchPage(item) : null">{{ item }}</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<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 === '-'">
|
||||
<GapIcon />
|
||||
</div>
|
||||
<ButtonStyled
|
||||
v-else
|
||||
circular
|
||||
:color="page === item ? 'brand' : 'standard'"
|
||||
:type="page === item ? 'standard' : 'transparent'"
|
||||
>
|
||||
<a
|
||||
v-if="linkFunction"
|
||||
:href="linkFunction(item)"
|
||||
@click.prevent="page !== item ? switchPage(item) : null"
|
||||
>
|
||||
{{ item }}
|
||||
</a>
|
||||
<button v-else @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>
|
||||
<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, GapIcon } from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
import { GapIcon, ChevronLeftIcon, ChevronRightIcon } from '@modrinth/assets'
|
||||
|
||||
import ButtonStyled from './ButtonStyled.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
'switch-page': [page: number]
|
||||
'switch-page': [page: number]
|
||||
}>()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
page: number
|
||||
count: number
|
||||
linkFunction?: (page: number) => string | undefined
|
||||
}>(),
|
||||
{
|
||||
page: 1,
|
||||
count: 1,
|
||||
linkFunction: (page: number) => void page,
|
||||
},
|
||||
defineProps<{
|
||||
page: number
|
||||
count: number
|
||||
linkFunction?: (page: number) => string | undefined
|
||||
}>(),
|
||||
{
|
||||
page: 1,
|
||||
count: 1,
|
||||
linkFunction: (page: number) => void page,
|
||||
},
|
||||
)
|
||||
|
||||
const pages = computed(() => {
|
||||
const pages: ('-' | number)[] = []
|
||||
const pages: ('-' | number)[] = []
|
||||
|
||||
const first = 1
|
||||
const last = props.count
|
||||
const current = props.page
|
||||
const prev = current - 1
|
||||
const next = current + 1
|
||||
const gap = '-'
|
||||
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)
|
||||
}
|
||||
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
|
||||
return pages
|
||||
})
|
||||
|
||||
function switchPage(newPage: number) {
|
||||
emit('switch-page', Math.min(Math.max(newPage, 1), props.count))
|
||||
emit('switch-page', Math.min(Math.max(newPage, 1), props.count))
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
<template>
|
||||
<Dropdown
|
||||
ref="dropdown"
|
||||
no-auto-focus
|
||||
:aria-id="dropdownId || null"
|
||||
placement="bottom-end"
|
||||
:class="dropdownClass"
|
||||
@apply-hide="focusTrigger"
|
||||
@apply-show="focusMenuChild"
|
||||
>
|
||||
<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>
|
||||
<Dropdown
|
||||
ref="dropdown"
|
||||
no-auto-focus
|
||||
:aria-id="dropdownId || null"
|
||||
placement="bottom-end"
|
||||
:class="dropdownClass"
|
||||
@apply-hide="focusTrigger"
|
||||
@apply-show="focusMenuChild"
|
||||
>
|
||||
<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>
|
||||
@@ -30,68 +30,68 @@ 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,
|
||||
},
|
||||
dropdownId: {
|
||||
type: String,
|
||||
default: null,
|
||||
required: false,
|
||||
},
|
||||
dropdownClass: {
|
||||
type: String,
|
||||
default: null,
|
||||
required: false,
|
||||
},
|
||||
tooltip: {
|
||||
type: String,
|
||||
default: null,
|
||||
required: false,
|
||||
},
|
||||
})
|
||||
|
||||
function focusMenuChild() {
|
||||
setTimeout(() => {
|
||||
if (menu.value && menu.value.children && menu.value.children.length > 0) {
|
||||
menu.value.children[0].focus()
|
||||
}
|
||||
}, 50)
|
||||
setTimeout(() => {
|
||||
if (menu.value && menu.value.children && menu.value.children.length > 0) {
|
||||
menu.value.children[0].focus()
|
||||
}
|
||||
}, 50)
|
||||
}
|
||||
|
||||
function hideAndFocusTrigger(hide) {
|
||||
hide()
|
||||
focusTrigger()
|
||||
hide()
|
||||
focusTrigger()
|
||||
}
|
||||
|
||||
function focusTrigger() {
|
||||
trigger.value.focus()
|
||||
trigger.value.focus()
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
function hide() {
|
||||
dropdown.value.hide()
|
||||
dropdown.value.hide()
|
||||
}
|
||||
|
||||
function show() {
|
||||
dropdown.value.show()
|
||||
dropdown.value.show()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
hide,
|
||||
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;
|
||||
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>
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { RadioButtonIcon, RadioButtonCheckedIcon } from '@modrinth/assets'
|
||||
import { RadioButtonCheckedIcon, RadioButtonIcon } from '@modrinth/assets'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
checked: boolean
|
||||
}>(),
|
||||
{
|
||||
checked: false,
|
||||
},
|
||||
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>
|
||||
<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>
|
||||
|
||||
@@ -2,85 +2,85 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
progress: number
|
||||
max?: number
|
||||
color?: 'brand' | 'green' | 'red' | 'orange' | 'blue' | 'purple' | 'gray'
|
||||
waiting?: boolean
|
||||
}>(),
|
||||
{
|
||||
max: 1,
|
||||
color: 'brand',
|
||||
waiting: false,
|
||||
},
|
||||
defineProps<{
|
||||
progress: number
|
||||
max?: number
|
||||
color?: 'brand' | 'green' | 'red' | 'orange' | 'blue' | 'purple' | 'gray'
|
||||
waiting?: boolean
|
||||
}>(),
|
||||
{
|
||||
max: 1,
|
||||
color: 'brand',
|
||||
waiting: 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',
|
||||
},
|
||||
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 max-w-[15rem] h-1 rounded-full overflow-hidden"
|
||||
:class="colors[props.color].bg"
|
||||
>
|
||||
<div
|
||||
class="rounded-full progress-bar"
|
||||
:class="[colors[props.color].fg, { 'progress-bar--waiting': waiting }]"
|
||||
:style="!waiting ? { width: `${percent * 100}%` } : {}"
|
||||
></div>
|
||||
</div>
|
||||
<div
|
||||
class="flex w-full max-w-[15rem] h-1 rounded-full overflow-hidden"
|
||||
:class="colors[props.color].bg"
|
||||
>
|
||||
<div
|
||||
class="rounded-full progress-bar"
|
||||
:class="[colors[props.color].fg, { 'progress-bar--waiting': waiting }]"
|
||||
:style="!waiting ? { width: `${percent * 100}%` } : {}"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped lang="scss">
|
||||
.progress-bar {
|
||||
transition: width 0.2s ease-in-out;
|
||||
transition: width 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.progress-bar--waiting {
|
||||
animation: progress-bar-waiting 1s linear infinite;
|
||||
position: relative;
|
||||
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%;
|
||||
}
|
||||
0% {
|
||||
left: -50%;
|
||||
width: 20%;
|
||||
}
|
||||
50% {
|
||||
width: 60%;
|
||||
}
|
||||
100% {
|
||||
left: 100%;
|
||||
width: 20%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,51 +1,51 @@
|
||||
<template>
|
||||
<div>
|
||||
<div :style="colorClasses" class="radial-header relative" v-bind="$attrs">
|
||||
<slot />
|
||||
</div>
|
||||
<div class="radial-header-divider" />
|
||||
</div>
|
||||
<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,
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
color?: 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple' | 'gray'
|
||||
}>(),
|
||||
{
|
||||
color: 'brand',
|
||||
},
|
||||
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});`,
|
||||
() =>
|
||||
`--_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;
|
||||
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;
|
||||
}
|
||||
&::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>
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<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>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<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 { RadioButtonIcon, RadioButtonCheckedIcon } from '@modrinth/assets'
|
||||
import { RadioButtonCheckedIcon, RadioButtonIcon } from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: T
|
||||
items: T[]
|
||||
forceSelection?: boolean
|
||||
}>(),
|
||||
{
|
||||
forceSelection: false,
|
||||
},
|
||||
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)
|
||||
},
|
||||
get() {
|
||||
return props.modelValue
|
||||
},
|
||||
set(value) {
|
||||
emit('update:modelValue', value)
|
||||
},
|
||||
})
|
||||
|
||||
if (props.items.length > 0 && props.forceSelection && !props.modelValue) {
|
||||
selected.value = props.items[0]
|
||||
selected.value = props.items[0]
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
<template>
|
||||
<div class="scrollable-pane-wrapper">
|
||||
<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>
|
||||
<div class="scrollable-pane-wrapper">
|
||||
<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 { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
disableScrolling?: boolean
|
||||
}>(),
|
||||
{
|
||||
disableScrolling: false,
|
||||
},
|
||||
defineProps<{
|
||||
disableScrolling?: boolean
|
||||
}>(),
|
||||
{
|
||||
disableScrolling: false,
|
||||
},
|
||||
)
|
||||
|
||||
const scrollableAtTop = ref(true)
|
||||
@@ -38,85 +38,85 @@ 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)
|
||||
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()
|
||||
}
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
})
|
||||
function updateFade(scrollTop, offsetHeight, scrollHeight) {
|
||||
console.log(scrollTop, offsetHeight, scrollHeight)
|
||||
scrollableAtBottom.value = Math.ceil(scrollTop + offsetHeight) >= scrollHeight
|
||||
scrollableAtTop.value = scrollTop <= 0
|
||||
console.log(scrollTop, offsetHeight, scrollHeight)
|
||||
scrollableAtBottom.value = Math.ceil(scrollTop + offsetHeight) >= scrollHeight
|
||||
scrollableAtTop.value = scrollTop <= 0
|
||||
}
|
||||
function onScroll({ target: { scrollTop, offsetHeight, scrollHeight } }) {
|
||||
updateFade(scrollTop, offsetHeight, scrollHeight)
|
||||
updateFade(scrollTop, offsetHeight, scrollHeight)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@property --_top-fade-height {
|
||||
syntax: '<length-percentage>';
|
||||
inherits: false;
|
||||
initial-value: 0%;
|
||||
syntax: '<length-percentage>';
|
||||
inherits: false;
|
||||
initial-value: 0%;
|
||||
}
|
||||
|
||||
@property --_bottom-fade-height {
|
||||
syntax: '<length-percentage>';
|
||||
inherits: false;
|
||||
initial-value: 0%;
|
||||
syntax: '<length-percentage>';
|
||||
inherits: false;
|
||||
initial-value: 0%;
|
||||
}
|
||||
|
||||
.scrollable-pane-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.wrapper-wrapper {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition:
|
||||
--_top-fade-height 0.05s linear,
|
||||
--_bottom-fade-height 0.05s linear;
|
||||
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;
|
||||
--_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%
|
||||
);
|
||||
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);
|
||||
}
|
||||
&.top-fade {
|
||||
--_top-fade-height: var(--_fade-height);
|
||||
}
|
||||
|
||||
&.bottom-fade {
|
||||
--_bottom-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;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
<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" circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.dismiss)"
|
||||
@click="() => (preview ? {} : emit('dismiss'))"
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<div v-if="message" class="markdown-body" v-html="renderString(message)" />
|
||||
</Admonition>
|
||||
<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" circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.dismiss)"
|
||||
@click="() => (preview ? {} : emit('dismiss'))"
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<div v-if="message" class="markdown-body" v-html="renderString(message)" />
|
||||
</Admonition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -36,64 +36,65 @@ import { XIcon } from '@modrinth/assets'
|
||||
import { renderString } from '@modrinth/utils'
|
||||
import { defineMessages, type MessageDescriptor, useVIntl } from '@vintl/vintl'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Admonition from './Admonition.vue'
|
||||
import ButtonStyled from './ButtonStyled.vue'
|
||||
import CopyCode from './CopyCode.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const emit = defineEmits<{
|
||||
(e: 'dismiss'): void
|
||||
(e: 'dismiss'): void
|
||||
}>()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
level: string
|
||||
message: string
|
||||
dismissable: boolean
|
||||
preview?: boolean
|
||||
title?: string
|
||||
}>(),
|
||||
{
|
||||
preview: false,
|
||||
title: undefined,
|
||||
},
|
||||
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('\\'),
|
||||
() => 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',
|
||||
},
|
||||
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,
|
||||
info: messages.info,
|
||||
warn: messages.attention,
|
||||
critical: messages.attention,
|
||||
}
|
||||
|
||||
const NOTICE_TYPE: Record<string, 'info' | 'warning' | 'critical'> = {
|
||||
info: 'info',
|
||||
warn: 'warning',
|
||||
critical: 'critical',
|
||||
info: 'info',
|
||||
warn: 'warning',
|
||||
critical: 'critical',
|
||||
}
|
||||
|
||||
const heading = computed(() => NOTICE_HEADINGS[props.level] ?? messages.info)
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.markdown-body > *:first-child {
|
||||
margin-top: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,16 +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>
|
||||
<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'
|
||||
icon?: Component
|
||||
formattedName: string
|
||||
color?: 'brand' | 'green' | 'blue' | 'purple' | 'orange' | 'red'
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -1,55 +1,55 @@
|
||||
<template>
|
||||
<div class="root-container">
|
||||
<div class="slider-component">
|
||||
<div class="slide-container">
|
||||
<div class="snap-points-wrapper">
|
||||
<div class="snap-points">
|
||||
<div
|
||||
v-for="snapPoint in snapPoints"
|
||||
:key="snapPoint"
|
||||
class="snap-point"
|
||||
:class="{ green: snapPoint <= currentValue, 'opacity-0': disabled }"
|
||||
:style="{ left: ((snapPoint - min) / (max - min)) * 100 + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref="input"
|
||||
v-model="currentValue"
|
||||
type="range"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
class="slider"
|
||||
:class="{
|
||||
disabled: disabled,
|
||||
}"
|
||||
:disabled="disabled"
|
||||
:style="{
|
||||
'--current-value': currentValue,
|
||||
'--min-value': min,
|
||||
'--max-value': max,
|
||||
}"
|
||||
@input="onInputWithSnap(($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<div class="slider-range">
|
||||
<span> {{ min }} {{ unit }} </span>
|
||||
<span> {{ max }} {{ unit }} </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref="value"
|
||||
:value="currentValue"
|
||||
type="number"
|
||||
class="slider-input"
|
||||
:disabled="disabled"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
@change="onInput(($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
<div class="root-container">
|
||||
<div class="slider-component">
|
||||
<div class="slide-container">
|
||||
<div class="snap-points-wrapper">
|
||||
<div class="snap-points">
|
||||
<div
|
||||
v-for="snapPoint in snapPoints"
|
||||
:key="snapPoint"
|
||||
class="snap-point"
|
||||
:class="{ green: snapPoint <= currentValue, 'opacity-0': disabled }"
|
||||
:style="{ left: ((snapPoint - min) / (max - min)) * 100 + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref="input"
|
||||
v-model="currentValue"
|
||||
type="range"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
class="slider"
|
||||
:class="{
|
||||
disabled: disabled,
|
||||
}"
|
||||
:disabled="disabled"
|
||||
:style="{
|
||||
'--current-value': currentValue,
|
||||
'--min-value': min,
|
||||
'--max-value': max,
|
||||
}"
|
||||
@input="onInputWithSnap(($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<div class="slider-range">
|
||||
<span> {{ min }} {{ unit }} </span>
|
||||
<span> {{ max }} {{ unit }} </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref="value"
|
||||
:value="currentValue"
|
||||
type="number"
|
||||
class="slider-input"
|
||||
:disabled="disabled"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
@change="onInput(($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -58,192 +58,192 @@ import { ref } from '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
|
||||
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: '',
|
||||
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))
|
||||
|
||||
const inputValueValid = (inputValue: number) => {
|
||||
let newValue = inputValue || props.min
|
||||
let newValue = inputValue || props.min
|
||||
|
||||
if (props.forceStep) {
|
||||
newValue -= newValue % props.step
|
||||
}
|
||||
newValue = Math.max(props.min, Math.min(newValue, props.max))
|
||||
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)
|
||||
currentValue.value = newValue
|
||||
emit('update:modelValue', currentValue.value)
|
||||
}
|
||||
|
||||
const onInputWithSnap = (value: string) => {
|
||||
let parsedValue = parseInt(value)
|
||||
let parsedValue = parseInt(value)
|
||||
|
||||
for (const snapPoint of props.snapPoints) {
|
||||
const distance = Math.abs(snapPoint - parsedValue)
|
||||
for (const snapPoint of props.snapPoints) {
|
||||
const distance = Math.abs(snapPoint - parsedValue)
|
||||
|
||||
if (distance < props.snapRange) {
|
||||
parsedValue = snapPoint
|
||||
}
|
||||
}
|
||||
if (distance < props.snapRange) {
|
||||
parsedValue = snapPoint
|
||||
}
|
||||
}
|
||||
|
||||
inputValueValid(parsedValue)
|
||||
inputValueValid(parsedValue)
|
||||
}
|
||||
|
||||
const onInput = (value: string) => {
|
||||
inputValueValid(parseInt(value))
|
||||
inputValueValid(parseInt(value))
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.root-container {
|
||||
--transition-speed: 0.2s;
|
||||
--transition-speed: 0.2s;
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
--transition-speed: 0s;
|
||||
}
|
||||
@media (prefers-reduced-motion) {
|
||||
--transition-speed: 0s;
|
||||
}
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.slider-component,
|
||||
.slide-container {
|
||||
width: 100%;
|
||||
width: 100%;
|
||||
|
||||
position: relative;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.slider-component .slide-container .slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
position: relative;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
position: relative;
|
||||
|
||||
border-radius: var(--radius-sm);
|
||||
height: 0.25rem;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
min-height: 0px;
|
||||
box-shadow: none;
|
||||
border-radius: var(--radius-sm);
|
||||
height: 0.25rem;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
min-height: 0px;
|
||||
box-shadow: none;
|
||||
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--color-brand),
|
||||
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%
|
||||
);
|
||||
background-size: 100% 100%;
|
||||
outline: none;
|
||||
vertical-align: middle;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--color-brand),
|
||||
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%
|
||||
);
|
||||
background-size: 100% 100%;
|
||||
outline: none;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.slider-component .slide-container .slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
background: var(--color-brand);
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
transition: var(--transition-speed);
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
background: var(--color-brand);
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
transition: var(--transition-speed);
|
||||
}
|
||||
|
||||
.slider-component .slide-container .slider::-moz-range-thumb {
|
||||
border: none;
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
background: var(--color-brand);
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
transition: var(--transition-speed);
|
||||
border: none;
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
background: var(--color-brand);
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
transition: var(--transition-speed);
|
||||
}
|
||||
|
||||
.slider-component .slide-container .slider:hover::-webkit-slider-thumb:not(.disabled) {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
transition: var(--transition-speed);
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
transition: var(--transition-speed);
|
||||
}
|
||||
|
||||
.slider-component .slide-container .slider:hover::-moz-range-thumb:not(.disabled) {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
transition: var(--transition-speed);
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
transition: var(--transition-speed);
|
||||
}
|
||||
|
||||
.slider-component .slide-container .snap-points-wrapper {
|
||||
position: absolute;
|
||||
height: 50%;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
height: 50%;
|
||||
width: 100%;
|
||||
|
||||
.snap-points {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
.snap-points {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
|
||||
vertical-align: middle;
|
||||
vertical-align: middle;
|
||||
|
||||
width: calc(100% - 0.75rem);
|
||||
height: 0.75rem;
|
||||
width: calc(100% - 0.75rem);
|
||||
height: 0.75rem;
|
||||
|
||||
left: calc(0.75rem / 2);
|
||||
left: calc(0.75rem / 2);
|
||||
|
||||
.snap-point {
|
||||
position: absolute;
|
||||
display: inline-block;
|
||||
.snap-point {
|
||||
position: absolute;
|
||||
display: inline-block;
|
||||
|
||||
width: 0.25rem;
|
||||
height: 100%;
|
||||
border-radius: var(--radius-sm);
|
||||
width: 0.25rem;
|
||||
height: 100%;
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
background-color: var(--color-base);
|
||||
background-color: var(--color-base);
|
||||
|
||||
transform: translateX(calc(-0.25rem / 2));
|
||||
transform: translateX(calc(-0.25rem / 2));
|
||||
|
||||
&.green {
|
||||
background-color: var(--color-brand);
|
||||
}
|
||||
}
|
||||
}
|
||||
&.green {
|
||||
background-color: var(--color-brand);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.slider-input {
|
||||
width: 6rem;
|
||||
margin-left: 0.75rem;
|
||||
width: 6rem;
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
|
||||
.slider-range {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
font-size: 0.75rem;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
font-size: 0.75rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,61 +1,61 @@
|
||||
<template>
|
||||
<div class="smart-clickable" :class="{ 'smart-clickable--has-clickable': !!$slots.clickable }">
|
||||
<slot name="clickable" />
|
||||
<div v-bind="$attrs" class="smart-clickable__contents pointer-events-none">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
<div class="smart-clickable" :class="{ 'smart-clickable--has-clickable': !!$slots.clickable }">
|
||||
<slot name="clickable" />
|
||||
<div v-bind="$attrs" class="smart-clickable__contents pointer-events-none">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
inheritAttrs: false,
|
||||
})
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.smart-clickable {
|
||||
display: grid;
|
||||
display: grid;
|
||||
|
||||
> * {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
> * {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
|
||||
.smart-clickable__contents {
|
||||
// Utility classes for contents
|
||||
:deep(.smart-clickable\:allow-pointer-events) {
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
.smart-clickable__contents {
|
||||
// Utility classes for contents
|
||||
:deep(.smart-clickable\:allow-pointer-events) {
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
// Setup base styles for contents
|
||||
.smart-clickable__contents {
|
||||
transition: scale 0.125s ease-out;
|
||||
|
||||
// Why? I don't know. It forces the SVGs to render differently, which fixes some shift on hover otherwise.
|
||||
//filter: brightness(1.00001);
|
||||
}
|
||||
// Why? I don't know. It forces the SVGs to render differently, which fixes some shift on hover otherwise.
|
||||
//filter: brightness(1.00001);
|
||||
}
|
||||
|
||||
// When clickable is being hovered or focus-visible, give contents an effect
|
||||
&:has(> *:first-child:hover, > *:first-child:focus-visible) .smart-clickable__contents {
|
||||
filter: var(--hover-filter-weak);
|
||||
// When clickable is being hovered or focus-visible, give contents an effect
|
||||
&:has(> *:first-child:hover, > *:first-child:focus-visible) .smart-clickable__contents {
|
||||
filter: var(--hover-filter-weak);
|
||||
|
||||
// Utility classes for contents
|
||||
:deep(.smart-clickable\:underline-on-hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
// Utility classes for contents
|
||||
:deep(.smart-clickable\:underline-on-hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
// Utility classes for contents
|
||||
:deep(.smart-clickable\:highlight-on-hover) {
|
||||
filter: brightness(var(--hover-brightness, 1.25));
|
||||
}
|
||||
}
|
||||
// Utility classes for contents
|
||||
:deep(.smart-clickable\:highlight-on-hover) {
|
||||
filter: brightness(var(--hover-brightness, 1.25));
|
||||
}
|
||||
}
|
||||
|
||||
// When clickable is being clicked, give contents an effect
|
||||
&:has(> *:first-child:active) .smart-clickable__contents {
|
||||
scale: 0.97;
|
||||
}
|
||||
// When clickable is being clicked, give contents an effect
|
||||
&:has(> *:first-child:active) .smart-clickable__contents {
|
||||
scale: 0.97;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-3">
|
||||
<slot></slot>
|
||||
<div class="flex flex-col">
|
||||
<span class="font-bold">{{ value }}</span>
|
||||
<span class="text-secondary">{{ label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<slot></slot>
|
||||
<div class="flex flex-col">
|
||||
<span class="font-bold">{{ value }}</span>
|
||||
<span class="text-secondary">{{ label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
:slotted(*) {
|
||||
@apply h-6 w-6 text-secondary;
|
||||
@apply h-6 w-6 text-secondary;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<template>
|
||||
<button
|
||||
v-if="action"
|
||||
class="bg-[--_bg-color,var(--color-button-bg)] px-2 py-1 leading-none rounded-full font-semibold text-sm inline-flex items-center gap-1 text-[--_color,var(--color-secondary)] [&>svg]:shrink-0 [&>svg]:h-4 [&>svg]:w-4 border-none transition-transform active:scale-[0.95] cursor-pointer hover:underline"
|
||||
@click="action"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
<div
|
||||
v-else
|
||||
class="bg-[--_bg-color,var(--color-button-bg)] px-2 py-1 leading-none rounded-full font-semibold text-sm inline-flex items-center gap-1 text-[--_color,var(--color-secondary)] [&>svg]:shrink-0 [&>svg]:h-4 [&>svg]:w-4"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
<button
|
||||
v-if="action"
|
||||
class="bg-[--_bg-color,var(--color-button-bg)] px-2 py-1 leading-none rounded-full font-semibold text-sm inline-flex items-center gap-1 text-[--_color,var(--color-secondary)] [&>svg]:shrink-0 [&>svg]:h-4 [&>svg]:w-4 border-none transition-transform active:scale-[0.95] cursor-pointer hover:underline"
|
||||
@click="action"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
<div
|
||||
v-else
|
||||
class="bg-[--_bg-color,var(--color-button-bg)] px-2 py-1 leading-none rounded-full font-semibold text-sm inline-flex items-center gap-1 text-[--_color,var(--color-secondary)] [&>svg]:shrink-0 [&>svg]:h-4 [&>svg]:w-4"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
action?: (event: MouseEvent) => void
|
||||
action?: (event: MouseEvent) => void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -1,135 +1,135 @@
|
||||
<template>
|
||||
<div
|
||||
ref="dropdown"
|
||||
data-pyro-dropdown
|
||||
tabindex="0"
|
||||
role="combobox"
|
||||
:aria-expanded="dropdownVisible"
|
||||
class="relative inline-block h-9 w-full max-w-80"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@mousedown.prevent
|
||||
@keydown="handleKeyDown"
|
||||
>
|
||||
<div
|
||||
data-pyro-dropdown-trigger
|
||||
class="duration-50 flex h-full w-full cursor-pointer select-none items-center justify-between gap-4 rounded-xl bg-button-bg px-4 py-2 shadow-sm transition-all ease-in-out"
|
||||
:class="triggerClasses"
|
||||
@click="toggleDropdown"
|
||||
>
|
||||
<span>{{ selectedOption }}</span>
|
||||
<DropdownIcon
|
||||
class="transition-transform duration-200 ease-in-out"
|
||||
:class="{ 'rotate-180': dropdownVisible }"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref="dropdown"
|
||||
data-pyro-dropdown
|
||||
tabindex="0"
|
||||
role="combobox"
|
||||
:aria-expanded="dropdownVisible"
|
||||
class="relative inline-block h-9 w-full max-w-80"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@mousedown.prevent
|
||||
@keydown="handleKeyDown"
|
||||
>
|
||||
<div
|
||||
data-pyro-dropdown-trigger
|
||||
class="duration-50 flex h-full w-full cursor-pointer select-none items-center justify-between gap-4 rounded-xl bg-button-bg px-4 py-2 shadow-sm transition-all ease-in-out"
|
||||
:class="triggerClasses"
|
||||
@click="toggleDropdown"
|
||||
>
|
||||
<span>{{ selectedOption }}</span>
|
||||
<DropdownIcon
|
||||
class="transition-transform duration-200 ease-in-out"
|
||||
:class="{ 'rotate-180': dropdownVisible }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Teleport to="#teleports">
|
||||
<transition
|
||||
enter-active-class="transition-opacity duration-200 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition-opacity duration-200 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="dropdownVisible"
|
||||
ref="optionsContainer"
|
||||
data-pyro-dropdown-options
|
||||
class="experimental-styles-within fixed z-50 bg-button-bg shadow-lg"
|
||||
:class="{
|
||||
'rounded-b-xl': !isRenderingUp,
|
||||
'rounded-t-xl': isRenderingUp,
|
||||
}"
|
||||
:style="positionStyle"
|
||||
@keydown.stop="handleDropdownKeyDown"
|
||||
>
|
||||
<div
|
||||
class="overflow-y-auto"
|
||||
:style="{ height: `${virtualListHeight}px` }"
|
||||
data-pyro-dropdown-options-virtual-scroller
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<div :style="{ height: `${totalHeight}px`, position: 'relative' }">
|
||||
<div
|
||||
v-for="item in visibleOptions"
|
||||
:key="item.index"
|
||||
data-pyro-dropdown-option
|
||||
:style="{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
transform: `translateY(${item.index * ITEM_HEIGHT}px)`,
|
||||
width: '100%',
|
||||
height: `${ITEM_HEIGHT}px`,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
:ref="(el) => handleOptionRef(el as HTMLElement, item.index)"
|
||||
role="option"
|
||||
:tabindex="focusedOptionIndex === item.index ? 0 : -1"
|
||||
class="hover:brightness-85 flex h-full cursor-pointer select-none items-center px-4 transition-colors duration-150 ease-in-out focus:border-none focus:outline-none"
|
||||
:class="{
|
||||
'bg-brand font-bold text-brand-inverted': selectedValue === item.option,
|
||||
'bg-bg-raised': focusedOptionIndex === item.index,
|
||||
}"
|
||||
:aria-selected="selectedValue === item.option"
|
||||
@click="selectOption(item.option, item.index)"
|
||||
@mouseover="focusedOptionIndex = item.index"
|
||||
@focus="focusedOptionIndex = item.index"
|
||||
>
|
||||
<input
|
||||
:id="`${name}-${item.index}`"
|
||||
v-model="radioValue"
|
||||
type="radio"
|
||||
:value="item.option"
|
||||
:name="name"
|
||||
class="hidden"
|
||||
/>
|
||||
<label :for="`${name}-${item.index}`" class="w-full cursor-pointer">
|
||||
{{ displayName(item.option) }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
<Teleport to="#teleports">
|
||||
<transition
|
||||
enter-active-class="transition-opacity duration-200 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition-opacity duration-200 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="dropdownVisible"
|
||||
ref="optionsContainer"
|
||||
data-pyro-dropdown-options
|
||||
class="experimental-styles-within fixed z-50 bg-button-bg shadow-lg"
|
||||
:class="{
|
||||
'rounded-b-xl': !isRenderingUp,
|
||||
'rounded-t-xl': isRenderingUp,
|
||||
}"
|
||||
:style="positionStyle"
|
||||
@keydown.stop="handleDropdownKeyDown"
|
||||
>
|
||||
<div
|
||||
class="overflow-y-auto"
|
||||
:style="{ height: `${virtualListHeight}px` }"
|
||||
data-pyro-dropdown-options-virtual-scroller
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<div :style="{ height: `${totalHeight}px`, position: 'relative' }">
|
||||
<div
|
||||
v-for="item in visibleOptions"
|
||||
:key="item.index"
|
||||
data-pyro-dropdown-option
|
||||
:style="{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
transform: `translateY(${item.index * ITEM_HEIGHT}px)`,
|
||||
width: '100%',
|
||||
height: `${ITEM_HEIGHT}px`,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
:ref="(el) => handleOptionRef(el as HTMLElement, item.index)"
|
||||
role="option"
|
||||
:tabindex="focusedOptionIndex === item.index ? 0 : -1"
|
||||
class="hover:brightness-85 flex h-full cursor-pointer select-none items-center px-4 transition-colors duration-150 ease-in-out focus:border-none focus:outline-none"
|
||||
:class="{
|
||||
'bg-brand font-bold text-brand-inverted': selectedValue === item.option,
|
||||
'bg-bg-raised': focusedOptionIndex === item.index,
|
||||
}"
|
||||
:aria-selected="selectedValue === item.option"
|
||||
@click="selectOption(item.option, item.index)"
|
||||
@mouseover="focusedOptionIndex = item.index"
|
||||
@focus="focusedOptionIndex = item.index"
|
||||
>
|
||||
<input
|
||||
:id="`${name}-${item.index}`"
|
||||
v-model="radioValue"
|
||||
type="radio"
|
||||
:value="item.option"
|
||||
:name="name"
|
||||
class="hidden"
|
||||
/>
|
||||
<label :for="`${name}-${item.index}`" class="w-full cursor-pointer">
|
||||
{{ displayName(item.option) }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="OptionValue extends string | number | Record<string, any>">
|
||||
import { DropdownIcon } from '@modrinth/assets'
|
||||
import { computed, ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import type { CSSProperties } from 'vue'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
const ITEM_HEIGHT = 44
|
||||
const BUFFER_ITEMS = 5
|
||||
|
||||
interface Props {
|
||||
options: OptionValue[]
|
||||
name: string
|
||||
defaultValue?: OptionValue | null
|
||||
placeholder?: string | number | null
|
||||
modelValue?: OptionValue | null
|
||||
renderUp?: boolean
|
||||
disabled?: boolean
|
||||
displayName?: (option: OptionValue) => string
|
||||
options: OptionValue[]
|
||||
name: string
|
||||
defaultValue?: OptionValue | null
|
||||
placeholder?: string | number | null
|
||||
modelValue?: OptionValue | null
|
||||
renderUp?: boolean
|
||||
disabled?: boolean
|
||||
displayName?: (option: OptionValue) => string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
defaultValue: null,
|
||||
placeholder: null,
|
||||
modelValue: null,
|
||||
renderUp: false,
|
||||
disabled: false,
|
||||
displayName: (option: OptionValue) => String(option),
|
||||
defaultValue: null,
|
||||
placeholder: null,
|
||||
modelValue: null,
|
||||
renderUp: false,
|
||||
disabled: false,
|
||||
displayName: (option: OptionValue) => String(option),
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'input' | 'update:modelValue', value: OptionValue): void
|
||||
(e: 'change', value: { option: OptionValue; index: number }): void
|
||||
(e: 'input' | 'update:modelValue', value: OptionValue): void
|
||||
(e: 'change', value: { option: OptionValue; index: number }): void
|
||||
}>()
|
||||
|
||||
const dropdownVisible = ref(false)
|
||||
@@ -144,298 +144,298 @@ const virtualListHeight = ref(300)
|
||||
const lastFocusedElement = ref<HTMLElement | null>(null)
|
||||
|
||||
const positionStyle = ref<CSSProperties>({
|
||||
position: 'fixed',
|
||||
top: '0px',
|
||||
left: '0px',
|
||||
width: '0px',
|
||||
zIndex: 999,
|
||||
position: 'fixed',
|
||||
top: '0px',
|
||||
left: '0px',
|
||||
width: '0px',
|
||||
zIndex: 999,
|
||||
})
|
||||
|
||||
const handleOptionRef = (el: HTMLElement | null, index: number) => {
|
||||
if (focusedOptionIndex.value === index) {
|
||||
focusedOptionRef.value = el
|
||||
}
|
||||
if (focusedOptionIndex.value === index) {
|
||||
focusedOptionRef.value = el
|
||||
}
|
||||
}
|
||||
|
||||
const onFocus = async () => {
|
||||
if (!props.disabled) {
|
||||
focusedOptionIndex.value = props.options.findIndex((option) => option === selectedValue.value)
|
||||
lastFocusedElement.value = document.activeElement as HTMLElement
|
||||
dropdownVisible.value = true
|
||||
await updatePosition()
|
||||
nextTick(() => {
|
||||
dropdown.value?.focus()
|
||||
})
|
||||
}
|
||||
if (!props.disabled) {
|
||||
focusedOptionIndex.value = props.options.findIndex((option) => option === selectedValue.value)
|
||||
lastFocusedElement.value = document.activeElement as HTMLElement
|
||||
dropdownVisible.value = true
|
||||
await updatePosition()
|
||||
nextTick(() => {
|
||||
dropdown.value?.focus()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const onBlur = (event: FocusEvent) => {
|
||||
if (!isChildOfDropdown(event.relatedTarget as HTMLElement | null)) {
|
||||
closeDropdown()
|
||||
}
|
||||
if (!isChildOfDropdown(event.relatedTarget as HTMLElement | null)) {
|
||||
closeDropdown()
|
||||
}
|
||||
}
|
||||
|
||||
const isChildOfDropdown = (element: HTMLElement | null): boolean => {
|
||||
let currentNode: HTMLElement | null = element
|
||||
while (currentNode) {
|
||||
if (currentNode === dropdown.value || currentNode === optionsContainer.value) {
|
||||
return true
|
||||
}
|
||||
currentNode = currentNode.parentElement
|
||||
}
|
||||
return false
|
||||
let currentNode: HTMLElement | null = element
|
||||
while (currentNode) {
|
||||
if (currentNode === dropdown.value || currentNode === optionsContainer.value) {
|
||||
return true
|
||||
}
|
||||
currentNode = currentNode.parentElement
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const totalHeight = computed(() => props.options.length * ITEM_HEIGHT)
|
||||
|
||||
const visibleOptions = computed(() => {
|
||||
const startIndex = Math.floor(scrollTop.value / ITEM_HEIGHT) - BUFFER_ITEMS
|
||||
const visibleCount = Math.ceil(virtualListHeight.value / ITEM_HEIGHT) + 2 * BUFFER_ITEMS
|
||||
const startIndex = Math.floor(scrollTop.value / ITEM_HEIGHT) - BUFFER_ITEMS
|
||||
const visibleCount = Math.ceil(virtualListHeight.value / ITEM_HEIGHT) + 2 * BUFFER_ITEMS
|
||||
|
||||
return Array.from({ length: visibleCount }, (_, i) => {
|
||||
const index = startIndex + i
|
||||
if (index >= 0 && index < props.options.length) {
|
||||
return {
|
||||
index,
|
||||
option: props.options[index],
|
||||
}
|
||||
}
|
||||
return null
|
||||
}).filter((item): item is { index: number; option: OptionValue } => item !== null)
|
||||
return Array.from({ length: visibleCount }, (_, i) => {
|
||||
const index = startIndex + i
|
||||
if (index >= 0 && index < props.options.length) {
|
||||
return {
|
||||
index,
|
||||
option: props.options[index],
|
||||
}
|
||||
}
|
||||
return null
|
||||
}).filter((item): item is { index: number; option: OptionValue } => item !== null)
|
||||
})
|
||||
|
||||
const selectedOption = computed(() => {
|
||||
if (selectedValue.value !== null && selectedValue.value !== undefined) {
|
||||
return props.displayName(selectedValue.value as OptionValue)
|
||||
}
|
||||
return props.placeholder || 'Select an option'
|
||||
if (selectedValue.value !== null && selectedValue.value !== undefined) {
|
||||
return props.displayName(selectedValue.value as OptionValue)
|
||||
}
|
||||
return props.placeholder || 'Select an option'
|
||||
})
|
||||
|
||||
const radioValue = computed<OptionValue>({
|
||||
get() {
|
||||
return props.modelValue ?? selectedValue.value ?? ''
|
||||
},
|
||||
set(newValue: OptionValue) {
|
||||
emit('update:modelValue', newValue)
|
||||
selectedValue.value = newValue
|
||||
},
|
||||
get() {
|
||||
return props.modelValue ?? selectedValue.value ?? ''
|
||||
},
|
||||
set(newValue: OptionValue) {
|
||||
emit('update:modelValue', newValue)
|
||||
selectedValue.value = newValue
|
||||
},
|
||||
})
|
||||
|
||||
const triggerClasses = computed(() => ({
|
||||
'cursor-not-allowed opacity-50 grayscale': props.disabled,
|
||||
'rounded-b-none': dropdownVisible.value && !isRenderingUp.value && !props.disabled,
|
||||
'rounded-t-none': dropdownVisible.value && isRenderingUp.value && !props.disabled,
|
||||
'cursor-not-allowed opacity-50 grayscale': props.disabled,
|
||||
'rounded-b-none': dropdownVisible.value && !isRenderingUp.value && !props.disabled,
|
||||
'rounded-t-none': dropdownVisible.value && isRenderingUp.value && !props.disabled,
|
||||
}))
|
||||
|
||||
const updatePosition = async () => {
|
||||
if (!dropdown.value) return
|
||||
if (!dropdown.value) return
|
||||
|
||||
await nextTick()
|
||||
const triggerRect = dropdown.value.getBoundingClientRect()
|
||||
const viewportHeight = window.innerHeight
|
||||
const margin = 8
|
||||
await nextTick()
|
||||
const triggerRect = dropdown.value.getBoundingClientRect()
|
||||
const viewportHeight = window.innerHeight
|
||||
const margin = 8
|
||||
|
||||
const contentHeight = props.options.length * ITEM_HEIGHT
|
||||
const preferredHeight = Math.min(contentHeight, 300)
|
||||
const contentHeight = props.options.length * ITEM_HEIGHT
|
||||
const preferredHeight = Math.min(contentHeight, 300)
|
||||
|
||||
const spaceBelow = viewportHeight - triggerRect.bottom
|
||||
const spaceAbove = triggerRect.top
|
||||
const spaceBelow = viewportHeight - triggerRect.bottom
|
||||
const spaceAbove = triggerRect.top
|
||||
|
||||
isRenderingUp.value = spaceBelow < preferredHeight && spaceAbove > spaceBelow
|
||||
isRenderingUp.value = spaceBelow < preferredHeight && spaceAbove > spaceBelow
|
||||
|
||||
virtualListHeight.value = isRenderingUp.value
|
||||
? Math.min(spaceAbove - margin, preferredHeight)
|
||||
: Math.min(spaceBelow - margin, preferredHeight)
|
||||
virtualListHeight.value = isRenderingUp.value
|
||||
? Math.min(spaceAbove - margin, preferredHeight)
|
||||
: Math.min(spaceBelow - margin, preferredHeight)
|
||||
|
||||
positionStyle.value = {
|
||||
position: 'fixed',
|
||||
left: `${triggerRect.left}px`,
|
||||
width: `${triggerRect.width}px`,
|
||||
zIndex: 999,
|
||||
...(isRenderingUp.value
|
||||
? { bottom: `${viewportHeight - triggerRect.top}px`, top: 'auto' }
|
||||
: { top: `${triggerRect.bottom}px`, bottom: 'auto' }),
|
||||
}
|
||||
positionStyle.value = {
|
||||
position: 'fixed',
|
||||
left: `${triggerRect.left}px`,
|
||||
width: `${triggerRect.width}px`,
|
||||
zIndex: 999,
|
||||
...(isRenderingUp.value
|
||||
? { bottom: `${viewportHeight - triggerRect.top}px`, top: 'auto' }
|
||||
: { top: `${triggerRect.bottom}px`, bottom: 'auto' }),
|
||||
}
|
||||
}
|
||||
|
||||
const openDropdown = async () => {
|
||||
if (!props.disabled) {
|
||||
closeAllDropdowns()
|
||||
dropdownVisible.value = true
|
||||
focusedOptionIndex.value = props.options.findIndex((option) => option === selectedValue.value)
|
||||
lastFocusedElement.value = document.activeElement as HTMLElement
|
||||
await updatePosition()
|
||||
if (!props.disabled) {
|
||||
closeAllDropdowns()
|
||||
dropdownVisible.value = true
|
||||
focusedOptionIndex.value = props.options.findIndex((option) => option === selectedValue.value)
|
||||
lastFocusedElement.value = document.activeElement as HTMLElement
|
||||
await updatePosition()
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
updatePosition()
|
||||
})
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
updatePosition()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const toggleDropdown = () => {
|
||||
if (!props.disabled) {
|
||||
if (dropdownVisible.value) {
|
||||
closeDropdown()
|
||||
} else {
|
||||
openDropdown()
|
||||
}
|
||||
}
|
||||
if (!props.disabled) {
|
||||
if (dropdownVisible.value) {
|
||||
closeDropdown()
|
||||
} else {
|
||||
openDropdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleResize = () => {
|
||||
if (dropdownVisible.value) {
|
||||
requestAnimationFrame(() => {
|
||||
updatePosition()
|
||||
})
|
||||
}
|
||||
if (dropdownVisible.value) {
|
||||
requestAnimationFrame(() => {
|
||||
updatePosition()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleScroll = (event: Event) => {
|
||||
const target = event.target as HTMLElement
|
||||
scrollTop.value = target.scrollTop
|
||||
const target = event.target as HTMLElement
|
||||
scrollTop.value = target.scrollTop
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!dropdownVisible.value) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
lastFocusedElement.value = document.activeElement as HTMLElement
|
||||
toggleDropdown()
|
||||
}
|
||||
} else {
|
||||
handleDropdownKeyDown(event)
|
||||
}
|
||||
if (!dropdownVisible.value) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
lastFocusedElement.value = document.activeElement as HTMLElement
|
||||
toggleDropdown()
|
||||
}
|
||||
} else {
|
||||
handleDropdownKeyDown(event)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDropdownKeyDown = (event: KeyboardEvent) => {
|
||||
event.stopPropagation()
|
||||
event.stopPropagation()
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault()
|
||||
focusNextOption()
|
||||
break
|
||||
case 'ArrowUp':
|
||||
event.preventDefault()
|
||||
focusPreviousOption()
|
||||
break
|
||||
case 'Enter':
|
||||
event.preventDefault()
|
||||
if (focusedOptionIndex.value !== null) {
|
||||
selectOption(props.options[focusedOptionIndex.value], focusedOptionIndex.value)
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
closeDropdown()
|
||||
break
|
||||
case 'Tab':
|
||||
event.preventDefault()
|
||||
if (event.shiftKey) {
|
||||
focusPreviousOption()
|
||||
} else {
|
||||
focusNextOption()
|
||||
}
|
||||
break
|
||||
}
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault()
|
||||
focusNextOption()
|
||||
break
|
||||
case 'ArrowUp':
|
||||
event.preventDefault()
|
||||
focusPreviousOption()
|
||||
break
|
||||
case 'Enter':
|
||||
event.preventDefault()
|
||||
if (focusedOptionIndex.value !== null) {
|
||||
selectOption(props.options[focusedOptionIndex.value], focusedOptionIndex.value)
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
closeDropdown()
|
||||
break
|
||||
case 'Tab':
|
||||
event.preventDefault()
|
||||
if (event.shiftKey) {
|
||||
focusPreviousOption()
|
||||
} else {
|
||||
focusNextOption()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const closeDropdown = () => {
|
||||
dropdownVisible.value = false
|
||||
focusedOptionIndex.value = null
|
||||
if (lastFocusedElement.value) {
|
||||
lastFocusedElement.value.focus()
|
||||
lastFocusedElement.value = null
|
||||
}
|
||||
dropdownVisible.value = false
|
||||
focusedOptionIndex.value = null
|
||||
if (lastFocusedElement.value) {
|
||||
lastFocusedElement.value.focus()
|
||||
lastFocusedElement.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const closeAllDropdowns = () => {
|
||||
const event = new CustomEvent('close-all-dropdowns')
|
||||
window.dispatchEvent(event)
|
||||
const event = new CustomEvent('close-all-dropdowns')
|
||||
window.dispatchEvent(event)
|
||||
}
|
||||
|
||||
const selectOption = (option: OptionValue, index: number) => {
|
||||
radioValue.value = option
|
||||
emit('change', { option, index })
|
||||
closeDropdown()
|
||||
radioValue.value = option
|
||||
emit('change', { option, index })
|
||||
closeDropdown()
|
||||
}
|
||||
|
||||
const focusNextOption = () => {
|
||||
if (focusedOptionIndex.value === null) {
|
||||
focusedOptionIndex.value = 0
|
||||
} else {
|
||||
focusedOptionIndex.value = (focusedOptionIndex.value + 1) % props.options.length
|
||||
}
|
||||
scrollToFocused()
|
||||
nextTick(() => {
|
||||
focusedOptionRef.value?.focus()
|
||||
})
|
||||
if (focusedOptionIndex.value === null) {
|
||||
focusedOptionIndex.value = 0
|
||||
} else {
|
||||
focusedOptionIndex.value = (focusedOptionIndex.value + 1) % props.options.length
|
||||
}
|
||||
scrollToFocused()
|
||||
nextTick(() => {
|
||||
focusedOptionRef.value?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
const focusPreviousOption = () => {
|
||||
if (focusedOptionIndex.value === null) {
|
||||
focusedOptionIndex.value = props.options.length - 1
|
||||
} else {
|
||||
focusedOptionIndex.value =
|
||||
(focusedOptionIndex.value - 1 + props.options.length) % props.options.length
|
||||
}
|
||||
scrollToFocused()
|
||||
nextTick(() => {
|
||||
focusedOptionRef.value?.focus()
|
||||
})
|
||||
if (focusedOptionIndex.value === null) {
|
||||
focusedOptionIndex.value = props.options.length - 1
|
||||
} else {
|
||||
focusedOptionIndex.value =
|
||||
(focusedOptionIndex.value - 1 + props.options.length) % props.options.length
|
||||
}
|
||||
scrollToFocused()
|
||||
nextTick(() => {
|
||||
focusedOptionRef.value?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
const scrollToFocused = () => {
|
||||
if (focusedOptionIndex.value === null) return
|
||||
if (focusedOptionIndex.value === null) return
|
||||
|
||||
const optionsElement = optionsContainer.value?.querySelector('.overflow-y-auto')
|
||||
if (!optionsElement) return
|
||||
const optionsElement = optionsContainer.value?.querySelector('.overflow-y-auto')
|
||||
if (!optionsElement) return
|
||||
|
||||
const targetScrollTop = focusedOptionIndex.value * ITEM_HEIGHT
|
||||
const scrollBottom = optionsElement.clientHeight
|
||||
const targetScrollTop = focusedOptionIndex.value * ITEM_HEIGHT
|
||||
const scrollBottom = optionsElement.clientHeight
|
||||
|
||||
if (targetScrollTop < optionsElement.scrollTop) {
|
||||
optionsElement.scrollTop = targetScrollTop
|
||||
} else if (targetScrollTop + ITEM_HEIGHT > optionsElement.scrollTop + scrollBottom) {
|
||||
optionsElement.scrollTop = targetScrollTop - scrollBottom + ITEM_HEIGHT
|
||||
}
|
||||
if (targetScrollTop < optionsElement.scrollTop) {
|
||||
optionsElement.scrollTop = targetScrollTop
|
||||
} else if (targetScrollTop + ITEM_HEIGHT > optionsElement.scrollTop + scrollBottom) {
|
||||
optionsElement.scrollTop = targetScrollTop - scrollBottom + ITEM_HEIGHT
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', handleResize)
|
||||
window.addEventListener('scroll', handleResize, true)
|
||||
window.addEventListener('click', (event) => {
|
||||
if (!isChildOfDropdown(event.target as HTMLElement)) {
|
||||
closeDropdown()
|
||||
}
|
||||
})
|
||||
window.addEventListener('close-all-dropdowns', closeDropdown)
|
||||
window.addEventListener('resize', handleResize)
|
||||
window.addEventListener('scroll', handleResize, true)
|
||||
window.addEventListener('click', (event) => {
|
||||
if (!isChildOfDropdown(event.target as HTMLElement)) {
|
||||
closeDropdown()
|
||||
}
|
||||
})
|
||||
window.addEventListener('close-all-dropdowns', closeDropdown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
window.removeEventListener('scroll', handleResize, true)
|
||||
window.removeEventListener('click', (event) => {
|
||||
if (!isChildOfDropdown(event.target as HTMLElement)) {
|
||||
closeDropdown()
|
||||
}
|
||||
})
|
||||
window.removeEventListener('close-all-dropdowns', closeDropdown)
|
||||
lastFocusedElement.value = null
|
||||
window.removeEventListener('resize', handleResize)
|
||||
window.removeEventListener('scroll', handleResize, true)
|
||||
window.removeEventListener('click', (event) => {
|
||||
if (!isChildOfDropdown(event.target as HTMLElement)) {
|
||||
closeDropdown()
|
||||
}
|
||||
})
|
||||
window.removeEventListener('close-all-dropdowns', closeDropdown)
|
||||
lastFocusedElement.value = null
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
selectedValue.value = newValue
|
||||
},
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
selectedValue.value = newValue
|
||||
},
|
||||
)
|
||||
|
||||
watch(dropdownVisible, async (newValue) => {
|
||||
if (newValue) {
|
||||
await updatePosition()
|
||||
scrollTop.value = 0
|
||||
}
|
||||
if (newValue) {
|
||||
await updatePosition()
|
||||
scrollTop.value = 0
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,57 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
fadeOutStart?: boolean
|
||||
fadeOutEnd?: boolean
|
||||
}>(),
|
||||
{
|
||||
fadeOutStart: false,
|
||||
fadeOutEnd: false,
|
||||
},
|
||||
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>
|
||||
<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;
|
||||
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;
|
||||
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-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-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%
|
||||
);
|
||||
}
|
||||
&.fade-out-start.fade-out-end {
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
black,
|
||||
black calc(100% - 8rem),
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
<template>
|
||||
<input
|
||||
:id="id"
|
||||
type="checkbox"
|
||||
class="switch stylized-toggle"
|
||||
:disabled="disabled"
|
||||
:checked="checked"
|
||||
@change="checked = !checked"
|
||||
/>
|
||||
<input
|
||||
:id="id"
|
||||
type="checkbox"
|
||||
class="switch stylized-toggle"
|
||||
:disabled="disabled"
|
||||
:checked="checked"
|
||||
@change="checked = !checked"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
id?: string
|
||||
disabled?: boolean
|
||||
id?: string
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const checked = defineModel<boolean>()
|
||||
|
||||
Reference in New Issue
Block a user