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:
Cal H.
2025-08-14 21:48:38 +01:00
committed by GitHub
parent 82697278dc
commit 2aabcf36ee
702 changed files with 101360 additions and 102020 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 */

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>()