App redesign (#2946)

* Start of app redesign

* format

* continue progress

* Content page nearly done

* Fix recursion issues with content page

* Fix update all alignment

* Discover page progress

* Settings progress

* Removed unlocked-size hack that breaks web

* Revamp project page, refactor web project page to share code with app, fixed loading bar, misc UI/UX enhancements, update ko-fi logo, update arrow icons, fix web issues caused by floating-vue migration, fix tooltip issues, update web tooltips, clean up web hydration issues

* Ads + run prettier

* Begin auth refactor, move common messages to ui lib, add i18n extraction to all apps, begin Library refactor

* fix ads not hiding when plus log in

* rev lockfile changes/conflicts

* Fix sign in page

* Add generated

* (mostly) Data driven search

* Fix search mobile issue

* profile fixes

* Project versions page, fix typescript on UI lib and misc fixes

* Remove unused gallery component

* Fix linkfunction err

* Search filter controls at top, localization for locked filters

* Fix provided filter names

* Fix navigating from instance browse to main browse

* Friends frontend (#2995)

* Friends system frontend

* (almost) finish frontend

* finish friends, fix lint

* Fix lint

---------

Signed-off-by: Geometrically <18202329+Geometrically@users.noreply.github.com>

* Refresh macOS app icon

* Update web search UI more

* Fix link opens

* Fix frontend build

---------

Signed-off-by: Geometrically <18202329+Geometrically@users.noreply.github.com>
Co-authored-by: Jai A <jaiagr+gpg@pm.me>
Co-authored-by: Geometrically <18202329+Geometrically@users.noreply.github.com>
This commit is contained in:
Prospector
2024-12-11 19:54:18 -08:00
committed by GitHub
parent 6ec1dcf088
commit c39bb78e38
257 changed files with 15713 additions and 9475 deletions

View File

@@ -0,0 +1,91 @@
<template>
<div v-bind="$attrs">
<button
v-if="!!slots.title"
:class="buttonClass ?? 'flex flex-col gap-2'"
@click="() => (isOpen ? close() : open())"
>
<slot name="button" :open="isOpen">
<div class="flex items-center w-full">
<slot name="title" />
<DropdownIcon
class="ml-auto size-5 transition-transform duration-300 shrink-0 text-contrast"
:class="{ 'rotate-180': isOpen }"
/>
</div>
</slot>
<slot name="summary" />
</button>
<div class="accordion-content" :class="{ open: isOpen }">
<div>
<div :class="contentClass ? contentClass : ''" :inert="!isOpen">
<slot />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
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,
},
)
const isOpen = ref(props.openByDefault)
const emit = defineEmits(['onOpen', 'onClose'])
const slots = useSlots()
function open() {
isOpen.value = true
emit('onOpen')
}
function close() {
isOpen.value = false
emit('onClose')
}
defineExpose({
open,
close,
isOpen,
})
defineOptions({
inheritAttrs: false,
})
</script>
<style scoped>
.accordion-content {
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.open {
grid-template-rows: 1fr;
}
.accordion-content > div {
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,21 @@
<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>
</template>
<script setup lang="ts">
defineProps<{
to: any
}>()
defineOptions({
inheritAttrs: false,
})
</script>

View File

@@ -74,7 +74,7 @@
</span>
</template>
<script setup>
<script setup lang="ts">
import {
ModrinthIcon,
ScaleIcon,
@@ -172,16 +172,10 @@ const messages = defineMessages({
})
const { formatMessage } = useVIntl()
defineProps({
type: {
type: String,
required: true,
},
color: {
type: String,
default: '',
},
})
defineProps<{
type: string
color?: string
}>()
</script>
<style lang="scss" scoped>
.version-badge {

View File

@@ -126,7 +126,7 @@ function setColorFill(
const colorVariables = computed(() => {
if (props.highlighted) {
let colors = {
const colors = {
bg:
props.highlightedStyle === 'main-nav-primary'
? 'var(--color-brand-highlight)'
@@ -137,7 +137,7 @@ const colorVariables = computed(() => {
? 'var(--color-brand)'
: 'var(--color-contrast)',
}
let hoverColors = JSON.parse(JSON.stringify(colors))
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};`
}
@@ -186,6 +186,7 @@ const colorVariables = computed(() => {
}
/* Searches up to 4 children deep for valid button */
.btn-wrapper :deep(:is(button, a, .button-like):first-child),
.btn-wrapper :slotted(:is(button, a, .button-like):first-child),
.btn-wrapper :slotted(*) > :is(button, a, .button-like):first-child,
.btn-wrapper :slotted(*) > *:first-child > :is(button, a, .button-like):first-child,
@@ -194,7 +195,7 @@ const colorVariables = computed(() => {
> *:first-child
> *:first-child
> :is(button, a, .button-like):first-child {
@apply flex 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];
@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];
transition:
scale 0.125s ease-in-out,
background-color 0.25s ease-in-out,
@@ -202,6 +203,11 @@ const colorVariables = computed(() => {
svg:first-child {
color: var(--_icon, var(--_text));
transition: color 0.25s ease-in-out;
}
&:hover svg:first-child {
color: var(--_hover-text);
}
&[disabled],
@@ -222,6 +228,7 @@ const colorVariables = computed(() => {
}
}
.btn-wrapper.outline :deep(:is(button, a, .button-like):first-child),
.btn-wrapper.outline :slotted(:is(button, a, .button-like):first-child),
.btn-wrapper.outline :slotted(*) > :is(button, a, .button-like):first-child,
.btn-wrapper.outline :slotted(*) > *:first-child > :is(button, a, .button-like):first-child,
@@ -234,6 +241,7 @@ const colorVariables = computed(() => {
}
/*noinspection CssUnresolvedCustomProperty*/
.btn-wrapper :deep(: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(*) > :is(button, a, .button-like):first-child > svg:first-child,
.btn-wrapper
@@ -256,6 +264,7 @@ const colorVariables = computed(() => {
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,
@@ -266,6 +275,7 @@ const colorVariables = computed(() => {
}
> :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,

View File

@@ -6,14 +6,15 @@
@click="toggle"
>
<button
class="checkbox"
class="checkbox border-none"
role="checkbox"
:disabled="disabled"
:class="{ checked: modelValue, collapsing: collapsingToggleStyle }"
:aria-label="description"
:aria-checked="modelValue"
>
<CheckIcon v-if="modelValue && !collapsingToggleStyle" aria-hidden="true" />
<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 -->
@@ -24,7 +25,7 @@
</div>
</template>
<script setup lang="ts">
import { CheckIcon, DropdownIcon } from '@modrinth/assets'
import { CheckIcon, DropdownIcon, MinusIcon } from '@modrinth/assets'
const emit = defineEmits<{
'update:modelValue': [boolean]
@@ -32,12 +33,13 @@ const emit = defineEmits<{
const props = withDefaults(
defineProps<{
label: string
label?: string
disabled?: boolean
description: string
description?: string
modelValue: boolean
clickEvent?: () => void
collapsingToggleStyle?: boolean
indeterminate?: boolean
}>(),
{
label: '',
@@ -46,6 +48,7 @@ const props = withDefaults(
modelValue: false,
clickEvent: () => {},
collapsingToggleStyle: false,
indeterminate: false,
},
)
@@ -78,7 +81,6 @@ function toggle() {
align-items: center;
justify-content: center;
cursor: pointer;
outline: none;
min-width: 1rem;
min-height: 1rem;
@@ -95,10 +97,14 @@ function toggle() {
&.checked {
background-color: var(--color-brand);
svg {
color: var(--color-accent-contrast);
}
}
svg {
color: var(--color-accent-contrast);
color: var(--color-secondary);
stroke-width: 0.2rem;
height: 0.8rem;
width: 0.8rem;

View File

@@ -1,21 +0,0 @@
<template>
<router-link v-if="isLink" :to="to">
<slot />
</router-link>
<span v-else>
<slot />
</span>
</template>
<script setup>
defineProps({
to: {
type: String,
required: true,
},
isLink: {
type: Boolean,
required: true,
},
})
</script>

View File

@@ -1,6 +1,6 @@
<template>
<div
class="grid grid-cols-1 gap-x-8 gap-y-6 border-0 border-b border-solid border-button-bg pb-6 lg:grid-cols-[1fr_auto]"
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" />
@@ -11,10 +11,10 @@
</h1>
<slot name="title-suffix" />
</div>
<p class="m-0 line-clamp-2 max-w-[40rem]">
<p class="m-0 line-clamp-2 max-w-[40rem] empty:hidden">
<slot name="summary" />
</p>
<div class="mt-auto flex flex-wrap gap-4">
<div class="mt-auto flex flex-wrap gap-4 empty:hidden">
<slot name="stats" />
</div>
</div>

View File

@@ -22,7 +22,13 @@
}"
@click="toggleDropdown"
>
<span>{{ selectedOption }}</span>
<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 }">

View File

@@ -0,0 +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>
</template>
<script setup></script>
<style scoped>
.title {
position: absolute;
z-index: 1;
font-weight: bold;
color: var(--color-contrast);
&::after {
content: '';
animation: dots 2s infinite;
}
}
@keyframes dots {
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;
&::before {
content: '';
position: absolute;
inset: 0;
background-image: linear-gradient(
-45deg,
transparent 30%,
rgba(196, 217, 237, 0.075) 50%,
transparent 70%
);
animation: shimmer 4s ease-in-out infinite;
}
&:nth-child(2)::before {
animation-delay: 0s;
}
&:nth-child(3)::before {
animation-delay: 0.3s;
}
&:nth-child(4)::before {
animation-delay: 0.6s;
}
&:nth-child(2) {
animation-delay: 0s;
}
&:nth-child(3) {
animation-delay: 0.3s;
}
&:nth-child(4) {
animation-delay: 0.6s;
}
}
@keyframes pop {
from {
opacity: 0.25;
border-color: transparent;
}
50% {
opacity: 0.5;
border-color: var(--color-button-bg);
}
to {
opacity: 0.25;
border-color: transparent;
}
}
@keyframes shimmer {
from {
transform: translateX(-80%);
}
50%,
to {
transform: translateX(80%);
}
}
</style>

View File

@@ -6,6 +6,7 @@
:disabled="disabled"
:position="position"
:direction="direction"
:dropdown-id="dropdownId"
@open="
() => {
searchQuery = ''
@@ -15,15 +16,15 @@
<slot />
<DropdownIcon class="h-5 w-5 text-secondary" />
<template #menu>
<div class="iconified-input mb-2 w-full" v-if="search">
<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"
ref="searchInput"
@keydown.enter="
() => {
toggleOption(filteredOptions[0])
@@ -40,7 +41,7 @@
class="!w-full"
:color="manyValues.includes(option) ? 'secondary' : 'default'"
>
<slot name="option" :option="option">{{ displayName(option) }}</slot>
<slot name="option" :option="option">{{ displayName?.(option) }}</slot>
<CheckIcon
class="h-5 w-5 text-contrast ml-auto transition-opacity"
:class="{ 'opacity-0': !manyValues.includes(option) }"
@@ -56,7 +57,7 @@
class="!w-full"
:color="manyValues.includes(option) ? 'secondary' : 'default'"
>
<slot name="option" :option="option">{{ displayName(option) }}</slot>
<slot name="option" :option="option">{{ displayName?.(option) }}</slot>
<CheckIcon
class="h-5 w-5 text-contrast ml-auto transition-opacity"
:class="{ 'opacity-0': !manyValues.includes(option) }"
@@ -85,6 +86,7 @@ const props = withDefaults(
direction?: string
displayName?: (option: Option) => string
search?: boolean
dropdownId?: string
}>(),
{
disabled: false,

View File

@@ -368,7 +368,7 @@ onMounted(() => {
if (clipboardData.files && clipboardData.files.length > 0 && props.onImageUpload) {
// If the user is pasting a file, upload it if there's an included handler and insert the link.
uploadImagesFromList(clipboardData.files)
// eslint-disable-next-line func-names -- who the fuck did this?
.then(function (url) {
const selection = markdownCommands.yankSelection(view)
const altText = selection || 'Replace this with a description'

View File

@@ -1,5 +1,5 @@
<template>
<div class="vue-notification-group">
<div class="vue-notification-group" :class="{ 'has-sidebar': sidebar }">
<transition-group name="notifs">
<div
v-for="(item, index) in notifications"
@@ -20,6 +20,13 @@
<script setup>
import { ref } from 'vue'
const props = defineProps({
sidebar: {
type: Boolean,
default: false,
},
})
const notifications = ref([])
defineExpose({
@@ -90,6 +97,10 @@ function stopTimer(notif) {
z-index: 99999999;
width: 300px;
&.has-sidebar {
right: 325px;
}
.vue-notification-wrapper {
width: 100%;
overflow: hidden;

View File

@@ -3,8 +3,8 @@
ref="dropdown"
v-bind="$attrs"
:disabled="disabled"
:position="position"
:direction="direction"
:dropdown-id="dropdownId"
:tooltip="tooltip"
>
<slot></slot>
<template #menu>
@@ -17,10 +17,12 @@
<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) => {
@@ -33,6 +35,7 @@
"
:link="option.link ? option.link : null"
:external="option.external ? option.external : false"
:disabled="option.disabled"
@click="
() => {
if (option.link && !option.remainOnClick) {
@@ -80,6 +83,8 @@ interface Item extends BaseOption {
hoverFilled?: boolean
hoverFilledOnly?: boolean
remainOnClick?: boolean
disabled?: boolean
tooltip?: string
}
type Option = Divider | Item
@@ -88,14 +93,14 @@ const props = withDefaults(
defineProps<{
options: Option[]
disabled?: boolean
position?: string
direction?: string
dropdownId?: string
tooltip?: string
}>(),
{
options: () => [],
disabled: false,
position: 'auto',
direction: 'auto',
dropdownId: null,
tooltip: null,
},
)
@@ -106,7 +111,6 @@ defineOptions({
const dropdown = ref(null)
const close = () => {
console.log('closing!')
dropdown.value.hide()
}
</script>

View File

@@ -2,12 +2,16 @@
<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"
@@ -27,20 +31,29 @@
:color="page === item ? 'brand' : 'standard'"
:type="page === item ? 'standard' : 'transparent'"
>
<a :href="linkFunction(item)" @click.prevent="page !== item ? switchPage(item) : null">
<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>
</template>
@@ -68,7 +81,7 @@ const props = withDefaults(
)
const pages = computed(() => {
let pages: ('-' | number)[] = []
const pages: ('-' | number)[] = []
const first = 1
const last = props.count

View File

@@ -1,275 +1,91 @@
<template>
<div ref="dropdown" class="popup-container" tabindex="-1" :aria-expanded="dropdownVisible">
<button
v-bind="$attrs"
ref="dropdownButton"
:class="{ 'popout-open': dropdownVisible }"
:tabindex="tabInto ? -1 : 0"
@click="toggleDropdown"
>
<Dropdown
ref="dropdown"
theme="ribbit-popout"
no-auto-focus
:aria-id="dropdownId || null"
@hide="focusTrigger"
@apply-show="focusMenuChild"
>
<button ref="trigger" v-bind="$attrs" v-tooltip="tooltip">
<slot></slot>
</button>
<div
class="popup-menu"
:class="`position-${computedPosition}-${computedDirection} ${dropdownVisible ? 'visible' : ''}`"
:inert="!tabInto && !dropdownVisible"
>
<slot name="menu"> </slot>
</div>
</div>
<template #popper="{ hide }">
<button class="dummy-button" @focusin="hideAndFocusTrigger(hide)"></button>
<div ref="menu" class="contents">
<slot name="menu"> </slot>
</div>
<button class="dummy-button" @focusin="hideAndFocusTrigger(hide)"></button>
</template>
</Dropdown>
</template>
<script setup>
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { Dropdown } from 'floating-vue'
import { ref, defineOptions } from 'vue'
const props = defineProps({
disabled: {
type: Boolean,
default: false,
},
position: {
const trigger = ref()
const menu = ref()
const dropdown = ref()
defineProps({
dropdownId: {
type: String,
default: 'auto',
default: null,
required: false,
},
direction: {
tooltip: {
type: String,
default: 'auto',
},
tabInto: {
type: Boolean,
default: false,
default: null,
required: false,
},
})
function focusMenuChild() {
setTimeout(() => {
if (menu.value && menu.value.children && menu.value.children.length > 0) {
menu.value.children[0].focus()
}
}, 50)
}
function hideAndFocusTrigger(hide) {
hide()
focusTrigger()
}
function focusTrigger() {
trigger.value.focus()
}
defineOptions({
inheritAttrs: false,
})
const emit = defineEmits(['open', 'close'])
const dropdownVisible = ref(false)
const dropdown = ref(null)
const dropdownButton = ref(null)
const computedPosition = ref('bottom')
const computedDirection = ref('left')
function updateDirection() {
if (props.direction === 'auto') {
if (dropdownButton.value) {
const x = dropdownButton.value.getBoundingClientRect().left
computedDirection.value = x < window.innerWidth / 2 ? 'right' : 'left'
} else {
computedDirection.value = 'left'
}
} else {
computedDirection.value = props.direction
}
if (props.position === 'auto') {
if (dropdownButton.value) {
const y = dropdownButton.value.getBoundingClientRect().top
computedPosition.value = y < window.innerHeight / 2 ? 'bottom' : 'top'
} else {
computedPosition.value = 'bottom'
}
} else {
computedPosition.value = props.position
}
function hide() {
dropdown.value.hide()
}
const toggleDropdown = () => {
if (!props.disabled) {
dropdownVisible.value = !dropdownVisible.value
if (dropdownVisible.value) {
emit('open')
} else {
dropdownButton.value.focus()
emit('close')
}
}
}
const hide = () => {
dropdownVisible.value = false
dropdownButton.value.focus()
emit('close')
}
const show = () => {
dropdownVisible.value = true
emit('open')
function show() {
dropdown.value.show()
}
defineExpose({
show,
hide,
})
const handleClickOutside = (event) => {
if (!dropdown.value) return
const isContextMenuClick = event.button === 2 || event.which === 3
const elements = document.elementsFromPoint(event.clientX, event.clientY)
if (
(dropdown.value !== event.target &&
!elements.includes(dropdown.value) &&
!dropdown.value.contains(event.target)) ||
isContextMenuClick
) {
dropdownVisible.value = false
emit('close')
}
}
const handleKeyDown = (event) => {
if (event.key === 'Escape') {
hide()
}
}
onMounted(() => {
window.addEventListener('click', handleClickOutside)
window.addEventListener('mouseup', handleClickOutside)
window.addEventListener('resize', updateDirection)
window.addEventListener('scroll', updateDirection)
window.addEventListener('keydown', handleKeyDown)
updateDirection()
})
onBeforeUnmount(() => {
window.removeEventListener('click', handleClickOutside)
window.removeEventListener('mouseup', handleClickOutside)
window.removeEventListener('resize', updateDirection)
window.removeEventListener('scroll', updateDirection)
window.removeEventListener('keydown', handleKeyDown)
})
</script>
<style lang="scss" scoped>
.popup-container {
position: relative;
.popup-menu {
--_animation-offset: -1rem;
position: absolute;
scale: 0.75;
border: 1px solid var(--color-button-bg);
padding: var(--gap-sm);
width: fit-content;
border-radius: var(--radius-md);
background-color: var(--color-raised-bg);
box-shadow: var(--shadow-floating);
z-index: 10;
opacity: 0;
transition:
bottom 0.125s ease-in-out,
top 0.125s ease-in-out,
left 0.125s ease-in-out,
right 0.125s ease-in-out,
opacity 0.125s ease-in-out,
scale 0.125s ease-in-out;
@media (prefers-reduced-motion) {
transition: none !important;
}
&.position-bottom-left {
top: calc(100% + var(--gap-sm) - 1rem);
right: -1rem;
}
&.position-bottom-right {
top: calc(100% + var(--gap-sm) - 1rem);
left: -1rem;
}
&.position-top-left {
bottom: calc(100% + var(--gap-sm) - 1rem);
right: -1rem;
}
&.position-top-right {
bottom: calc(100% + var(--gap-sm) - 1rem);
left: -1rem;
}
&.position-left-up {
bottom: -1rem;
right: calc(100% + var(--gap-sm) - 1rem);
}
&.position-left-down {
top: -1rem;
right: calc(100% + var(--gap-sm) - 1rem);
}
&.position-right-up {
bottom: -1rem;
left: calc(100% + var(--gap-sm) - 1rem);
}
&.position-right-down {
top: -1rem;
left: calc(100% + var(--gap-sm) - 1rem);
}
&:not(.visible):not(:focus-within) {
pointer-events: none;
*,
::before,
::after {
pointer-events: none;
}
}
&.visible,
&:focus-within {
opacity: 1;
scale: 1;
&.position-bottom-left {
top: calc(100% + var(--gap-sm));
right: 0;
}
&.position-bottom-right {
top: calc(100% + var(--gap-sm));
left: 0;
}
&.position-top-left {
bottom: calc(100% + var(--gap-sm));
right: 0;
}
&.position-top-right {
bottom: calc(100% + var(--gap-sm));
left: 0;
}
&.position-left-up {
bottom: 0rem;
right: calc(100% + var(--gap-sm));
}
&.position-left-down {
top: 0rem;
right: calc(100% + var(--gap-sm));
}
&.position-right-up {
bottom: 0rem;
left: calc(100% + var(--gap-sm));
}
&.position-right-down {
top: 0rem;
left: calc(100% + var(--gap-sm));
}
}
.btn {
white-space: nowrap;
}
}
<style scoped>
.dummy-button {
position: absolute;
width: 0;
height: 0;
margin: 0;
padding: 0;
border: none;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
outline: none;
}
</style>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import { RadioButtonIcon, RadioButtonChecked } from '@modrinth/assets'
import { ref } from 'vue'
withDefaults(
defineProps<{
checked: boolean
}>(),
{
checked: false,
},
)
</script>
<template>
<div class="" role="button" @click="() => {}">
<slot name="preview" />
<div>
<RadioButtonIcon v-if="!checked" class="w-4 h-4" />
<RadioButtonChecked v-else class="w-4 h-4" />
<slot />
</div>
</div>
</template>

View File

@@ -74,7 +74,7 @@ import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime.js'
import { defineComponent } from 'vue'
import Categories from '../search/Categories.vue'
import Badge from './Badge.vue'
import Badge from './SimpleBadge.vue'
import Avatar from './Avatar.vue'
import EnvironmentIndicator from './EnvironmentIndicator.vue'
</script>

View File

@@ -1,10 +1,10 @@
<template>
<div class="scrollable-pane-wrapper" :class="{ 'max-height': !props.noMaxHeight }">
<div class="scrollable-pane-wrapper">
<div
class="wrapper-wrapper"
:class="{
'top-fade': !scrollableAtTop && !props.noMaxHeight,
'bottom-fade': !scrollableAtBottom && !props.noMaxHeight,
'top-fade': !scrollableAtTop && !props.disableScrolling,
'bottom-fade': !scrollableAtBottom && !props.disableScrolling,
}"
>
<div ref="scrollablePane" class="scrollable-pane" @scroll="onScroll">
@@ -19,10 +19,10 @@ import { ref, onMounted, onUnmounted } from 'vue'
const props = withDefaults(
defineProps<{
noMaxHeight?: boolean
disableScrolling?: boolean
}>(),
{
noMaxHeight: false,
disableScrolling: false,
},
)
@@ -49,7 +49,7 @@ onUnmounted(() => {
})
function updateFade(scrollTop, offsetHeight, scrollHeight) {
scrollableAtBottom.value = Math.ceil(scrollTop + offsetHeight) >= scrollHeight
scrollableAtTop.value = scrollTop === 0
scrollableAtTop.value = scrollTop <= 0
}
function onScroll({ target: { scrollTop, offsetHeight, scrollHeight } }) {
updateFade(scrollTop, offsetHeight, scrollHeight)
@@ -61,47 +61,26 @@ function onScroll({ target: { scrollTop, offsetHeight, scrollHeight } }) {
display: flex;
flex-direction: column;
position: relative;
&.max-height {
max-height: 19rem;
}
}
.wrapper-wrapper {
flex-grow: 1;
display: flex;
overflow: hidden;
position: relative;
--_fade-height: 4rem;
margin-bottom: var(--gap-sm);
&.top-fade::before,
&.bottom-fade::after {
opacity: 1;
&.top-fade {
mask-image: linear-gradient(transparent, rgb(0 0 0 / 100%) var(--_fade-height));
}
&::before,
&::after {
content: '';
left: 0;
right: 0;
opacity: 0;
position: absolute;
pointer-events: none;
transition: opacity 0.125s ease;
height: var(--_fade-height);
z-index: 1;
&.bottom-fade {
mask-image: linear-gradient(rgb(0 0 0 / 100%) calc(100% - var(--_fade-height)), transparent 100%);
}
&::before {
top: 0;
background-image: linear-gradient(
var(--scrollable-pane-bg, var(--color-raised-bg)),
transparent
);
}
&::after {
bottom: 0;
background-image: linear-gradient(
transparent,
var(--scrollable-pane-bg, var(--color-raised-bg))
);
&.top-fade.bottom-fade {
mask-image: linear-gradient(transparent, rgb(0 0 0 / 100%) var(--_fade-height), rgb(0 0 0 / 100%) calc(100% - var(--_fade-height)), transparent 100%);
}
}
.scrollable-pane {
@@ -113,26 +92,5 @@ function onScroll({ target: { scrollTop, offsetHeight, scrollHeight } }) {
overflow-y: auto;
overflow-x: hidden;
position: relative;
::-webkit-scrollbar {
transition: all;
}
&::-webkit-scrollbar {
width: var(--gap-md);
border: 3px solid var(--color-bg);
}
&::-webkit-scrollbar-track {
background: var(--color-bg);
border: 3px solid var(--color-bg);
}
&::-webkit-scrollbar-thumb {
background-color: var(--color-raised-bg);
border-radius: var(--radius-lg);
padding: 4px;
border: 3px solid var(--color-bg);
}
}
</style>

View File

@@ -0,0 +1,18 @@
<template>
<span
class="inline-flex items-center gap-1 font-semibold text-secondary"
>
<component :is="icon" v-if="icon" :aria-hidden="true" class="shrink-0" />
{{ formattedName }}
</span>
</template>
<script setup lang="ts">
import type { Component } from 'vue'
defineProps<{
icon?: Component
formattedName: string
color?: 'brand' | 'green' | 'blue' | 'purple' | 'orange' | 'red'
}>()
</script>

View File

@@ -0,0 +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>
</template>
<script setup lang="ts">
defineProps<{
action?: (event: MouseEvent) => void
}>()
</script>