refactor: Button components (#6929)

* feat: refactor button components

* fix: qa

* fix: storybook

* a11y: pass

* fix: build

* fix: rename default ->md

* fix: lint

* docs: buttons.md

* refactor part 1

* refactor part 2

* fix: undo refactor for fresh restart

* refactor

* Revert "refactor"

This reverts commit 96d65902d7.

* refactor: part 1

* fix: qa

* fix: qa

* fix: qa

* fix: qa

* fix: qa

* fix: qa

* fix: qa

* fix: qa

* fix: remove text-contrast

* fix: qa

* fix: v-tooltip

* fix: lint

* fix: split broken

* fix: passkey qa

* fix: qa

* fix: qa

* fix: prepr

* fix: splitbutton

* improve button group seam

---------

Signed-off-by: Calum H. <calum@modrinth.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-08-04 13:54:04 -07:00
committed by GitHub
co-authored by Prospector
parent 6cfe999cb3
commit bb2193b6f5
398 changed files with 13688 additions and 13133 deletions
@@ -18,12 +18,16 @@
</div>
<div class="ml-auto flex items-center gap-2">
<slot />
<ButtonStyled v-if="showRevoke" color="red" color-fill="text">
<button @click="emit('revoke', affiliate)">
<XCircleIcon />
{{ formatMessage(messages.revokeAffiliateLink) }}
</button>
</ButtonStyled>
<Button
v-if="showRevoke"
type="quiet"
color="red"
class="!text-red [&>svg]:!text-red"
@click="emit('revoke', affiliate)"
>
<XCircleIcon />
{{ formatMessage(messages.revokeAffiliateLink) }}
</Button>
</div>
</div>
<CopyCode :text="`https://modrinth.gg?afl=${affiliate.id}`" />
@@ -34,8 +38,10 @@
import type { Labrinth } from '@modrinth/api-client'
import { AffiliateIcon, XCircleIcon } from '@modrinth/assets'
import { Button } from '#ui/components/base/buttons'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { AutoBrandIcon, ButtonStyled, CopyCode } from '../index'
import { AutoBrandIcon, CopyCode } from '../index'
type AffiliateCode = Labrinth.Affiliate.Internal.AffiliateCode
@@ -46,13 +46,16 @@
input-class="pl-10"
/>
</div>
<ButtonStyled color="brand">
<button :disabled="creatingLink || !canCreate" @click="createAffiliateLink">
<SpinnerIcon v-if="creatingLink" class="animate-spin" />
<PlusIcon v-else />
{{ formatMessage(creatingLink ? messages.creatingButton : messages.createButton) }}
</button>
</ButtonStyled>
<Button
type="colored"
color="brand"
:disabled="creatingLink || !canCreate"
@click="createAffiliateLink"
>
<SpinnerIcon v-if="creatingLink" class="animate-spin" />
<PlusIcon v-else />
{{ formatMessage(creatingLink ? messages.creatingButton : messages.createButton) }}
</Button>
</div>
</div>
</NewModal>
@@ -62,9 +65,11 @@
import { AffiliateIcon, PlusIcon, SpinnerIcon, UserIcon } from '@modrinth/assets'
import { computed, ref, useTemplateRef } from 'vue'
import { Button } from '#ui/components/base/buttons'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { commonMessages } from '../../utils/common-messages'
import { AutoBrandIcon, ButtonStyled, NewModal, StyledInput } from '../index'
import { AutoBrandIcon, NewModal, StyledInput } from '../index'
export type CreateAffiliateProps = { sourceName: string; username?: string }
const props = withDefaults(
+16 -10
View File
@@ -58,17 +58,22 @@
class="col-start-3 row-start-1 flex shrink-0 items-center gap-2 self-start"
>
<slot name="top-right-actions" />
<ButtonStyled
<IconButton
v-if="dismissible"
circular
type="transparent"
:color="buttonColors[type]"
hover-color-fill="background"
type="quiet"
:color="
buttonColors[type] && buttonColors[type] !== 'standard'
? buttonColors[type] === 'medal-promo'
? 'medal_promotion'
: buttonColors[type]
: undefined
"
label="Dismiss"
native-type="button"
@click="$emit('dismiss')"
>
<button type="button" aria-label="Dismiss" @click="$emit('dismiss')">
<XIcon />
</button>
</ButtonStyled>
<XIcon />
</IconButton>
</div>
<div
v-if="progress != null"
@@ -96,9 +101,10 @@ import { ClockIcon, XIcon } from '@modrinth/assets'
import { useNow } from '@vueuse/core'
import { computed } from 'vue'
import { IconButton } from '#ui/components/base/buttons'
import { useFormatDateTime, useRelativeTime } from '../../composables'
import { getSeverityIcon } from '../../utils'
import ButtonStyled from './ButtonStyled.vue'
const props = withDefaults(
defineProps<{
@@ -12,11 +12,9 @@
/>
</Transition>
<div v-if="!isAtBottom" class="absolute bottom-4 right-4 z-10">
<ButtonStyled circular type="highlight" size="large">
<button class="!shadow-2xl" aria-label="Scroll to bottom" @click="scrollToBottom">
<ChevronDownIcon />
</button>
</ButtonStyled>
<IconButton size="xl" label="Scroll to bottom" class="!shadow-2xl" @click="scrollToBottom">
<ChevronDownIcon />
</IconButton>
</div>
</div>
<div
@@ -43,7 +41,7 @@ import { ChevronDownIcon, TerminalSquareIcon } from '@modrinth/assets'
import type { Terminal } from '@xterm/xterm'
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import { IconButton } from '#ui/components/base/buttons'
import StyledInput from '#ui/components/base/StyledInput.vue'
import { useTerminal } from '#ui/composables/terminal'
-142
View File
@@ -1,142 +0,0 @@
<script setup>
import { ExternalIcon, UnknownIcon } from '@modrinth/assets'
import { computed } from 'vue'
const props = defineProps({
link: {
type: String,
default: null,
},
external: {
type: Boolean,
default: false,
},
download: {
type: String,
default: null,
},
action: {
type: Function,
default: null,
},
color: {
type: String,
default: 'default',
},
iconOnly: {
type: Boolean,
default: false,
},
large: {
type: Boolean,
default: false,
},
outline: {
type: Boolean,
default: false,
},
transparent: {
type: Boolean,
default: false,
},
hoverFilled: {
type: Boolean,
default: false,
},
hoverFilledOnly: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
})
const accentedButton = computed(() =>
['danger', 'primary', 'red', 'orange', 'green', 'blue', 'purple', 'gray'].includes(props.color),
)
const classes = computed(() => {
const color = props.color
return {
'icon-only': props.iconOnly,
'btn-large': props.large,
'btn-danger': color === 'danger',
'btn-primary': color === 'primary',
'btn-secondary': color === 'secondary',
'btn-highlight': color === 'highlight',
'btn-red': color === 'red',
'btn-orange': color === 'orange',
'btn-green': color === 'green',
'btn-blue': color === 'blue',
'btn-purple': color === 'purple',
'btn-gray': color === 'gray',
'btn-transparent': props.transparent,
'btn-hover-filled': props.hoverFilled,
'btn-hover-filled-only': props.hoverFilledOnly,
'btn-outline': props.outline,
'color-accent-contrast': accentedButton,
disabled: props.disabled,
}
})
</script>
<template>
<router-link
v-if="link && link.startsWith('/')"
class="btn"
:class="classes"
:to="disabled ? '' : link"
:target="external ? '_blank' : '_self'"
@click="
(event) => {
if (disabled) {
event.preventDefault()
return
}
if (action) {
action(event)
}
}
"
>
<slot />
<ExternalIcon v-if="external && !iconOnly" class="external-icon" />
<UnknownIcon v-if="!$slots.default" />
</router-link>
<a
v-else-if="link"
class="btn"
:class="classes"
:href="disabled ? undefined : link"
:download="download || undefined"
:target="external ? '_blank' : '_self'"
@click="
(event) => {
if (disabled) {
event.preventDefault()
return
}
if (action) {
action(event)
}
}
"
>
<slot />
<ExternalIcon v-if="external && !iconOnly" class="external-icon" />
<UnknownIcon v-if="!$slots.default" />
</a>
<button v-else class="btn" :class="classes" :disabled="disabled" @click="action">
<slot />
<UnknownIcon v-if="!$slots.default" />
</button>
</template>
<style lang="scss" scoped>
:where(button) {
background: none;
color: var(--color-base);
}
</style>
@@ -1,397 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
color?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple' | 'medal-promo'
size?: 'standard' | 'large' | 'small'
circular?: boolean
type?: 'standard' | 'outlined' | 'transparent' | 'highlight' | 'highlight-colored-text' | 'chip'
colorFill?: 'auto' | 'background' | 'text' | 'none'
hoverColorFill?: 'auto' | 'background' | 'text' | 'none'
highlightedStyle?: 'main-nav-primary' | 'main-nav-secondary'
highlighted?: boolean
}>(),
{
color: 'standard',
size: 'standard',
circular: false,
type: 'standard',
colorFill: 'auto',
hoverColorFill: 'auto',
highlightedStyle: 'main-nav-primary',
highlighted: false,
},
)
const highlightedColorVar = computed(() => {
switch (props.color) {
case 'brand':
return 'var(--color-brand-highlight)'
case 'red':
return 'var(--color-red-highlight)'
case 'orange':
return 'var(--color-orange-highlight)'
case 'green':
return 'var(--color-green-highlight)'
case 'medal-promo':
case 'blue':
return 'var(--color-blue-highlight)'
case 'purple':
return 'var(--color-purple-highlight)'
case 'standard':
default:
return null
}
})
const colorVar = computed(() => {
switch (props.color) {
case 'brand':
return 'var(--color-brand)'
case 'red':
return 'var(--color-red)'
case 'orange':
return 'var(--color-orange)'
case 'green':
return 'var(--color-green)'
case 'blue':
return 'var(--color-blue)'
case 'purple':
return 'var(--color-purple)'
case 'medal-promo':
return 'var(--medal-promotion-text-orange)'
case 'standard':
default:
return null
}
})
const height = computed(() => {
if (props.size === 'large') {
return '3rem'
} else if (props.size === 'small') {
return '1.5rem'
}
return '2.25rem'
})
const width = computed(() => {
if (props.size === 'large') {
return props.circular ? '3rem' : 'auto'
} else if (props.size === 'small') {
return props.circular ? '1.5rem' : 'auto'
}
return props.circular ? '2.25rem' : 'auto'
})
const paddingX = computed(() => {
let padding = props.circular ? '0.5rem' : '0.75rem'
if (props.size === 'large') {
padding = props.circular ? '0.75rem' : '1rem'
} else if (props.size === 'small') {
padding = props.circular ? '0.125rem' : '0.5rem'
}
return `calc(${padding} - 0.125rem)`
})
const paddingY = computed(() => {
if (props.size === 'large') {
return '0.75rem'
}
return '0.5rem'
})
const gap = computed(() => {
if (props.size === 'large') {
return '0.5rem'
} else if (props.size === 'small') {
return '0.25rem'
}
return '0.375rem'
})
const fontWeight = computed(() => {
if (props.size === 'large') {
return '800'
}
return '600'
})
const radius = computed(() => {
if (props.circular) {
return '99999px'
}
if (props.size === 'large') {
return '1rem'
} else if (props.size === 'small') {
return '0.5rem'
}
return '0.75rem'
})
const iconSize = computed(() => {
if (props.size === 'large') {
return '1.5rem'
} else if (props.size === 'small') {
return '1rem'
}
return '1.25rem'
})
function setColorFill(
colors: { bg: string; text: string },
fill: 'background' | 'text' | 'none',
): { bg: string; text: string } {
if (colorVar.value) {
if (fill === 'background') {
if (props.type === 'highlight' && highlightedColorVar.value) {
colors.bg = highlightedColorVar.value
colors.text = 'var(--color-contrast)'
} else if (props.type === 'highlight-colored-text' && highlightedColorVar.value) {
colors.bg = highlightedColorVar.value
colors.text = colorVar.value
} else {
colors.bg = colorVar.value
colors.text = 'var(--color-accent-contrast)'
}
} else if (fill === 'text') {
colors.text = colorVar.value
}
}
return colors
}
const colorVariables = computed(() => {
const defaultShadow =
props.type === 'standard' || props.type === 'highlight' || props.highlighted
? 'var(--shadow-button)'
: 'none'
if (props.highlighted) {
const colors = {
bg:
props.highlightedStyle === 'main-nav-primary'
? 'var(--color-button-bg-selected)'
: 'var(--color-button-bg)',
text:
props.highlightedStyle === 'main-nav-primary'
? 'var(--color-button-text-selected)'
: 'var(--color-contrast)',
icon:
props.type === 'chip'
? 'var(--color-contrast)'
: props.highlightedStyle === 'main-nav-primary'
? 'var(--color-button-text-selected)'
: 'var(--color-contrast)',
}
const hoverColors = JSON.parse(JSON.stringify(colors))
const boxShadow =
props.type === 'chip' && colorVar.value ? `0 0 0 1px ${colorVar.value}` : defaultShadow
return `--_bg: ${colors.bg}; --_text: ${colors.text}; --_icon: ${colors.icon}; --_hover-bg: ${hoverColors.bg}; --_hover-text: ${hoverColors.text}; --_hover-icon: ${hoverColors.icon}; --_box-shadow: ${boxShadow};`
}
let colors = {
bg: 'var(--color-button-bg)',
text: 'var(--color-base)',
}
let hoverColors = JSON.parse(JSON.stringify(colors))
if (props.type === 'outlined') {
hoverColors.bg = 'transparent'
}
if (props.type === 'outlined' || props.type === 'transparent') {
colors.bg = 'transparent'
colors = setColorFill(colors, props.colorFill === 'auto' ? 'text' : props.colorFill)
hoverColors = setColorFill(
hoverColors,
props.hoverColorFill === 'auto' ? 'text' : props.hoverColorFill,
)
} else if (props.type === 'chip') {
// Chip type uses highlight-colored-text styling when colored
if (colorVar.value && highlightedColorVar.value) {
colors.bg = highlightedColorVar.value
colors.text = colorVar.value
hoverColors.bg = highlightedColorVar.value
hoverColors.text = colorVar.value
}
} else {
colors = setColorFill(colors, props.colorFill === 'auto' ? 'background' : props.colorFill)
hoverColors = setColorFill(
hoverColors,
props.hoverColorFill === 'auto' ? 'background' : props.hoverColorFill,
)
}
const boxShadow =
props.type === 'chip' && colorVar.value ? `0 0 0 1px ${colorVar.value}` : defaultShadow
return `--_bg: ${colors.bg}; --_text: ${colors.text}; --_hover-bg: ${hoverColors.bg}; --_hover-text: ${hoverColors.text}; --_box-shadow: ${boxShadow};`
})
const fontSize = computed(() => {
if (props.size === 'small') {
return 'text-sm'
}
return 'text-base'
})
</script>
<template>
<div
class="btn-wrapper"
:class="[
{ outline: type === 'outlined', transparent: type === 'transparent', chip: type === 'chip' },
fontSize,
]"
:style="`${colorVariables}--_height:${height};--_width:${width};--_radius: ${radius};--_padding-x:${paddingX};--_padding-y:${paddingY};--_gap:${gap};--_font-weight:${fontWeight};--_icon-size:${iconSize};--_outline-color:${color === 'standard' && type === 'outlined' ? 'var(--surface-5)' : 'currentColor'}`"
>
<slot />
</div>
</template>
<style scoped lang="scss">
.btn-wrapper {
display: contents;
}
/* Searches up to 4 children deep for valid button */
.btn-wrapper :deep(:is(button, a, .button-like):first-child),
.btn-wrapper :slotted(:is(button, a, .button-like):first-child),
.btn-wrapper :slotted(*) > :is(button, a, .button-like):first-child,
.btn-wrapper :slotted(*) > *:first-child > :is(button, a, .button-like):first-child,
.btn-wrapper
:slotted(*)
> *:first-child
> *:first-child
> :is(button, a, .button-like):first-child {
@apply flex touch-manipulation cursor-pointer flex-row items-center justify-center border-solid border border-transparent bg-[--_bg] text-[--_text] h-[--_height] min-w-[--_width] rounded-[--_radius] px-[--_padding-x] py-[--_padding-y] gap-[--_gap] font-[--_font-weight] whitespace-nowrap;
box-shadow: var(--_box-shadow, inset 0 0 0 transparent);
transition:
scale 0.125s ease-in-out,
background-color 0.25s ease-in-out,
color 0.25s ease-in-out,
filter 0.25s ease-in-out;
svg:first-child {
color: var(--_icon, var(--_text));
transition: color 0.25s ease-in-out;
flex-shrink: 0;
}
&[disabled]:not([disabled='false']),
&[disabled='true'],
&.disabled,
&.looks-disabled {
@apply opacity-50;
}
&[disabled]:not([disabled='false']),
&[disabled='true'],
&.disabled {
@apply cursor-not-allowed;
}
&:not([disabled]:not([disabled='false'])):not([disabled='true']):not(.disabled) {
@apply hover:brightness-[--hover-brightness] focus-visible:brightness-[--hover-brightness] hover:bg-[--_hover-bg] hover:text-[--_hover-text] focus-visible:bg-[--_hover-bg] focus-visible:text-[--_hover-text];
&:hover svg:first-child,
&:focus-visible svg:first-child {
color: var(--_hover-icon, var(--_hover-text));
}
}
}
.btn-wrapper:not(.chip) :deep(:is(button, a, .button-like):first-child),
.btn-wrapper:not(.chip) :slotted(:is(button, a, .button-like):first-child),
.btn-wrapper:not(.chip) :slotted(*) > :is(button, a, .button-like):first-child,
.btn-wrapper:not(.chip) :slotted(*) > *:first-child > :is(button, a, .button-like):first-child,
.btn-wrapper:not(.chip)
:slotted(*)
> *:first-child
> *:first-child
> :is(button, a, .button-like):first-child {
&:not([disabled]:not([disabled='false'])):not([disabled='true']):not(.disabled) {
@apply active:scale-95;
}
}
.disable-advanced-rendering {
.btn-wrapper:not(.outline):not(.transparent) :deep(:is(button, a, .button-like):first-child),
.btn-wrapper:not(.outline):not(.transparent) :slotted(:is(button, a, .button-like):first-child),
.btn-wrapper:not(.outline):not(.transparent)
:slotted(*)
> :is(button, a, .button-like):first-child,
.btn-wrapper:not(.outline):not(.transparent)
:slotted(*)
> *:first-child
> :is(button, a, .button-like):first-child,
.btn-wrapper
:slotted(*)
> *:first-child
> *:first-child
> :is(button, a, .button-like):first-child {
@apply border border-[rgba(0,0,0,0.2)];
}
}
.btn-wrapper.outline :deep(:is(button, a, .button-like):first-child),
.btn-wrapper.outline :slotted(:is(button, a, .button-like):first-child),
.btn-wrapper.outline :slotted(*) > :is(button, a, .button-like):first-child,
.btn-wrapper.outline :slotted(*) > *:first-child > :is(button, a, .button-like):first-child,
.btn-wrapper.outline
:slotted(*)
> *:first-child
> *:first-child
> :is(button, a, .button-like):first-child {
@apply border-[--_outline-color,currentColor];
}
/*noinspection CssUnresolvedCustomProperty*/
.btn-wrapper :deep(:is(button, a, .button-like):first-child) > svg,
.btn-wrapper :slotted(:is(button, a, .button-like):first-child) > svg,
.btn-wrapper :slotted(*) > :is(button, a, .button-like):first-child > svg,
.btn-wrapper :slotted(*) > *:first-child > :is(button, a, .button-like):first-child > svg,
.btn-wrapper
:slotted(*)
> *:first-child
> *:first-child
> :is(button, a, .button-like):first-child
> svg {
display: block;
width: var(--_icon-size, 1rem);
height: var(--_icon-size, 1rem);
min-width: var(--_icon-size, 1rem);
min-height: var(--_icon-size, 1rem);
}
.joined-buttons {
display: flex;
gap: 1px;
> .btn-wrapper:not(:first-child) {
:deep(:is(button, a, .button-like):first-child),
:slotted(:is(button, a, .button-like):first-child),
:slotted(*) > :is(button, a, .button-like):first-child,
:slotted(*) > *:first-child > :is(button, a, .button-like):first-child,
:slotted(*) > *:first-child > *:first-child > :is(button, a, .button-like):first-child {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
> :not(:last-child) {
:deep(:is(button, a, .button-like):first-child),
:slotted(:is(button, a, .button-like):first-child),
:slotted(*) > :is(button, a, .button-like):first-child,
:slotted(*) > *:first-child > :is(button, a, .button-like):first-child,
:slotted(*) > *:first-child > *:first-child > :is(button, a, .button-like):first-child {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
}
}
/* guys, I know this is nuts, I know */
</style>
+4 -6
View File
@@ -2,7 +2,7 @@
import { DropdownIcon } from '@modrinth/assets'
import { reactive } from 'vue'
import ButtonStyled from './ButtonStyled.vue'
import { IconButton } from '#ui/components/base/buttons'
const props = defineProps({
collapsible: {
@@ -33,11 +33,9 @@ function toggleCollapsed() {
<div v-if="!!$slots.header || collapsible" class="header">
<slot name="header"></slot>
<div v-if="collapsible" class="btn-group">
<ButtonStyled circular>
<button @click="toggleCollapsed">
<DropdownIcon :style="{ transform: `rotate(${state.collapsed ? 0 : 180}deg)` }" />
</button>
</ButtonStyled>
<IconButton label="Toggle details" @click="toggleCollapsed">
<DropdownIcon :style="{ transform: `rotate(${state.collapsed ? 0 : 180}deg)` }" />
</IconButton>
</div>
</div>
<slot v-if="!state.collapsed" />
+6 -2
View File
@@ -15,7 +15,11 @@
}"
@click="toggleItem(item)"
>
<CheckIcon v-if="selected === item && !hideCheckmarkIcon" />
<CheckIcon
v-if="selected === item && !hideCheckmarkIcon"
class="!text-brand"
aria-hidden="true"
/>
<span>{{ formatLabel(item) }}</span>
</Button>
</div>
@@ -24,7 +28,7 @@
<script setup lang="ts" generic="T">
import { CheckIcon } from '@modrinth/assets'
import Button from './Button.vue'
import Button from './buttons/Button.vue'
const props = withDefaults(
defineProps<{
@@ -17,24 +17,26 @@
</span>
</div>
<div class="flex items-center gap-2">
<ButtonStyled circular type="highlight-colored-text" :color="buttonColors[type]">
<button aria-label="Toggle" @click.stop="expanded = !expanded">
<ChevronDownIcon
class="h-4 w-4 transition-transform duration-300"
:class="expanded && 'rotate-180'"
/>
</button>
</ButtonStyled>
<ButtonStyled
v-if="dismissible"
circular
type="highlight-colored-text"
<IconButton
type="quiet"
:color="buttonColors[type]"
label="Toggle"
@click.stop="expanded = !expanded"
>
<button aria-label="Dismiss" @click.stop="handleDismiss">
<XIcon class="h-4 w-4" />
</button>
</ButtonStyled>
<ChevronDownIcon
class="h-4 w-4 transition-transform duration-300"
:class="expanded && 'rotate-180'"
/>
</IconButton>
<IconButton
v-if="dismissible"
type="quiet"
:color="buttonColors[type]"
label="Dismiss"
@click.stop="handleDismiss"
>
<XIcon class="h-4 w-4" />
</IconButton>
</div>
</div>
@@ -72,7 +74,7 @@
import { ChevronDownIcon, LightBulbIcon, TriangleAlertIcon, XIcon } from '@modrinth/assets'
import { ref } from 'vue'
import ButtonStyled from './ButtonStyled.vue'
import { IconButton } from '#ui/components/base/buttons'
export interface CollapsibleAdmonitionItem {
title: string
@@ -17,13 +17,15 @@
/>
<div class="absolute bottom-4 left-1/2 z-20 -translate-x-1/2">
<ButtonStyled circular type="transparent">
<button class="flex items-center gap-1 text-xs" @click="collapsed = !collapsed">
<ExpandIcon v-if="collapsed" />
<CollapseIcon v-else />
{{ collapsed ? expandText : collapseText }}
</button>
</ButtonStyled>
<Button
type="quiet"
class="flex items-center gap-1 text-xs !rounded-full"
@click="collapsed = !collapsed"
>
<ExpandIcon v-if="collapsed" />
<CollapseIcon v-else />
{{ collapsed ? expandText : collapseText }}
</Button>
</div>
</div>
</template>
@@ -31,7 +33,7 @@
<script setup lang="ts">
import { CollapseIcon, ExpandIcon } from '@modrinth/assets'
import ButtonStyled from './ButtonStyled.vue'
import { Button } from '#ui/components/base/buttons'
withDefaults(
defineProps<{
+30 -15
View File
@@ -49,19 +49,19 @@
</div>
<!-- Standard mode: button trigger -->
<span
<ButtonFrame
v-else
ref="triggerRef"
role="button"
tabindex="0"
class="relative flex min-h-5 w-full items-center justify-between overflow-hidden rounded-xl bg-surface-4 px-4 py-2 text-left transition-all duration-200 text-button-text gap-2.5"
as="button"
native-type="button"
:type="triggerType"
:size="triggerSize"
:interaction="triggerInteraction"
:disabled="disabled"
:class="[
'min-w-full w-full !justify-between overflow-hidden text-left',
props.triggerClass,
{
'z-[9999]': isOpen,
'cursor-not-allowed opacity-50': disabled,
'cursor-pointer hover:brightness-[115%] active:brightness-[115%]': !disabled,
},
{ 'z-[9999]': isOpen },
]"
:aria-expanded="isOpen"
:aria-haspopup="listbox ? 'listbox' : 'menu'"
@@ -78,7 +78,7 @@
/>
<span
v-if="selectedOption"
class="min-w-0 truncate text-primary font-semibold leading-tight"
class="min-w-0 truncate font-semibold leading-tight text-inherit"
>
<slot name="selected" :label="selectedTriggerText">{{ selectedTriggerText }}</slot>
</span>
@@ -94,7 +94,7 @@
:class="isOpen ? (openDirection === 'down' ? 'rotate-90' : '-rotate-90') : '-rotate-90'"
/>
</div>
</span>
</ButtonFrame>
<Teleport to="#teleports">
<Transition
@@ -217,6 +217,13 @@ import {
watch,
} from 'vue'
import ButtonFrame from './buttons/ButtonFrame.vue'
import type {
ButtonElementHandle,
ButtonInteraction,
ButtonSize,
ButtonType,
} from './buttons/types'
import StyledInput from './StyledInput.vue'
export interface ComboboxOption<T> {
@@ -281,6 +288,10 @@ const props = withDefaults(
displayValue?: string
searchValue?: string
triggerClass?: string
/** Shared button frame style for non-searchable combobox triggers. */
triggerType?: ButtonType
triggerSize?: ButtonSize
triggerInteraction?: ButtonInteraction
dropdownClass?: string
/** Additional selectors to ignore when detecting outside clicks */
outsideClickIgnore?: string[]
@@ -322,6 +333,9 @@ const props = withDefaults(
selectSearchTextOnFocus: false,
showSearchIcon: false,
searchType: 'text',
triggerType: 'base',
triggerSize: 'md',
triggerInteraction: 'surface',
outsideClickIgnore: () => [],
},
)
@@ -343,7 +357,7 @@ const searchQuery = ref('')
const userHasTyped = ref(false)
const focusedIndex = ref(-1)
const containerRef = ref<HTMLElement>()
const triggerRef = ref<HTMLElement>()
const triggerRef = ref<ButtonElementHandle>()
const searchTriggerRef = ref<InstanceType<typeof StyledInput>>()
const dropdownRef = ref<HTMLElement>()
const optionsScrollbarRef = ref<HTMLElement>()
@@ -357,10 +371,11 @@ const effectiveTriggerEl = computed(() => {
if (props.searchable && searchTriggerRef.value) {
return (searchTriggerRef.value as unknown as { $el: HTMLElement }).$el as HTMLElement
}
return triggerRef.value
return triggerRef.value?.element ?? undefined
})
const outsideClickIgnoreTargets = computed(() => [
triggerRef,
effectiveTriggerEl,
containerRef,
...props.outsideClickIgnore,
])
@@ -642,7 +657,7 @@ function closeDropdown() {
if (!props.searchable) {
nextTick(() => {
triggerRef.value?.focus()
effectiveTriggerEl.value?.focus()
})
}
}
+40 -28
View File
@@ -14,33 +14,43 @@
rangeEndpointMoveState ? 'is-moving-range-end' : '',
]"
>
<CalendarIcon
v-if="showIcon && !calendarOnly"
class="pointer-events-none absolute left-3 z-[1] h-5 w-5 text-secondary opacity-60 transition-colors"
aria-hidden="true"
/>
<input
:id="id"
ref="inputRef"
:name="name"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:autocomplete="autocomplete"
:class="inputClasses"
:tabindex="calendarOnly ? -1 : undefined"
:aria-hidden="calendarOnly ? 'true' : undefined"
type="text"
/>
<button
v-if="hasClearButton"
type="button"
class="absolute right-0.5 top-px z-[1] touch-manipulation cursor-pointer select-none border-none bg-transparent p-2 text-secondary transition-colors hover:text-contrast"
aria-label="Clear date"
@click.stop="clearValue"
<ButtonFrame
as="span"
:class="[
calendarOnly
? '!block !h-auto !min-w-0 !bg-transparent !p-0 !shadow-none hover:!brightness-100'
: 'w-full !justify-start !p-0',
disabled ? '!cursor-not-allowed' : '',
]"
>
<XIcon class="h-5 w-5" aria-hidden="true" />
</button>
<CalendarIcon
v-if="showIcon && !calendarOnly"
class="pointer-events-none absolute left-3 z-[1] h-5 w-5 text-secondary opacity-60 transition-colors"
aria-hidden="true"
/>
<input
:id="id"
ref="inputRef"
:name="name"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:autocomplete="autocomplete"
:class="inputClasses"
:tabindex="calendarOnly ? -1 : undefined"
:aria-hidden="calendarOnly ? 'true' : undefined"
type="text"
/>
<button
v-if="hasClearButton"
type="button"
class="absolute right-0.5 top-px z-[1] touch-manipulation cursor-pointer select-none border-none bg-transparent p-2 text-secondary transition-colors hover:text-contrast"
aria-label="Clear date"
@click.stop="clearValue"
>
<XIcon class="h-5 w-5" aria-hidden="true" />
</button>
</ButtonFrame>
</div>
</template>
@@ -55,6 +65,8 @@ import type { Instance } from 'flatpickr/dist/types/instance'
import type { Options } from 'flatpickr/dist/types/options'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import ButtonFrame from './buttons/ButtonFrame.vue'
type DatePickerValue = string | Date | null | undefined
type RangeEdge = 'start' | 'end'
type ViewDateAlignment = 'left' | 'right'
@@ -1188,11 +1200,11 @@ const hasClearButton = computed(
const inputClasses = computed(() => [
props.calendarOnly
? 'sr-only pointer-events-none absolute h-0 w-0 opacity-0'
: 'w-full touch-manipulation text-primary placeholder:text-secondary focus:text-contrast font-medium transition-[shadow,color] appearance-none shadow-none focus:ring-4 focus:ring-brand-shadow !outline-0',
: 'h-full w-full touch-manipulation appearance-none bg-transparent font-semibold text-primary shadow-none transition-[shadow,color] placeholder:text-secondary focus:text-contrast focus:ring-4 focus:ring-brand-shadow !outline-0',
!props.calendarOnly && props.showIcon ? 'pl-10' : '',
!props.calendarOnly && !props.showIcon ? 'pl-3' : '',
!props.calendarOnly
? `${hasClearButton.value ? 'pr-10' : 'pr-3'} h-9 py-2 text-base outline-none bg-surface-4 border-none rounded-xl`
? `${hasClearButton.value ? 'pr-10' : 'pr-3'} rounded-xl border-none py-2 text-base outline-none`
: '',
props.disabled && !props.calendarOnly ? 'cursor-not-allowed' : '',
props.inputClass,
@@ -20,6 +20,8 @@
:fit-content="true"
:searchable="preview.category.searchable"
:search-placeholder="preview.category.searchPlaceholder"
trigger-type="base"
trigger-size="lg"
:trigger-class="effectivePreviewTriggerClass"
:dropdown-width="getPreviewDropdownWidth(preview.category)"
:dropdown-min-width="getPreviewDropdownMinWidth(preview.category)"
@@ -105,24 +107,24 @@
</MultiSelect>
<div class="flex h-10 min-w-0 max-w-full items-center gap-2">
<ButtonStyled type="outlined">
<button
ref="addMenuTrigger"
type="button"
:class="addButtonClass ?? '!border'"
:aria-expanded="isAddMenuOpen"
aria-haspopup="menu"
@click="handleAddMenuTriggerClick"
@keydown="handleAddMenuTriggerKeydown"
>
<PlusIcon />
{{ addLabel }}
</button>
</ButtonStyled>
<Button
ref="addMenuTrigger"
type="outlined"
native-type="button"
:size="addButtonSize"
:class="addButtonClass ?? '!border'"
:aria-expanded="isAddMenuOpen"
aria-haspopup="menu"
@click="handleAddMenuTriggerClick"
@keydown="handleAddMenuTriggerKeydown"
>
<PlusIcon />
{{ addLabel }}
</Button>
<ButtonStyled v-if="shouldShowClear" type="transparent">
<button type="button" @click="clearAllFilters">{{ clearLabel }}</button>
</ButtonStyled>
<Button v-if="shouldShowClear" type="quiet" native-type="button" @click="clearAllFilters">{{
clearLabel
}}</Button>
</div>
<Teleport to="#teleports">
@@ -385,8 +387,9 @@ import { OverlayScrollbars, type PartialOptions } from 'overlayscrollbars'
import type { Component, ComponentPublicInstance, CSSProperties } from 'vue'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { Button, type ButtonSize } from '#ui/components/base/buttons'
import { useVirtualScroll } from '../../composables/virtual-scroll'
import ButtonStyled from './ButtonStyled.vue'
import MultiSelect, { type MultiSelectItem } from './MultiSelect.vue'
import StyledInput from './StyledInput.vue'
@@ -514,6 +517,7 @@ const props = withDefaults(
showPreviewFilterIcon?: boolean
previewTriggerClass?: string
addButtonClass?: string
addButtonSize?: ButtonSize
emptyOptionsLabel?: string
emptySearchLabel?: string
checkboxPosition?: 'left' | 'right'
@@ -525,6 +529,7 @@ const props = withDefaults(
showClear: false,
showLabel: true,
useFilterIcon: false,
addButtonSize: 'md',
applyImmediately: false,
showPreviewFilterIcon: false,
emptyOptionsLabel: 'No options available.',
@@ -752,8 +757,7 @@ const appliedFilterPreviews = computed(() =>
const hasAppliedFilters = computed(() => appliedFilterPreviews.value.length > 0)
const shouldShowClear = computed(() => hasAppliedFilters.value || props.showClear)
const DEFAULT_PREVIEW_TRIGGER_CLASS =
'h-10 max-w-[16rem] bg-surface-4 px-4 py-1.5 transition-all bg-surface-4 hover:brightness-110 active:brightness-110'
const DEFAULT_PREVIEW_TRIGGER_CLASS = 'max-w-[16rem]'
const effectivePreviewTriggerClass = computed(
() => props.previewTriggerClass ?? DEFAULT_PREVIEW_TRIGGER_CLASS,
)
@@ -46,31 +46,29 @@
</div>
<div class="mt-4 flex !w-full flex-row gap-4">
<ButtonStyled
<Button
v-if="action"
size="large"
:color="action.color || 'brand'"
:type="action.color === 'standard' ? 'base' : 'colored'"
:color="action.color === 'standard' ? undefined : (action.color ?? 'brand')"
size="xl"
:disabled="action.disabled"
class="!w-full"
@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>
<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 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>
<Button v-if="errorDetails" size="xl" class="!w-full" @click="copyErrorInformation">
<CopyIcon v-if="!infoCopied" class="size-4" />
<CheckIcon v-else class="size-4" />
Copy Information
</Button>
</div>
</div>
</template>
@@ -80,7 +78,7 @@ import { CheckIcon, CopyIcon } from '@modrinth/assets'
import type { Component } from 'vue'
import { ref } from 'vue'
import ButtonStyled from './ButtonStyled.vue'
import { Button } from '#ui/components/base/buttons'
const infoCopied = ref(false)
@@ -2,7 +2,7 @@
import { onClickOutside } from '@vueuse/core'
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import ButtonStyled from './ButtonStyled.vue'
import { Button } from '#ui/components/base/buttons'
const PANEL_VIEWPORT_MARGIN = 8
@@ -249,19 +249,18 @@ defineExpose({
<template>
<div class="relative inline-block">
<ButtonStyled v-bind="$attrs">
<button
ref="triggerRef"
:class="buttonClass"
:disabled="disabled"
:aria-expanded="isOpen"
aria-haspopup="true"
@click="toggle"
@keydown="handleTriggerKeydown"
>
<slot></slot>
</button>
</ButtonStyled>
<Button
v-bind="$attrs"
ref="triggerRef"
:class="buttonClass"
:disabled="disabled"
:aria-expanded="isOpen"
aria-haspopup="true"
@click="toggle"
@keydown="handleTriggerKeydown"
>
<slot></slot>
</Button>
<Teleport to="body">
<Transition
@@ -10,8 +10,9 @@ import {
} from '@modrinth/assets'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { IconButton } from '#ui/components/base/buttons'
import { injectI18nDebug } from '../../composables/i18n-debug'
import ButtonStyled from './ButtonStyled.vue'
import StyledInput from './StyledInput.vue'
const debugContext = injectI18nDebug()
@@ -295,37 +296,41 @@ const listMaxHeight = computed(() => `${panelHeight.value - 120}px`)
<!-- Toolbar -->
<div class="ml-auto flex items-center gap-0.5">
<ButtonStyled circular type="transparent">
<button
v-tooltip="
debugContext?.keyReveal.value ? 'Hide keys inline' : 'Reveal keys inline'
"
@click="toggleKeyReveal"
>
<component :is="debugContext?.keyReveal.value ? EyeOffIcon : EyeIcon" />
</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<button v-tooltip="'Toggle CSS debug overlay'" @click="toggleOverlay">
<ScanEyeIcon />
</button>
</ButtonStyled>
<IconButton
v-tooltip="debugContext?.keyReveal.value ? 'Hide keys inline' : 'Reveal keys inline'"
type="quiet"
:label="debugContext?.keyReveal.value ? 'Hide keys inline' : 'Reveal keys inline'"
@click="toggleKeyReveal"
>
<component :is="debugContext?.keyReveal.value ? EyeOffIcon : EyeIcon" />
</IconButton>
<IconButton
v-tooltip="'Toggle CSS debug overlay'"
type="quiet"
:label="'Toggle CSS debug overlay'"
@click="toggleOverlay"
>
<ScanEyeIcon />
</IconButton>
<div class="mx-0.5 h-4 w-px bg-surface-5/60" />
<ButtonStyled circular type="transparent">
<button
v-tooltip="minimized ? 'Expand panel' : 'Minimize panel'"
@click="minimized = !minimized"
>
<component :is="minimized ? MaximizeIcon : MinusIcon" />
</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<button v-tooltip="'Close inspector'" @click="closePanel">
<XIcon />
</button>
</ButtonStyled>
<IconButton
v-tooltip="minimized ? 'Expand panel' : 'Minimize panel'"
type="quiet"
:label="minimized ? 'Expand panel' : 'Minimize panel'"
@click="minimized = !minimized"
>
<component :is="minimized ? MaximizeIcon : MinusIcon" />
</IconButton>
<IconButton
v-tooltip="'Close inspector'"
type="quiet"
:label="'Close inspector'"
@click="closePanel"
>
<XIcon />
</IconButton>
</div>
</div>
+13 -6
View File
@@ -1,8 +1,10 @@
<script setup lang="ts">
import { EditIcon, TrashIcon, UploadIcon } from '@modrinth/assets'
import { TeleportOverflowMenu } from '#ui/components/base/buttons'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { Avatar, OverflowMenu } from '../index'
import { Avatar } from '../index'
const { formatMessage } = useVIntl()
@@ -43,17 +45,22 @@ const messages = defineMessages({
</script>
<template>
<OverflowMenu
v-tooltip="formatMessage(messages.editIcon)"
class="m-0 cursor-pointer appearance-none border-none bg-transparent p-0 transition-transform group-active:scale-95"
<TeleportOverflowMenu
:label="formatMessage(messages.editIcon)"
:tooltip="formatMessage(messages.editIcon)"
:icon-only="false"
type="quiet"
interaction="none"
class="m-0 !h-auto cursor-pointer appearance-none border-none bg-transparent !p-0 transition-transform group-active:scale-95"
:options="[
{
id: 'select',
label: icon ? formatMessage(messages.replaceIcon) : formatMessage(messages.selectIcon),
action: () => emit('select'),
},
{
id: 'remove',
color: 'danger',
label: formatMessage(messages.removeIcon),
action: () => emit('remove'),
shown: !!icon,
},
@@ -72,5 +79,5 @@ const messages = defineMessages({
{{ icon ? formatMessage(messages.replaceIcon) : formatMessage(messages.selectIcon) }}
</template>
<template #remove> <TrashIcon /> {{ formatMessage(messages.removeIcon) }} </template>
</OverflowMenu>
</TeleportOverflowMenu>
</template>
@@ -1,151 +0,0 @@
<template>
<div class="joined-buttons">
<ButtonStyled :color="color" :size="size">
<button
v-tooltip="primaryTooltip"
:class="{ 'joined-buttons__primary--muted': primaryMuted }"
:disabled="primaryDisabledResolved"
@click="handlePrimaryAction"
>
<component :is="primaryAction.icon" v-if="primaryAction.icon" aria-hidden="true" />
{{ primaryAction.label }}
</button>
</ButtonStyled>
<ButtonStyled v-if="dropdownActions.length > 0" :color="color" :size="size">
<OverflowMenu
class="btn-dropdown-animation !w-10"
:options="dropdownOptions"
:disabled="dropdownDisabledResolved"
:tooltip="dropdownTooltip"
>
<DropdownIcon />
<template v-for="action in dropdownActions" :key="action.id" #[action.id]>
<component :is="action.icon" v-if="action.icon" aria-hidden="true" />
{{ action.label }}
</template>
</OverflowMenu>
</ButtonStyled>
</div>
</template>
<script setup lang="ts">
import { DropdownIcon } from '@modrinth/assets'
import type { Component } from 'vue'
import { computed } from 'vue'
import ButtonStyled from './ButtonStyled.vue'
import OverflowMenu from './OverflowMenu.vue'
// TODO: This should be moved to a shared types file.
type Colors = 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
export interface JoinedButtonAction {
id: string
label: string
icon?: Component
action: () => void
color?: Colors
hoverFilled?: boolean
}
interface Props {
actions: JoinedButtonAction[]
color?: Colors
size?: 'standard' | 'large' | 'small'
disabled?: boolean
primaryDisabled?: boolean
dropdownDisabled?: boolean
primaryMuted?: boolean
primaryTooltip?: string
dropdownTooltip?: string
}
const props = withDefaults(defineProps<Props>(), {
color: 'standard',
size: 'standard',
disabled: false,
primaryDisabled: undefined,
dropdownDisabled: undefined,
primaryMuted: false,
primaryTooltip: undefined,
dropdownTooltip: undefined,
})
const primaryDisabledResolved = computed(() => props.primaryDisabled ?? props.disabled)
const dropdownDisabledResolved = computed(() => props.dropdownDisabled ?? props.disabled)
const primaryAction = computed(() => props.actions[0])
const dropdownActions = computed(() => props.actions.slice(1))
const colorMap: Record<
Colors,
| 'red'
| 'orange'
| 'green'
| 'blue'
| 'purple'
| 'highlight'
| 'primary'
| 'danger'
| 'secondary'
| undefined
> = {
standard: 'secondary',
brand: 'primary',
red: 'red',
orange: 'orange',
green: 'green',
blue: 'blue',
purple: 'purple',
}
const dropdownOptions = computed(() =>
dropdownActions.value.map((action) => ({
id: action.id,
color: action.color ? colorMap[action.color] : undefined,
action: action.action,
hoverFilled: action.hoverFilled ?? true,
})),
)
function handlePrimaryAction() {
if (primaryAction.value && !primaryDisabledResolved.value) {
primaryAction.value.action()
}
}
</script>
<style scoped>
.joined-buttons {
display: flex;
align-items: center;
}
.joined-buttons > :deep(.btn) {
border-radius: 0;
}
.joined-buttons > :deep(.btn:first-child) {
border-top-left-radius: var(--radius-md);
border-bottom-left-radius: var(--radius-md);
}
.joined-buttons > :deep(.btn:last-child) {
border-top-right-radius: var(--radius-md);
border-bottom-right-radius: var(--radius-md);
margin-left: -1px;
}
.joined-buttons > :deep(.btn:not(:last-child)) {
border-right: none;
}
.btn-dropdown-animation {
padding: 0.5rem !important;
}
.joined-buttons__primary--muted {
opacity: 0.6;
}
</style>
+22 -27
View File
@@ -1,23 +1,19 @@
<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 = ''
}
"
>
<TeleportPopoutMenu
v-if="options.length > 1 || showAlways"
v-bind="$attrs"
:disabled="disabled"
:label="tooltip || 'Select options'"
:tooltip="tooltip"
placement="bottom-start"
@open="searchQuery = ''"
>
<template #trigger>
<slot />
<DropdownIcon class="h-5 w-5 text-secondary" />
<template #menu>
</template>
<template #panel>
<div :class="dropdownClass">
<StyledInput
v-if="search"
id="search-input"
@@ -37,10 +33,9 @@
<Button
v-for="(option, index) in filteredOptions"
:key="`option-${index}`"
:transparent="!manyValues.includes(option)"
:action="() => toggleOption(option)"
:type="manyValues.includes(option) ? 'base' : 'quiet'"
class="!w-full"
:color="manyValues.includes(option) ? 'secondary' : 'default'"
@click="toggleOption(option)"
>
<slot name="option" :option="option">{{ getOptionLabel(option) }}</slot>
<CheckIcon
@@ -53,10 +48,9 @@
<Button
v-for="(option, index) in filteredOptions"
:key="`option-${index}`"
:transparent="!manyValues.includes(option)"
:action="() => toggleOption(option)"
:type="manyValues.includes(option) ? 'base' : 'quiet'"
class="!w-full"
:color="manyValues.includes(option) ? 'secondary' : 'default'"
@click="toggleOption(option)"
>
<slot name="option" :option="option">{{ getOptionLabel(option) }}</slot>
<CheckIcon
@@ -66,16 +60,17 @@
</Button>
</div>
<slot name="footer" />
</template>
</PopoutMenu>
</ButtonStyled>
</div>
</template>
</TeleportPopoutMenu>
</template>
<script setup lang="ts">
import { CheckIcon, DropdownIcon, SearchIcon } from '@modrinth/assets'
import { computed, ref } from 'vue'
import { Button, ButtonStyled, PopoutMenu, StyledInput } from '../index'
import { Button, TeleportPopoutMenu } from './buttons'
import ScrollablePanel from './ScrollablePanel.vue'
import StyledInput from './StyledInput.vue'
type Option = string | number | object
@@ -46,24 +46,22 @@
/>
</div>
<div class="flex gap-2 justify-end mt-4">
<ButtonStyled type="outlined">
<button @click="() => linkModal?.hide()">
<XIcon /> {{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button
:disabled="!!linkValidationErrorMessage || !linkUrl"
@click="
() => {
if (editor) markdownCommands.replaceSelection(editor, linkMarkdown)
linkModal?.hide()
}
"
>
<PlusIcon /> {{ formatMessage(messages.insertButton) }}
</button>
</ButtonStyled>
<Button type="outlined" @click="() => linkModal?.hide()">
<XIcon /> {{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button
type="colored"
color="brand"
:disabled="!!linkValidationErrorMessage || !linkUrl"
@click="
() => {
if (editor) markdownCommands.replaceSelection(editor, linkMarkdown)
linkModal?.hide()
}
"
>
<PlusIcon /> {{ formatMessage(messages.insertButton) }}
</Button>
</div>
</div>
</NewModal>
@@ -144,24 +142,22 @@
/>
</div>
<div class="flex gap-2 justify-end mt-4">
<ButtonStyled type="outlined">
<button @click="() => imageModal?.hide()">
<XIcon /> {{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button
:disabled="!canInsertImage"
@click="
() => {
if (editor) markdownCommands.replaceSelection(editor, imageMarkdown)
imageModal?.hide()
}
"
>
<PlusIcon /> {{ formatMessage(messages.insertButton) }}
</button>
</ButtonStyled>
<Button type="outlined" @click="() => imageModal?.hide()">
<XIcon /> {{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button
type="colored"
color="brand"
:disabled="!canInsertImage"
@click="
() => {
if (editor) markdownCommands.replaceSelection(editor, imageMarkdown)
imageModal?.hide()
}
"
>
<PlusIcon /> {{ formatMessage(messages.insertButton) }}
</Button>
</div>
</div>
</NewModal>
@@ -204,24 +200,22 @@
/>
</div>
<div class="flex gap-2 justify-end mt-4">
<ButtonStyled type="outlined">
<button @click="() => videoModal?.hide()">
<XIcon /> {{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button
:disabled="!!linkValidationErrorMessage || !linkUrl"
@click="
() => {
if (editor) markdownCommands.replaceSelection(editor, videoMarkdown)
videoModal?.hide()
}
"
>
<PlusIcon /> {{ formatMessage(messages.insertButton) }}
</button>
</ButtonStyled>
<Button type="outlined" @click="() => videoModal?.hide()">
<XIcon /> {{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button
type="colored"
color="brand"
:disabled="!!linkValidationErrorMessage || !linkUrl"
@click="
() => {
if (editor) markdownCommands.replaceSelection(editor, videoMarkdown)
videoModal?.hide()
}
"
>
<PlusIcon /> {{ formatMessage(messages.insertButton) }}
</Button>
</div>
</div>
</NewModal>
@@ -235,17 +229,16 @@
>
<div class="divider"></div>
<template v-for="button in buttonGroup.buttons" :key="button.label.id">
<ButtonStyled circular>
<button
v-tooltip="formatMessage(button.label)"
:aria-label="formatMessage(button.label)"
:class="{ 'mobile-hidden-group': !!buttonGroup.hideOnMobile }"
:disabled="previewMode || disabled"
@click="() => button.action(editor)"
>
<component :is="button.icon" />
</button>
</ButtonStyled>
<IconButton
v-tooltip="formatMessage(button.label)"
:label="formatMessage(button.label)"
size="sm"
:class="{ 'mobile-hidden-group': !!buttonGroup.hideOnMobile }"
:disabled="previewMode || disabled"
@click="() => button.action(editor)"
>
<component :is="button.icon" />
</IconButton>
</template>
</template>
</div>
@@ -333,10 +326,11 @@ import { markdownCommands, modrinthMarkdownEditorKeymap } from '@modrinth/utils/
import { renderHighlightedString } from '@modrinth/utils/highlightjs'
import { type Component, computed, onBeforeUnmount, onMounted, ref, toRef, watch } from 'vue'
import { Button, IconButton } from '#ui/components/base/buttons'
import { defineMessages, type MessageDescriptor, useVIntl } from '../../composables/i18n'
import { commonMessages } from '../../utils/common-messages.ts'
import NewModal from '../modal/NewModal.vue'
import ButtonStyled from './ButtonStyled.vue'
import Chips from './Chips.vue'
import FileInput from './FileInput.vue'
import IntlFormatted from './IntlFormatted.vue'
+53 -15
View File
@@ -1,17 +1,21 @@
<template>
<div ref="containerRef" class="relative inline-block" :class="fitContent ? 'w-auto' : 'w-full'">
<span
<component
:is="triggerComponent"
ref="triggerRef"
role="button"
tabindex="0"
class="relative flex items-center overflow-hidden rounded-xl bg-surface-4 px-4 py-1 text-left transition-all duration-200"
v-bind="triggerButtonProps"
:role="triggerType ? undefined : 'button'"
:tabindex="triggerType ? undefined : 0"
:class="[
triggerType
? 'overflow-hidden text-left'
: 'relative flex items-center overflow-hidden rounded-xl bg-surface-4 px-4 py-1 text-left transition-all duration-200',
fitContent ? 'w-auto max-w-full' : 'w-full',
triggerClass,
{
'z-[9999]': isOpen,
'cursor-not-allowed opacity-50': disabled,
'cursor-pointer hover:brightness-125 active:brightness-125': !disabled,
'cursor-not-allowed opacity-50': disabled && !triggerType,
'cursor-pointer hover:brightness-125 active:brightness-125': !disabled && !triggerType,
},
]"
:aria-expanded="isOpen"
@@ -39,7 +43,8 @@
<span
v-for="tag in visibleTags"
:key="String(tag.value)"
class="inline-flex items-center gap-1 rounded-full border border-solid border-surface-5 bg-surface-4 px-2 py-1 text-sm font-medium text-primary transition-all hover:brightness-[115%]"
class="inline-flex items-center gap-1 rounded-full border border-solid border-surface-5 bg-surface-4 px-2 py-1 text-sm font-medium transition-all hover:brightness-[115%]"
:class="triggerType ? 'text-inherit' : 'text-primary'"
@click.stop="removeTag(tag.value)"
>
{{ tag.label }}
@@ -100,7 +105,7 @@
/>
</div>
</template>
</span>
</component>
<Teleport to="#teleports">
<Transition
@@ -415,6 +420,13 @@ import {
} from 'vue'
import { useVirtualScroll } from '../../composables/virtual-scroll'
import ButtonFrame from './buttons/ButtonFrame.vue'
import type {
ButtonElementHandle,
ButtonInteraction,
ButtonSize,
ButtonType,
} from './buttons/types'
import StyledInput from './StyledInput.vue'
export interface MultiSelectOption<T> {
@@ -492,6 +504,10 @@ const props = withDefaults(
clearable?: boolean
maxHeight?: number
triggerClass?: string
/** Apply the shared button frame to compact, button-owned multiselect triggers. */
triggerType?: ButtonType
triggerSize?: ButtonSize
triggerInteraction?: ButtonInteraction
fitContent?: boolean
/** Width for the teleported dropdown; defaults to the trigger width */
dropdownWidth?: string | number
@@ -517,6 +533,8 @@ const props = withDefaults(
showChevron: true,
clearable: true,
maxHeight: DEFAULT_MAX_HEIGHT,
triggerSize: 'md',
triggerInteraction: 'surface',
fitContent: false,
noOptionsMessage: 'No options available',
noResultsMessage: 'No results found',
@@ -538,11 +556,29 @@ const emit = defineEmits<{
}>()
const slots = useSlots()
const triggerComponent = computed(() => (props.triggerType ? ButtonFrame : 'span'))
const triggerButtonProps = computed(() =>
props.triggerType
? {
as: 'button' as const,
nativeType: 'button' as const,
type: props.triggerType,
size: props.triggerSize,
interaction: props.triggerInteraction,
disabled: props.disabled,
}
: {},
)
const isOpen = ref(false)
const searchQuery = ref('')
const focusedIndex = ref(-1)
const containerRef = ref<HTMLElement>()
const triggerRef = ref<HTMLElement>()
const triggerRef = ref<HTMLElement | ButtonElementHandle>()
const triggerElement = computed<HTMLElement | undefined>(() => {
const trigger = triggerRef.value
if (!trigger) return undefined
return 'element' in trigger ? (trigger.element ?? undefined) : trigger
})
const dropdownRef = ref<HTMLElement>()
const optionsScrollbarRef = ref<HTMLElement>()
const optionsContainerRef = ref<HTMLElement>()
@@ -944,11 +980,13 @@ function resolveCssSize(size: string | number | undefined): string | undefined {
}
async function updateDropdownPosition() {
if (!triggerRef.value || !dropdownRef.value) return
await nextTick()
const triggerRect = triggerRef.value.getBoundingClientRect()
const trigger = triggerElement.value
const dropdown = dropdownRef.value
if (!trigger || !dropdown) return
const triggerRect = trigger.getBoundingClientRect()
const width = resolveDropdownWidth(triggerRect.width)
const minWidth = resolveCssSize(props.dropdownMinWidth) ?? '0px'
@@ -960,7 +998,7 @@ async function updateDropdownPosition() {
await nextTick()
const dropdownRect = dropdownRef.value.getBoundingClientRect()
const dropdownRect = dropdown.getBoundingClientRect()
const viewport = getViewportRect()
const direction = determineOpenDirection(triggerRect, dropdownRect, viewport)
@@ -1064,7 +1102,7 @@ function closeDropdown() {
emit('close')
nextTick(() => {
triggerRef.value?.focus()
triggerElement.value?.focus()
})
}
@@ -1355,7 +1393,7 @@ onClickOutside(
() => {
closeDropdown()
},
{ ignore: [triggerRef, containerRef, '.v-popper__popper'] },
{ ignore: [triggerElement, containerRef, '.v-popper__popper'] },
)
onMounted(() => {
@@ -72,46 +72,48 @@
class="flex flex-col justify-end gap-2 sm:flex-row"
:class="leftButtonConfig || rightButtonConfig ? 'mt-4' : ''"
>
<ButtonStyled v-if="leftButtonConfig" type="outlined">
<button
v-tooltip="leftButtonConfig.tooltip"
:class="leftButtonConfig.buttonClass"
:disabled="leftButtonConfig.disabled"
@click="leftButtonConfig.onClick"
>
<component :is="leftButtonConfig.icon" />
{{ leftButtonConfig.label }}
</button>
</ButtonStyled>
<ButtonStyled v-if="rightButtonConfig" :color="rightButtonConfig.color">
<button
v-tooltip="rightButtonConfig.tooltip"
class="!shadow-none"
:class="rightButtonConfig.buttonClass"
:disabled="rightButtonConfig.disabled || rightButtonConfig.loading"
@click="rightButtonConfig.onClick"
>
<SpinnerIcon
v-if="rightButtonConfig.loading && rightButtonConfig.iconPosition === 'before'"
class="animate-spin"
/>
<component
:is="rightButtonConfig.icon"
v-else-if="rightButtonConfig.iconPosition === 'before'"
:class="rightButtonConfig.iconClass"
/>
{{ rightButtonConfig.label }}
<SpinnerIcon
v-if="rightButtonConfig.loading && rightButtonConfig.iconPosition === 'after'"
class="animate-spin"
/>
<component
:is="rightButtonConfig.icon"
v-else-if="rightButtonConfig.iconPosition === 'after'"
:class="rightButtonConfig.iconClass"
/>
</button>
</ButtonStyled>
<Button
v-if="leftButtonConfig"
v-tooltip="leftButtonConfig.tooltip"
type="outlined"
:class="leftButtonConfig.buttonClass"
:disabled="leftButtonConfig.disabled"
@click="leftButtonConfig.onClick"
>
<component :is="leftButtonConfig.icon" />
{{ leftButtonConfig.label }}
</Button>
<Button
v-if="rightButtonConfig"
v-tooltip="rightButtonConfig.tooltip"
:type="
rightButtonConfig.color && rightButtonConfig.color !== 'standard' ? 'colored' : 'base'
"
:color="rightButtonConfig.color === 'standard' ? undefined : rightButtonConfig.color"
:class="rightButtonConfig.buttonClass"
:disabled="rightButtonConfig.disabled || rightButtonConfig.loading"
@click="rightButtonConfig.onClick"
>
<SpinnerIcon
v-if="rightButtonConfig.loading && rightButtonConfig.iconPosition === 'before'"
class="animate-spin"
/>
<component
:is="rightButtonConfig.icon"
v-else-if="rightButtonConfig.iconPosition === 'before'"
:class="rightButtonConfig.iconClass"
/>
{{ rightButtonConfig.label }}
<SpinnerIcon
v-if="rightButtonConfig.loading && rightButtonConfig.iconPosition === 'after'"
class="animate-spin"
/>
<component
:is="rightButtonConfig.icon"
v-else-if="rightButtonConfig.iconPosition === 'after'"
:class="rightButtonConfig.iconClass"
/>
</Button>
</div>
</template>
</NewModal>
@@ -119,15 +121,18 @@
<script lang="ts">
import { ChevronRightIcon, SpinnerIcon } from '@modrinth/assets'
import { ButtonStyled, NewModal } from '@modrinth/ui'
import { NewModal } from '@modrinth/ui'
import type { Component } from 'vue'
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
import type { ButtonColor } from '#ui/components/base/buttons'
import { Button } from '#ui/components/base/buttons'
export interface StageButtonConfig {
label?: string
icon?: Component | null
iconPosition?: 'before' | 'after'
color?: InstanceType<typeof ButtonStyled>['$props']['color']
color?: ButtonColor | 'standard'
disabled?: boolean
loading?: boolean
tooltip?: string
@@ -1,149 +0,0 @@
<template>
<PopoutMenu
ref="dropdown"
v-bind="$attrs"
:disabled="disabled"
:dropdown-id="dropdownId"
:tooltip="tooltip"
:placement="placement"
>
<slot></slot>
<template #menu>
<slot name="menu-header" />
<template v-for="(option, index) in options.filter((x) => x.shown === undefined || x.shown)">
<div
v-if="isDivider(option)"
:key="`divider-${index}`"
class="h-px mx-[0.625rem] my-2 bg-surface-5"
></div>
<Button
v-else
:key="`option-${option.id}`"
v-tooltip="option.tooltip"
:color="option.color ? option.color : 'default'"
:hover-filled="option.hoverFilled"
:hover-filled-only="option.hoverFilledOnly"
transparent
:v-close-popper="!option.remainOnClick"
:action="
option.action
? (event: MouseEvent) => {
option.action?.(event)
if (!option.remainOnClick) {
close()
}
}
: undefined
"
:link="option.link ? option.link : undefined"
:download="option.download ? option.download : undefined"
:external="option.external ? option.external : false"
:disabled="option.disabled"
@click="
() => {
if (option.link && !option.remainOnClick) {
close()
}
}
"
>
<template v-if="!$slots[option.id]">
<component :is="option.icon" v-if="option.icon" class="size-5" />
{{ option.id }}
</template>
<slot :name="option.id"></slot>
</Button>
</template>
</template>
</PopoutMenu>
</template>
<script setup lang="ts">
import { type Component, type Ref, ref } from 'vue'
import Button from './Button.vue'
import PopoutMenu from './PopoutMenu.vue'
interface BaseOption {
shown?: boolean
}
interface Divider extends BaseOption {
divider?: boolean
}
interface Item extends BaseOption {
id: string
icon?: Component
action?: (event?: MouseEvent) => void
link?: string
download?: string
external?: boolean
color?:
| 'primary'
| 'danger'
| 'secondary'
| 'highlight'
| 'red'
| 'orange'
| 'green'
| 'blue'
| 'purple'
hoverFilled?: boolean
hoverFilledOnly?: boolean
remainOnClick?: boolean
disabled?: boolean
tooltip?: string
}
export type Option = Divider | Item
withDefaults(
defineProps<{
options: Option[]
disabled?: boolean
dropdownId?: string
tooltip?: string
placement?: string
}>(),
{
options: () => [],
disabled: false,
dropdownId: undefined,
tooltip: undefined,
placement: 'bottom-end',
},
)
defineOptions({
inheritAttrs: false,
})
const dropdown: Ref<InstanceType<typeof PopoutMenu> | null> = ref(null)
const close = () => {
dropdown.value?.hide()
}
const open = () => {
dropdown.value?.show()
}
function isDivider(option: BaseOption): option is Divider {
return 'divider' in option
}
defineExpose({ open, close })
</script>
<style lang="scss" scoped>
.btn {
white-space: nowrap;
width: 100%;
box-shadow: none;
--text-color: var(--color-base);
--background-color: transparent;
justify-content: flex-start;
padding: 0.55rem 0.625rem;
}
</style>
+37 -30
View File
@@ -1,18 +1,20 @@
<template>
<div v-if="count > 1" class="flex items-center gap-1">
<ButtonStyled v-if="page > 1" circular type="transparent">
<a
<template v-if="page > 1">
<ButtonLink
v-if="linkFunction"
aria-label="Previous Page"
:href="linkFunction(page - 1)"
type="quiet"
class="!w-9 !px-0 !rounded-full"
@click.prevent="switchPage(page - 1)"
>
<ChevronLeftIcon />
</a>
<button v-else aria-label="Previous Page" @click="switchPage(page - 1)">
<ChevronLeftIcon />
</button>
</ButtonStyled>
<ChevronLeftIcon aria-hidden="true" />
</ButtonLink>
<IconButton v-else label="Previous Page" type="quiet" @click="switchPage(page - 1)">
<ChevronLeftIcon aria-hidden="true" />
</IconButton>
</template>
<div
v-for="(item, index) in pages"
:key="'page-' + item + '-' + index"
@@ -25,50 +27,55 @@
<div v-if="item === '-'" class="rotate-90 grid place-content-center">
<EllipsisVerticalIcon />
</div>
<ButtonStyled
v-else
circular
:color="page === item ? 'brand' : 'standard'"
:type="page === item ? 'highlight' : 'transparent'"
>
<a
<template v-else>
<ButtonLink
v-if="linkFunction"
:href="linkFunction(item)"
:class="page === item ? '!text-brand' : ''"
type="quiet"
:color="page === item ? 'brand' : undefined"
:interaction="page === item ? 'filled' : undefined"
:aria-current="page === item ? 'page' : undefined"
:class="['!min-w-9 !rounded-full', page === item ? '!bg-brand-highlight' : '']"
@click.prevent="page !== item ? switchPage(item) : null"
>
{{ item }}
</a>
<button
</ButtonLink>
<Button
v-else
:class="page === item ? '!text-brand' : ''"
type="quiet"
:color="page === item ? 'brand' : undefined"
:interaction="page === item ? 'filled' : undefined"
:aria-current="page === item ? 'page' : undefined"
:class="['!min-w-9 !rounded-full', page === item ? '!bg-brand-highlight' : '']"
@click="page !== item ? switchPage(item) : null"
>
{{ item }}
</button>
</ButtonStyled>
</Button>
</template>
</div>
<ButtonStyled v-if="page !== pages[pages.length - 1]" circular type="transparent">
<a
<template v-if="page !== pages[pages.length - 1]">
<ButtonLink
v-if="linkFunction"
aria-label="Next Page"
:href="linkFunction(page + 1)"
type="quiet"
class="!w-9 !px-0 !rounded-full"
@click.prevent="switchPage(page + 1)"
>
<ChevronRightIcon />
</a>
<button v-else aria-label="Next Page" @click="switchPage(page + 1)">
<ChevronRightIcon />
</button>
</ButtonStyled>
<ChevronRightIcon aria-hidden="true" />
</ButtonLink>
<IconButton v-else label="Next Page" type="quiet" @click="switchPage(page + 1)">
<ChevronRightIcon aria-hidden="true" />
</IconButton>
</template>
</div>
</template>
<script setup lang="ts">
import { ChevronLeftIcon, ChevronRightIcon, EllipsisVerticalIcon } from '@modrinth/assets'
import { computed } from 'vue'
import ButtonStyled from './ButtonStyled.vue'
import { Button, ButtonLink, IconButton } from './buttons'
const emit = defineEmits<{
'switch-page': [page: number]
@@ -18,14 +18,15 @@
</template>
</template>
<template #actions>
<ButtonStyled v-if="dismissable" :color="NOTICE_TYPE_BTN[level]">
<button
v-tooltip="formatMessage(messages.dismiss)"
@click="() => (preview ? {} : emit('dismiss'))"
>
<XIcon /> Dismiss
</button>
</ButtonStyled>
<Button
v-if="dismissable"
v-tooltip="formatMessage(messages.dismiss)"
type="colored"
:color="NOTICE_TYPE_BTN[level]"
@click="() => (preview ? {} : emit('dismiss'))"
>
<XIcon /> Dismiss
</Button>
</template>
<div v-if="message" class="markdown-body" v-html="renderString(message)" />
</Admonition>
@@ -36,9 +37,10 @@ import { XIcon } from '@modrinth/assets'
import { renderString } from '@modrinth/utils'
import { computed } from 'vue'
import { Button } from '#ui/components/base/buttons'
import { defineMessages, type MessageDescriptor, useVIntl } from '../../composables/i18n'
import Admonition from './Admonition.vue'
import ButtonStyled from './ButtonStyled.vue'
import CopyCode from './CopyCode.vue'
const { formatMessage } = useVIntl()
@@ -5,8 +5,9 @@ import { ChevronDownIcon, XIcon } from '@modrinth/assets'
import { AnimatePresence, Motion } from 'motion-v'
import { computed, onBeforeUnmount, onMounted, ref, useAttrs, useId, watch } from 'vue'
import { Button } from '#ui/components/base/buttons'
import { defineMessages, useVIntl } from '../../composables/i18n'
import ButtonStyled from './ButtonStyled.vue'
export type StackedAdmonitionType = 'info' | 'warning' | 'critical' | 'success'
/** Extend this interface to attach arbitrary per-item data consumed in the #item slot. */
@@ -486,32 +487,34 @@ const messages = defineMessages({
>
<div v-if="hasActionBar" :ref="(el: unknown) => setActionBarRef(el)">
<div class="flex items-center justify-between pb-2">
<ButtonStyled type="transparent">
<button
type="button"
:aria-expanded="isExpanded"
:aria-controls="stackId"
@click="toggleExpanded"
<Button
type="quiet"
native-type="button"
:aria-expanded="isExpanded"
:aria-controls="stackId"
@click="toggleExpanded"
>
<Motion
as="span"
class="inline-flex"
:animate="{ rotate: isExpanded ? 0 : -90 }"
:transition="{ type: 'spring', stiffness: 350, damping: 30 }"
>
<Motion
as="span"
class="inline-flex"
:animate="{ rotate: isExpanded ? 0 : -90 }"
:transition="{ type: 'spring', stiffness: 350, damping: 30 }"
>
<ChevronDownIcon class="h-4 w-4" />
</Motion>
<slot name="header-label" :count="items.length" :expanded="isExpanded">
{{ formatMessage(messages.alertCount, { count: items.length }) }}
</slot>
</button>
</ButtonStyled>
<ButtonStyled v-if="dismissAllEnabled" type="transparent">
<button type="button" @click="$emit('dismiss-all')">
<XIcon class="h-4 w-4" />
{{ formatMessage(messages.dismissAll) }}
</button>
</ButtonStyled>
<ChevronDownIcon class="h-4 w-4" />
</Motion>
<slot name="header-label" :count="items.length" :expanded="isExpanded">
{{ formatMessage(messages.alertCount, { count: items.length }) }}
</slot>
</Button>
<Button
v-if="dismissAllEnabled"
type="quiet"
native-type="button"
@click="$emit('dismiss-all')"
>
<XIcon class="h-4 w-4" />
{{ formatMessage(messages.dismissAll) }}
</Button>
</div>
</div>
</Transition>
@@ -1,522 +0,0 @@
<template>
<div data-pyro-telepopover-wrapper class="relative">
<button
ref="triggerRef"
v-tooltip="tooltip"
class="teleport-overflow-menu-trigger"
:class="btnClass"
:aria-expanded="isOpen"
:aria-haspopup="true"
:aria-label="ariaLabel"
:disabled="disabled"
@mousedown="handleMouseDown"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@click="toggleMenu"
>
<slot></slot>
</button>
<Teleport to="#teleports">
<Transition
enter-active-class="transition duration-125 ease-out"
enter-from-class="transform scale-75 opacity-0"
enter-to-class="transform scale-100 opacity-100"
leave-active-class="transition duration-125 ease-in"
leave-from-class="transform scale-100 opacity-100"
leave-to-class="transform scale-75 opacity-0"
>
<div
v-if="isOpen"
ref="menuRef"
data-pyro-telepopover-root
class="fixed isolate z-[9999] flex w-fit flex-col gap-2 overflow-x-hidden overflow-y-auto rounded-2xl border-[1px] border-solid border-surface-5 bg-bg-raised p-2 shadow-lg"
:style="menuStyle"
role="menu"
tabindex="-1"
@mousedown.stop
@wheel.stop
@mouseleave="handleMouseLeave"
>
<template
v-for="(option, index) in filteredOptions"
:key="isDivider(option) ? `divider-${index}` : option.id"
>
<div v-if="isDivider(option)" class="h-px w-full bg-surface-5"></div>
<ButtonStyled
v-else
type="transparent"
role="menuitem"
:color="optionButtonColor(option)"
>
<button
v-if="typeof option.action === 'function' || option.disabled"
:ref="
(el) => {
if (el) menuItemsRef[index] = el as HTMLElement
}
"
v-tooltip="option.tooltip"
:disabled="option.disabled"
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
:aria-label="option.ariaLabel ?? option.label ?? option.id"
:aria-selected="index === selectedIndex"
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
@click="(event) => handleItemClick(option, index, event)"
@focus="selectedIndex = index"
@mouseover="handleMouseOver(index)"
>
<slot :name="option.id">
<component :is="option.icon" v-if="option.icon" class="size-5" />
{{ option.label ?? option.id }}
</slot>
</button>
<AutoLink
v-else-if="optionLink(option)"
:ref="
(el) => {
if (el) menuItemsRef[index] = el as HTMLElement
}
"
:to="optionLink(option)"
:target="option.external ? '_blank' : undefined"
:rel="option.external ? 'noopener noreferrer' : undefined"
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
:aria-label="option.ariaLabel ?? option.label ?? option.id"
:aria-selected="index === selectedIndex"
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
@click="(event) => handleItemClick(option, index, event)"
@focus="selectedIndex = index"
@mouseover="handleMouseOver(index)"
>
<slot :name="option.id">
<component :is="option.icon" v-if="option.icon" class="size-5" />
{{ option.label ?? option.id }}
</slot>
</AutoLink>
<span v-else>
<slot :name="option.id">
<component :is="option.icon" v-if="option.icon" class="size-5" />
{{ option.label ?? option.id }}
</slot>
</span>
</ButtonStyled>
</template>
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { onClickOutside, useElementHover } from '@vueuse/core'
import { type Component, computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import AutoLink from './AutoLink.vue'
import ButtonStyled from './ButtonStyled.vue'
type OptionColor =
| 'standard'
| 'brand'
| 'primary'
| 'danger'
| 'secondary'
| 'highlight'
| 'red'
| 'orange'
| 'green'
| 'blue'
| 'purple'
export interface Option {
id: string
label?: string
icon?: Component
action?: ((event?: MouseEvent) => void) | string
link?: string
external?: boolean
shown?: boolean
color?: OptionColor
disabled?: boolean
tooltip?: string
ariaLabel?: string
remainOnClick?: boolean
}
export type Divider = {
divider?: boolean
shown?: boolean
}
export type Item = Option | Divider
function isDivider(item: Item): item is Divider {
return !!(item as Divider).divider
}
const props = withDefaults(
defineProps<{
options: Item[]
hoverable?: boolean
btnClass?: string | string[] | Record<string, boolean>
disabled?: boolean
tooltip?: string
ariaLabel?: string
placement?: 'right' | 'center'
}>(),
{
hoverable: false,
btnClass: undefined,
disabled: false,
tooltip: undefined,
ariaLabel: undefined,
placement: 'right',
},
)
const emit = defineEmits<{
select: [option: Option]
open: []
}>()
const isOpen = ref(false)
const selectedIndex = ref(-1)
const menuRef = ref<HTMLElement | null>(null)
const triggerRef = ref<HTMLElement | null>(null)
const isMouseDown = ref(false)
const typeAheadBuffer = ref('')
const typeAheadTimeout = ref<number | null>(null)
const menuItemsRef = ref<HTMLElement[]>([])
const hoveringTrigger = useElementHover(triggerRef)
const hoveringMenu = useElementHover(menuRef)
const hovering = computed(() => hoveringTrigger.value || hoveringMenu.value)
const menuStyle = ref<Record<string, string>>({
top: '-9999px',
left: '-9999px',
})
const filteredOptions = computed(() => props.options.filter((option) => option.shown !== false))
const calculateMenuPosition = () => {
if (!triggerRef.value || !menuRef.value) return null
const triggerRect = triggerRef.value.getBoundingClientRect()
// offsetWidth is not affected by CSS transforms (unlike getBoundingClientRect)
// scrollHeight gives the full content height regardless of any maxHeight constraint,
// preventing a feedback loop where clamped offsetHeight makes it look like the menu fits
const menuWidth = menuRef.value.offsetWidth
const menuHeight = menuRef.value.scrollHeight
const margin = 8
let top: number
let maxHeight: number | null = null
const spaceBelow = window.innerHeight - triggerRect.bottom - margin
const spaceAbove = triggerRect.top - margin
if (menuHeight <= spaceBelow) {
top = triggerRect.bottom + margin
} else if (menuHeight <= spaceAbove) {
top = triggerRect.top - menuHeight - margin
} else if (spaceBelow >= spaceAbove) {
top = triggerRect.bottom + margin
maxHeight = spaceBelow
} else {
maxHeight = spaceAbove
top = triggerRect.top - maxHeight - margin
}
const preferredLeft =
props.placement === 'center'
? triggerRect.left + triggerRect.width / 2 - menuWidth / 2
: triggerRect.right - menuWidth
const left = Math.max(margin, Math.min(preferredLeft, window.innerWidth - menuWidth - margin))
return {
top: `${top}px`,
left: `${left}px`,
...(maxHeight !== null ? { maxHeight: `${maxHeight}px` } : {}),
}
}
const toggleMenu = (event: MouseEvent) => {
event.stopPropagation()
if (props.disabled) return
if (!props.hoverable) {
if (isOpen.value) {
closeMenu()
} else {
openMenu()
}
}
}
const openMenu = () => {
if (props.disabled) return
menuStyle.value = { top: '-9999px', left: '-9999px' }
isOpen.value = true
emit('open')
// nextTick lets Vue render the element, then requestAnimationFrame waits for the
// browser to complete layout so offsetWidth/offsetHeight are real values.
nextTick(() =>
requestAnimationFrame(() => {
const pos = calculateMenuPosition()
if (pos) menuStyle.value = pos
document.addEventListener('mousemove', handleMouseMove)
focusFirstMenuItem()
}),
)
}
const closeMenu = () => {
isOpen.value = false
selectedIndex.value = -1
document.removeEventListener('mousemove', handleMouseMove)
}
const selectOption = (option: Option, event?: MouseEvent) => {
emit('select', option)
if (typeof option.action === 'function') {
option.action(event)
}
if (!option.remainOnClick) {
closeMenu()
}
}
const handleMouseDown = (event: MouseEvent) => {
if (props.disabled) return
event.preventDefault()
isMouseDown.value = true
}
const handleMouseEnter = () => {
if (props.hoverable) {
openMenu()
}
}
const handleMouseLeave = () => {
if (props.hoverable) {
setTimeout(() => {
if (!hovering.value) {
closeMenu()
}
}, 250)
}
}
const handleMouseMove = (event: MouseEvent) => {
if (!isOpen.value || !isMouseDown.value) return
const menuRect = menuRef.value?.getBoundingClientRect()
if (!menuRect) return
const menuItems = menuRef.value?.querySelectorAll('[role="menuitem"]')
if (!menuItems) return
for (let i = 0; i < menuItems.length; i++) {
const itemRect = (menuItems[i] as HTMLElement).getBoundingClientRect()
if (
event.clientX >= itemRect.left &&
event.clientX <= itemRect.right &&
event.clientY >= itemRect.top &&
event.clientY <= itemRect.bottom
) {
selectedIndex.value = i
break
}
}
}
const handleItemClick = (option: Option, index: number, event?: MouseEvent) => {
if (option.disabled) return
selectedIndex.value = index
selectOption(option, event)
}
const handleMouseOver = (index: number) => {
selectedIndex.value = index
menuItemsRef.value[selectedIndex.value]?.focus?.()
}
const focusFirstMenuItem = () => {
if (menuItemsRef.value.length > 0) {
menuItemsRef.value[0]?.focus?.()
}
}
const handleKeydown = (event: KeyboardEvent) => {
if (!isOpen.value) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
openMenu()
}
return
}
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
selectedIndex.value = (selectedIndex.value + 1) % filteredOptions.value.length
menuItemsRef.value[selectedIndex.value]?.focus?.()
break
case 'ArrowUp':
event.preventDefault()
selectedIndex.value =
(selectedIndex.value - 1 + filteredOptions.value.length) % filteredOptions.value.length
menuItemsRef.value[selectedIndex.value]?.focus?.()
break
case 'Home':
event.preventDefault()
if (menuItemsRef.value.length > 0) {
selectedIndex.value = 0
menuItemsRef.value[selectedIndex.value]?.focus?.()
}
break
case 'End':
event.preventDefault()
if (menuItemsRef.value.length > 0) {
selectedIndex.value = filteredOptions.value.length - 1
menuItemsRef.value[selectedIndex.value]?.focus?.()
}
break
case 'Enter':
case ' ':
event.preventDefault()
if (selectedIndex.value >= 0) {
const option = filteredOptions.value[selectedIndex.value]
if (isDivider(option)) break
selectOption(option)
}
break
case 'Escape':
event.preventDefault()
closeMenu()
triggerRef.value?.focus?.()
break
case 'Tab':
event.preventDefault()
if (menuItemsRef.value.length > 0) {
if (event.shiftKey) {
selectedIndex.value =
(selectedIndex.value - 1 + filteredOptions.value.length) % filteredOptions.value.length
} else {
selectedIndex.value = (selectedIndex.value + 1) % filteredOptions.value.length
}
menuItemsRef.value[selectedIndex.value]?.focus?.()
}
break
default:
if (event.key.length === 1) {
typeAheadBuffer.value += event.key.toLowerCase()
const matchIndex = filteredOptions.value.findIndex(
(option) =>
!isDivider(option) && option.id.toLowerCase().startsWith(typeAheadBuffer.value),
)
if (matchIndex !== -1) {
selectedIndex.value = matchIndex
menuItemsRef.value[selectedIndex.value]?.focus?.()
}
if (typeAheadTimeout.value) {
clearTimeout(typeAheadTimeout.value)
}
typeAheadTimeout.value = setTimeout(() => {
typeAheadBuffer.value = ''
}, 1000) as unknown as number
}
break
}
}
const handleResize = () => {
if (!isOpen.value) return
const pos = calculateMenuPosition()
if (pos) menuStyle.value = pos
}
const handleScroll = () => {
if (!isOpen.value) return
const pos = calculateMenuPosition()
if (!pos) return
// On scroll only the vertical position changes don't update left, which would shift
// the menu horizontally due to scrollbar appearing/disappearing changing offsetWidth
const updated: Record<string, string> = { ...menuStyle.value, top: pos.top }
if (pos.maxHeight) {
updated.maxHeight = pos.maxHeight
} else {
delete updated.maxHeight
}
menuStyle.value = updated
}
const throttle = <T extends unknown[]>(
func: (...args: T) => void,
limit: number,
): ((...args: T) => void) => {
let inThrottle: boolean
return function (...args: T) {
if (!inThrottle) {
func(...args)
inThrottle = true
setTimeout(() => (inThrottle = false), limit)
}
}
}
const throttledHandleResize = throttle(handleResize, 100)
const throttledHandleScroll = throttle(handleScroll, 100)
onMounted(() => {
triggerRef.value?.addEventListener('keydown', handleKeydown)
window.addEventListener('resize', throttledHandleResize)
window.addEventListener('scroll', throttledHandleScroll)
})
onUnmounted(() => {
triggerRef.value?.removeEventListener('keydown', handleKeydown)
window.removeEventListener('resize', throttledHandleResize)
window.removeEventListener('scroll', throttledHandleScroll)
document.removeEventListener('mousemove', handleMouseMove)
if (typeAheadTimeout.value) {
clearTimeout(typeAheadTimeout.value)
}
})
watch(isOpen, (newValue) => {
if (newValue) {
nextTick(() => {
menuRef.value?.addEventListener('keydown', handleKeydown)
})
} else {
menuRef.value?.removeEventListener('keydown', handleKeydown)
}
})
onClickOutside(menuRef, (event) => {
if (!triggerRef.value?.contains(event.target as Node)) {
closeMenu()
}
})
function optionLink(option: Option) {
if (typeof option.action === 'string') return option.action
return option.link
}
function optionButtonColor(option: Option) {
switch (option.color) {
case 'primary':
return 'brand'
case 'danger':
return 'red'
case 'secondary':
case 'highlight':
return 'standard'
default:
return option.color ?? 'standard'
}
}
</script>
@@ -5,6 +5,9 @@
:display-value="selectedTimeframeLabel"
:max-height="maxHeight"
:trigger-class="triggerClass"
:trigger-type="triggerType"
:trigger-size="triggerSize"
:trigger-interaction="triggerInteraction"
:dropdown-min-width="timeframeDropdownMinWidth"
:outside-click-ignore="timeframeDropdownOutsideClickIgnore"
:dropdown-class="
@@ -103,16 +106,18 @@
</div>
<div class="flex items-center gap-2">
<ButtonStyled type="outlined">
<button type="button" @click="handleCustomRangeCancel">
{{ formatMessage(messages.cancel) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button type="button" :disabled="!hasCompleteRange" @click="handleCustomRangeApply">
{{ formatMessage(messages.apply) }}
</button>
</ButtonStyled>
<Button type="outlined" native-type="button" @click="handleCustomRangeCancel">
{{ formatMessage(messages.cancel) }}
</Button>
<Button
type="colored"
color="brand"
native-type="button"
:disabled="!hasCompleteRange"
@click="handleCustomRangeApply"
>
{{ formatMessage(messages.apply) }}
</Button>
</div>
</div>
</div>
@@ -196,8 +201,14 @@
import { MinusIcon, PlusIcon } from '@modrinth/assets'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import {
Button,
type ButtonInteraction,
type ButtonSize,
type ButtonType,
} from '#ui/components/base/buttons'
import { defineMessages, useVIntl } from '../../composables/i18n'
import ButtonStyled from './ButtonStyled.vue'
import Combobox, { type ComboboxOption } from './Combobox.vue'
import DatePicker from './DatePicker.vue'
@@ -391,11 +402,17 @@ const props = withDefaults(
nowTimestamp?: number
maxHeight?: number
triggerClass?: string
triggerType?: ButtonType
triggerSize?: ButtonSize
triggerInteraction?: ButtonInteraction
dropdownMinWidth?: string | number
customRangeDropdownMinWidth?: string | number
}>(),
{
maxHeight: TIMEFRAME_DROPDOWN_MAX_HEIGHT,
triggerType: 'base',
triggerSize: 'md',
triggerInteraction: 'surface',
dropdownMinWidth: TIMEFRAME_DROPDOWN_MIN_WIDTH,
customRangeDropdownMinWidth: CUSTOM_RANGE_DROPDOWN_MIN_WIDTH,
},
@@ -3,9 +3,10 @@ import { HistoryIcon, SaveIcon, SpinnerIcon } from '@modrinth/assets'
import { isEqual } from 'es-toolkit'
import { type Component, computed, ref } from 'vue'
import { Button } from '#ui/components/base/buttons'
import { defineMessage, type MessageDescriptor, useVIntl } from '../../composables/i18n'
import { commonMessages } from '../../utils'
import ButtonStyled from './ButtonStyled.vue'
import FloatingActionBar from './FloatingActionBar.vue'
const { formatMessage } = useVIntl()
@@ -62,18 +63,14 @@ defineExpose({ nudge })
<FloatingActionBar ref="actionBar" :shown="shown" :inline="inline">
<p class="m-0 font-semibold text-sm md:text-base">{{ localizeIfPossible(text) }}</p>
<div class="ml-auto flex gap-2">
<ButtonStyled v-if="canReset" type="transparent">
<button :disabled="saving" @click="(e) => emit('reset', e)">
<HistoryIcon /> {{ formatMessage(commonMessages.resetButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="saving" @click="(e) => emit('save', e)">
<SpinnerIcon v-if="saving" class="animate-spin" />
<component :is="saveIcon" v-else />
{{ localizeIfPossible(saving ? savingLabel : saveLabel) }}
</button>
</ButtonStyled>
<Button v-if="canReset" type="quiet" :disabled="saving" @click="(e) => emit('reset', e)">
<HistoryIcon /> {{ formatMessage(commonMessages.resetButton) }}
</Button>
<Button type="colored" color="brand" :disabled="saving" @click="(e) => emit('save', e)">
<SpinnerIcon v-if="saving" class="animate-spin" />
<component :is="saveIcon" v-else />
{{ localizeIfPossible(saving ? savingLabel : saveLabel) }}
</Button>
</div>
</FloatingActionBar>
</template>
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import ButtonFrame from './ButtonFrame.vue'
import type { ButtonProps } from './types'
const props = withDefaults(defineProps<ButtonProps>(), {
type: 'base',
size: 'md',
nativeType: 'button',
disabled: false,
loading: false,
})
const frame = ref<InstanceType<typeof ButtonFrame> | null>(null)
const element = computed(() => frame.value?.element ?? null)
defineExpose({ element })
</script>
<template>
<ButtonFrame
ref="frame"
as="button"
:type="props.type"
:color="props.color"
:size="props.size"
:interaction="props.interaction"
:native-type="props.nativeType"
:disabled="props.disabled || props.loading"
:aria-busy="props.loading || undefined"
>
<slot />
</ButtonFrame>
</template>
@@ -0,0 +1,165 @@
<script setup lang="ts">
import type { Component, CSSProperties } from 'vue'
import { computed, ref } from 'vue'
import type {
ButtonColor,
ButtonInteraction,
ButtonNativeType,
ButtonSize,
ButtonType,
} from './types'
const baseClasses = [
// Base
'relative inline-flex min-w-0 shrink-0 items-center justify-center',
'whitespace-nowrap border-0 no-underline',
// Interactions
'touch-manipulation cursor-pointer select-none transition-[background-color,color,box-shadow,filter,opacity,transform] duration-150 ease-out',
'enabled:active:scale-[0.97]',
// Hovering
'[&:not(:disabled):not([aria-disabled=true]):hover]:brightness-[--hover-brightness]',
// Accessibility
'[&:not(:disabled):not([aria-disabled=true]):focus-visible]:brightness-[--hover-brightness] focus-visible:outline-none [&:not(:disabled):not([aria-disabled=true]):focus-visible]:ring-4 [&:not(:disabled):not([aria-disabled=true]):focus-visible]:ring-brand-shadow',
'disabled:cursor-not-allowed disabled:opacity-50',
'[&[aria-disabled=true]]:cursor-not-allowed [&[aria-disabled=true]]:opacity-50',
].join(' ')
const sizeClasses: Record<ButtonSize, string> = {
xs: 'h-7 gap-1 rounded-lg px-1.5 text-sm font-semibold leading-5 [&>svg]:size-4 [&>svg]:min-h-4 [&>svg]:min-w-4 [&>svg]:shrink-0',
sm: 'h-8 gap-1 rounded-[10px] px-1.5 text-sm font-semibold leading-5 [&>svg]:size-4 [&>svg]:min-h-4 [&>svg]:min-w-4 [&>svg]:shrink-0',
md: 'h-9 gap-1.5 rounded-xl px-2.5 text-base font-semibold leading-5 [&>svg]:size-5 [&>svg]:min-h-5 [&>svg]:min-w-5 [&>svg]:shrink-0',
lg: 'h-10 gap-2 rounded-[14px] px-4 text-base font-semibold leading-5 [&>svg]:size-5 [&>svg]:min-h-5 [&>svg]:min-w-5 [&>svg]:shrink-0',
xl: 'h-12 gap-2 rounded-2xl px-3.5 text-base font-extrabold leading-5 [&>svg]:size-6 [&>svg]:min-h-6 [&>svg]:min-w-6 [&>svg]:shrink-0',
}
const iconOnlySizeClasses: Record<ButtonSize, string> = {
xs: 'w-7 !px-0',
sm: 'w-8 !px-0',
md: 'w-9 !px-0',
lg: 'w-10 !px-0',
xl: 'w-12 !px-0',
}
const typeClasses: Record<ButtonType, string> = {
base: 'button-frame--base bg-surface-4 text-contrast [&>svg]:text-primary',
colored:
'button-frame--colored bg-[--button-color] text-[var(--color-accent-contrast)] [&>svg]:text-inherit',
outlined:
'button-frame--outlined bg-transparent text-[var(--button-color,var(--color-contrast))] [&>svg]:text-inherit',
quiet: 'button-frame--quiet bg-transparent [&>svg]:text-inherit',
}
const interactionClasses: Record<ButtonInteraction, string> = {
surface:
'[&:not(:disabled):not([aria-disabled=true]):hover]:bg-surface-4 [&:not(:disabled):not([aria-disabled=true]):focus-visible]:bg-surface-4',
filled:
'[&:not(:disabled):not([aria-disabled=true]):hover]:!bg-[--button-color] [&:not(:disabled):not([aria-disabled=true]):focus-visible]:!bg-[--button-color] [&:not(:disabled):not([aria-disabled=true]):hover]:!text-[var(--color-accent-contrast)] [&:not(:disabled):not([aria-disabled=true]):focus-visible]:!text-[var(--color-accent-contrast)]',
none: '[&:not(:disabled):not([aria-disabled=true]):hover]:!brightness-100 [&:not(:disabled):not([aria-disabled=true]):focus-visible]:!brightness-100',
}
const colorVariables: Record<ButtonColor, string> = {
brand: 'var(--color-brand)',
red: 'var(--color-red)',
orange: 'var(--color-orange)',
green: 'var(--color-green)',
blue: 'var(--color-blue)',
purple: 'var(--color-purple)',
medal_promotion: 'var(--medal-promotion-text-orange, var(--color-orange))',
}
const props = withDefaults(
defineProps<{
as: string | Component
type?: ButtonType
color?: ButtonColor
size?: ButtonSize
interaction?: ButtonInteraction
iconOnly?: boolean
circular?: boolean
nativeType?: ButtonNativeType
}>(),
{
type: 'base',
size: 'md',
interaction: 'surface',
iconOnly: false,
circular: false,
nativeType: undefined,
},
)
const element = ref<HTMLElement | null>(null)
const classes = computed(() => [
baseClasses,
typeClasses[props.type],
props.type === 'quiet' ? interactionClasses[props.interaction] : '',
sizeClasses[props.size],
props.iconOnly ? iconOnlySizeClasses[props.size] : '',
props.circular ? '!rounded-full' : '',
])
const style = computed((): CSSProperties | undefined => {
if ((props.type === 'outlined' || props.type === 'quiet') && !props.color) return undefined
if (props.type !== 'colored' && props.type !== 'outlined' && props.type !== 'quiet')
return undefined
return {
'--button-color': colorVariables[props.color ?? 'brand'],
} as CSSProperties
})
defineExpose({ element })
</script>
<template>
<component
:is="as"
ref="element"
data-button
:type="props.nativeType"
:class="classes"
:style="style"
>
<slot />
</component>
</template>
<style scoped>
.button-frame--base {
box-shadow:
inset 0 0 0 1px var(--surface-5),
0 1px 1px rgba(0, 0, 0, 0.12);
}
.button-frame--colored {
box-shadow:
0 0 0 1px color-mix(in srgb, var(--button-color) 30%, transparent),
0 2px 4px rgba(0, 0, 0, 0.04),
0 5px 8px rgba(0, 0, 0, 0.04),
0 10px 18px rgba(0, 0, 0, 0.03),
0 24px 48px rgba(0, 0, 0, 0.03);
}
.button-frame--colored::before {
position: absolute;
inset: 0;
padding: 1px;
pointer-events: none;
content: '';
border-radius: inherit;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0));
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
}
.button-frame--outlined {
box-shadow: inset 0 0 0 1px var(--button-color, var(--surface-5));
}
.button-frame--quiet {
color: var(--button-color, var(--color-base));
}
</style>
@@ -0,0 +1,85 @@
<script setup lang="ts">
withDefaults(
defineProps<{
label?: string
}>(),
{
label: undefined,
},
)
</script>
<template>
<div
:role="label ? 'group' : undefined"
:aria-label="label"
class="button-group inline-flex [&>[data-button]+[data-button]]:-ms-px [&>[data-button]:active]:z-10 [&>[data-button]:focus-visible]:z-10 [&>[data-button]:not(:disabled):not([aria-disabled=true]):hover]:z-10 [&>[data-button]:not(:first-child)]:rounded-l-none [&>[data-button]:not(:last-child)]:rounded-r-none"
>
<slot />
</div>
</template>
<style scoped>
.button-group
:deep([data-button].button-frame--colored:first-child:not(:last-child):not(:focus-visible)) {
clip-path: inset(-4rem var(--button-group-inner-clip, 1px) -4rem -4rem);
}
.button-group
:deep(
[data-button].button-frame--colored:not(:first-child):not(:last-child):not(:focus-visible)
) {
clip-path: inset(
-4rem var(--button-group-inner-clip, 1px) -4rem var(--button-group-leading-clip, 0px)
);
}
.button-group
:deep([data-button].button-frame--colored:last-child:not(:first-child):not(:focus-visible)) {
clip-path: inset(-4rem -4rem -4rem var(--button-group-leading-clip, 0px));
}
:dir(rtl)
.button-group
:deep([data-button].button-frame--colored:first-child:not(:last-child):not(:focus-visible)) {
clip-path: inset(-4rem -4rem -4rem var(--button-group-inner-clip, 1px));
}
:dir(rtl)
.button-group
:deep(
[data-button].button-frame--colored:not(:first-child):not(:last-child):not(:focus-visible)
) {
clip-path: inset(
-4rem var(--button-group-leading-clip, 0px) -4rem var(--button-group-inner-clip, 1px)
);
}
:dir(rtl)
.button-group
:deep([data-button].button-frame--colored:last-child:not(:first-child):not(:focus-visible)) {
clip-path: inset(-4rem var(--button-group-leading-clip, 0px) -4rem -4rem);
}
.button-group :deep([data-button].button-frame--colored:active) {
--button-group-inner-clip: 0;
}
/*
* the seam is normally drawn by the button on the right, but hovering over the left one will make it draw the seam instead. when pressed, make the right one draw its own seam again.
*/
.button-group
:deep([data-button].button-frame--colored:not(:disabled):not([aria-disabled='true']):hover) {
--button-group-inner-clip: 0;
}
.button-group
:deep(
[data-button].button-frame--colored:not(:disabled):not([aria-disabled='true']):hover:not(
:active
)
+ [data-button]
) {
--button-group-leading-clip: 1px;
}
</style>
@@ -0,0 +1,67 @@
<script setup lang="ts">
import { computed } from 'vue'
import { RouterLink } from 'vue-router'
import ButtonFrame from './ButtonFrame.vue'
import type {
ButtonColor,
ButtonInteraction,
ButtonLinkDestination,
ButtonSize,
ButtonType,
} from './types'
type ButtonLinkProps = ButtonLinkDestination & {
type?: ButtonType
color?: ButtonColor
size?: ButtonSize
interaction?: ButtonInteraction
target?: string
rel?: string
download?: string | boolean
disabled?: boolean
}
const props = withDefaults(defineProps<ButtonLinkProps>(), {
type: 'base',
size: 'md',
target: undefined,
rel: undefined,
download: undefined,
disabled: false,
})
const usesRouter = computed(() => props.to !== undefined && !props.disabled)
const component = computed(() => (usesRouter.value ? RouterLink : 'a'))
const resolvedRel = computed(() => {
if (props.rel) return props.rel
return props.target === '_blank' ? 'noopener noreferrer' : undefined
})
function handleClick(event: MouseEvent) {
if (!props.disabled) return
event.preventDefault()
event.stopImmediatePropagation()
}
</script>
<template>
<ButtonFrame
:as="component"
:type="props.type"
:color="props.color"
:size="props.size"
:interaction="props.interaction"
:to="usesRouter ? props.to : undefined"
:href="!usesRouter && !props.disabled ? props.href : undefined"
:target="props.target"
:rel="resolvedRel"
:download="!props.disabled ? props.download : undefined"
:aria-disabled="props.disabled || undefined"
:role="props.disabled ? 'link' : undefined"
:tabindex="props.disabled ? -1 : undefined"
@click="handleClick"
>
<slot />
</ButtonFrame>
</template>
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { fileIsValid } from '@modrinth/utils'
import { useFormatBytes } from '../../../composables'
import ButtonFrame from './ButtonFrame.vue'
import type { ButtonColor, ButtonInteraction, ButtonSize, ButtonType } from './types'
const props = withDefaults(
defineProps<{
prompt?: string
multiple?: boolean
accept?: string
maxSize?: number | null
disabled?: boolean
allowDrop?: boolean
type?: ButtonType
color?: ButtonColor
size?: ButtonSize
interaction?: ButtonInteraction
}>(),
{
prompt: 'Select file',
multiple: false,
accept: undefined,
maxSize: undefined,
disabled: false,
allowDrop: true,
type: 'base',
size: 'md',
},
)
const emit = defineEmits<{
change: [files: File[]]
}>()
const formatBytes = useFormatBytes()
function selectFiles(incoming: FileList) {
if (props.disabled) return
const validationOptions = { maxSize: props.maxSize, alertOnInvalid: true }
const validFiles = Array.from(incoming).filter((file) =>
fileIsValid(file, validationOptions, formatBytes),
)
if (validFiles.length > 0) emit('change', validFiles)
}
function handleChange(event: Event) {
const input = event.target as HTMLInputElement
if (input.files) selectFiles(input.files)
input.value = ''
}
function handleDrop(event: DragEvent) {
if (!props.allowDrop || !event.dataTransfer) return
selectFiles(event.dataTransfer.files)
}
</script>
<template>
<ButtonFrame
as="label"
:type="props.type"
:color="props.color"
:size="props.size"
:interaction="props.interaction"
:aria-disabled="props.disabled || undefined"
class="focus-within:outline-none focus-within:ring-4 focus-within:ring-brand-shadow"
@drop.prevent="handleDrop"
@dragover.prevent
>
<slot />
{{ props.prompt }}
<input
type="file"
:multiple="props.multiple"
:accept="props.accept"
:disabled="props.disabled"
class="absolute size-px overflow-hidden whitespace-nowrap [clip:rect(0,0,0,0)]"
@change="handleChange"
/>
</ButtonFrame>
</template>
@@ -0,0 +1,58 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import ButtonFrame from './ButtonFrame.vue'
import type {
ButtonColor,
ButtonInteraction,
ButtonNativeType,
ButtonSize,
ButtonType,
} from './types'
const props = withDefaults(
defineProps<{
label: string
type?: ButtonType
color?: ButtonColor
size?: ButtonSize
interaction?: ButtonInteraction
nativeType?: ButtonNativeType
circular?: boolean
disabled?: boolean
loading?: boolean
}>(),
{
type: 'base',
size: 'md',
nativeType: 'button',
circular: true,
disabled: false,
loading: false,
},
)
const frame = ref<InstanceType<typeof ButtonFrame> | null>(null)
const element = computed(() => frame.value?.element ?? null)
defineExpose({ element })
</script>
<template>
<ButtonFrame
ref="frame"
as="button"
icon-only
:circular="props.circular"
:type="props.type"
:color="props.color"
:size="props.size"
:interaction="props.interaction"
:native-type="props.nativeType"
:disabled="props.disabled || props.loading"
:aria-label="props.label"
:aria-busy="props.loading || undefined"
>
<slot />
</ButtonFrame>
</template>
@@ -0,0 +1,88 @@
<script setup lang="ts">
import { DropdownIcon } from '@modrinth/assets'
import { computed, useSlots } from 'vue'
import Button from './Button.vue'
import ButtonGroup from './ButtonGroup.vue'
import TeleportOverflowMenu from './TeleportOverflowMenu.vue'
import type {
ButtonColor,
ButtonInteraction,
ButtonNativeType,
ButtonSize,
ButtonType,
OverflowMenuAction,
OverflowMenuLink,
OverflowMenuOption,
TeleportPlacement,
} from './types'
const props = withDefaults(
defineProps<{
menuLabel: string
options: OverflowMenuOption[]
groupLabel?: string
type?: ButtonType
color?: ButtonColor
size?: ButtonSize
interaction?: ButtonInteraction
nativeType?: ButtonNativeType
disabled?: boolean
primaryDisabled?: boolean
menuDisabled?: boolean
placement?: TeleportPlacement
}>(),
{
groupLabel: undefined,
type: 'base',
size: 'md',
nativeType: 'button',
disabled: false,
primaryDisabled: false,
menuDisabled: false,
placement: 'bottom-end',
},
)
const emit = defineEmits<{
click: [event: MouseEvent]
select: [option: OverflowMenuAction | OverflowMenuLink]
}>()
const slots = useSlots()
const forwardedSlots = computed(() => Object.keys(slots).filter((name) => name !== 'default'))
</script>
<template>
<ButtonGroup :label="props.groupLabel">
<Button
:type="props.type"
:color="props.color"
:size="props.size"
:interaction="props.interaction"
:native-type="props.nativeType"
:disabled="props.disabled || props.primaryDisabled"
@click="emit('click', $event)"
>
<slot />
</Button>
<TeleportOverflowMenu
:label="props.menuLabel"
:options="props.options"
:type="props.type"
:color="props.color"
:size="props.size"
:interaction="props.interaction"
:circular="false"
:disabled="props.disabled || props.menuDisabled"
:placement="props.placement"
@select="emit('select', $event)"
>
<DropdownIcon aria-hidden="true" />
<template v-for="slotName in forwardedSlots" #[slotName]="slotProps">
<slot :name="slotName" v-bind="slotProps" />
</template>
</TeleportOverflowMenu>
</ButtonGroup>
</template>
@@ -0,0 +1,469 @@
<script setup lang="ts">
import type { CSSProperties } from 'vue'
import { computed, nextTick, onUnmounted, ref, useId, watch } from 'vue'
import { RouterLink } from 'vue-router'
import { useAnchoredTeleport } from '../../../utils/use-anchored-teleport'
import Button from './Button.vue'
import IconButton from './IconButton.vue'
import type {
ButtonColor,
ButtonElementHandle,
ButtonInteraction,
ButtonSize,
ButtonType,
OverflowMenuAction,
OverflowMenuLink,
OverflowMenuOption,
TeleportPlacement,
} from './types'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
label: string
options: OverflowMenuOption[]
type?: ButtonType
color?: ButtonColor
size?: ButtonSize
interaction?: ButtonInteraction
disabled?: boolean
iconOnly?: boolean
circular?: boolean
tooltip?: string
placement?: TeleportPlacement
distance?: number
hoverable?: boolean
}>(),
{
type: 'base',
size: 'md',
disabled: false,
iconOnly: true,
circular: true,
placement: 'bottom-end',
distance: 8,
hoverable: false,
},
)
const emit = defineEmits<{
select: [option: OverflowMenuAction | OverflowMenuLink]
open: []
close: []
}>()
const triggerButton = ref<ButtonElementHandle | null>(null)
const triggerElement = computed(() => triggerButton.value?.element ?? null)
const panelElement = ref<HTMLElement | null>(null)
const resolvedPlacement = computed(() => props.placement)
const resolvedDistance = computed(() => props.distance)
const menuId = `button-overflow-${useId()}`
const selectedIndex = ref(-1)
const typeahead = ref('')
let typeaheadTimer: ReturnType<typeof setTimeout> | undefined
let hoverCloseTimer: ReturnType<typeof setTimeout> | undefined
const triggerComponent = computed(() => (props.iconOnly ? IconButton : Button))
const visibleOptions = computed(() => props.options.filter((option) => option.shown !== false))
const menuOptions = computed(() =>
visibleOptions.value.filter(
(option): option is OverflowMenuAction | OverflowMenuLink => option.type !== 'divider',
),
)
const { isOpen, panelStyle, anchorStyle, resolvedSide, open, close } = useAnchoredTeleport(
triggerElement,
panelElement,
resolvedPlacement,
resolvedDistance,
)
const menuItemClasses =
'overflow-menu-item flex min-h-10 w-full items-center gap-2 rounded-[10px] border-0 bg-transparent px-3 py-2 text-left text-base font-semibold leading-5 text-contrast no-underline ' +
'cursor-pointer whitespace-nowrap hover:bg-surface-4 focus-visible:bg-surface-4 focus-visible:outline-none ' +
'disabled:cursor-not-allowed disabled:opacity-50 [&[aria-disabled=true]]:cursor-not-allowed [&[aria-disabled=true]]:opacity-50 ' +
'[&>svg]:size-5 [&>svg]:shrink-0 [&>svg]:text-primary'
const toneVariables: Record<ButtonColor, string> = {
brand: 'var(--color-brand)',
red: 'var(--color-red)',
orange: 'var(--color-orange)',
green: 'var(--color-green)',
blue: 'var(--color-blue)',
purple: 'var(--color-purple)',
medal_promotion: 'var(--medal-promotion-text-orange, var(--color-orange))',
}
function getMenuItemStyle(option: OverflowMenuAction | OverflowMenuLink) {
if (!option.tone || option.tone === 'default') return undefined
return {
'--overflow-menu-item-tone': toneVariables[option.tone],
} as CSSProperties
}
function isDivider(
option: OverflowMenuOption,
): option is Extract<OverflowMenuOption, { type: 'divider' }> {
return option.type === 'divider'
}
function isLink(option: OverflowMenuOption): option is OverflowMenuLink {
return option.type === 'link'
}
function getMenuItems() {
if (!panelElement.value) return []
return Array.from(panelElement.value.querySelectorAll<HTMLElement>('[role="menuitem"]'))
}
function focusItem(index: number) {
const items = getMenuItems()
if (items.length === 0) return
selectedIndex.value = (index + items.length) % items.length
items[selectedIndex.value]?.focus()
}
async function openMenu(position: 'first' | 'last' = 'first', focus = true) {
if (props.disabled || isOpen.value) return
await open()
emit('open')
if (!focus) return
await nextTick()
focusItem(position === 'first' ? 0 : getMenuItems().length - 1)
}
function closeMenu(restoreFocus = false) {
if (!isOpen.value) return
selectedIndex.value = -1
close(restoreFocus)
}
async function toggleMenu(event?: MouseEvent) {
if (props.hoverable && window.matchMedia('(hover: hover)').matches && event?.detail) return
if (isOpen.value) closeMenu()
else await openMenu()
}
function clearHoverCloseTimer() {
if (!hoverCloseTimer) return
clearTimeout(hoverCloseTimer)
hoverCloseTimer = undefined
}
function handleMouseEnter() {
if (!props.hoverable || !window.matchMedia('(hover: hover)').matches) return
clearHoverCloseTimer()
openMenu('first', false)
}
function handleMouseLeave() {
if (!props.hoverable || !window.matchMedia('(hover: hover)').matches) return
clearHoverCloseTimer()
hoverCloseTimer = setTimeout(() => closeMenu(), 250)
}
function handleTriggerKeydown(event: KeyboardEvent) {
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return
event.preventDefault()
openMenu(event.key === 'ArrowDown' ? 'first' : 'last')
}
function handleAction(option: OverflowMenuAction, event: MouseEvent) {
if (option.disabled) return
option.action(event)
emit('select', option)
if (!option.remainOpen) closeMenu(true)
}
function handleLink(option: OverflowMenuLink, event: MouseEvent) {
if (option.disabled) {
event.preventDefault()
return
}
emit('select', option)
if (!option.remainOpen) closeMenu()
}
function handleLinkKeydown(option: OverflowMenuLink, event: KeyboardEvent) {
if (event.key !== ' ') return
event.preventDefault()
if (option.disabled) return
;(event.currentTarget as HTMLElement).click()
}
function handleMenuKeydown(event: KeyboardEvent) {
const items = getMenuItems()
if (items.length === 0) return
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
focusItem(selectedIndex.value + 1)
break
case 'ArrowUp':
event.preventDefault()
focusItem(selectedIndex.value - 1)
break
case 'Home':
event.preventDefault()
focusItem(0)
break
case 'End':
event.preventDefault()
focusItem(items.length - 1)
break
case 'Escape':
event.preventDefault()
closeMenu(true)
break
case 'Tab':
triggerElement.value?.focus()
closeMenu()
break
default: {
if (
event.key === ' ' ||
event.key.length !== 1 ||
event.ctrlKey ||
event.metaKey ||
event.altKey
)
return
const character = event.key.toLocaleLowerCase()
const query = typeahead.value === character ? character : `${typeahead.value}${character}`
const startIndex = query.length === 1 ? selectedIndex.value + 1 : 0
typeahead.value = query
for (let offset = 0; offset < menuOptions.value.length; offset++) {
const index = (startIndex + offset) % menuOptions.value.length
if (menuOptions.value[index]?.label.toLocaleLowerCase().startsWith(query)) {
focusItem(index)
break
}
}
if (typeaheadTimer) clearTimeout(typeaheadTimer)
typeaheadTimer = setTimeout(() => {
typeahead.value = ''
}, 500)
}
}
}
watch(isOpen, (openState, previousOpenState) => {
if (!openState && previousOpenState) emit('close')
if (!openState && typeaheadTimer) {
clearTimeout(typeaheadTimer)
typeaheadTimer = undefined
typeahead.value = ''
}
})
onUnmounted(clearHoverCloseTimer)
defineExpose({ open: openMenu, close: closeMenu })
</script>
<template>
<component
:is="triggerComponent"
ref="triggerButton"
v-bind="$attrs"
v-tooltip="props.tooltip"
:label="props.iconOnly ? props.label : undefined"
:aria-label="props.iconOnly ? undefined : props.label"
:circular="props.iconOnly ? props.circular : undefined"
:type="props.type"
:color="props.color"
:size="props.size"
:interaction="props.interaction"
:disabled="props.disabled"
:aria-expanded="isOpen"
:aria-controls="menuId"
aria-haspopup="menu"
@click="toggleMenu"
@keydown="handleTriggerKeydown"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<slot />
</component>
<Teleport to="body">
<Transition
enter-active-class="transition duration-125 ease-out"
enter-from-class="scale-95 opacity-0"
enter-to-class="scale-100 opacity-100"
leave-active-class="transition duration-100 ease-in"
leave-from-class="scale-100 opacity-100"
leave-to-class="scale-95 opacity-0"
>
<div
v-if="isOpen"
:id="menuId"
ref="panelElement"
class="fixed isolate z-[9999] flex min-w-48 flex-col gap-1 rounded-[14px] bg-surface-3 p-2 shadow-lg ring-1 ring-surface-5"
:style="panelStyle"
role="menu"
:aria-label="props.label"
@keydown="handleMenuKeydown"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<span
aria-hidden="true"
class="overflow-menu-arrow"
:data-side="resolvedSide"
:style="anchorStyle"
/>
<template v-for="(option, index) in visibleOptions" :key="option.id ?? `divider-${index}`">
<div v-if="isDivider(option)" role="separator" class="my-1 h-px bg-surface-5" />
<RouterLink
v-else-if="isLink(option) && option.to !== undefined && !option.disabled"
v-tooltip="option.tooltip"
:to="option.to"
:class="menuItemClasses"
:style="getMenuItemStyle(option)"
:data-tone="option.tone && option.tone !== 'default' ? option.tone : undefined"
:data-hover-filled="option.hoverFilled || option.hoverFilledOnly || undefined"
:data-hover-filled-only="option.hoverFilledOnly || undefined"
role="menuitem"
tabindex="-1"
@click="handleLink(option, $event)"
@keydown="handleLinkKeydown(option, $event)"
@focus="selectedIndex = getMenuItems().indexOf($event.currentTarget as HTMLElement)"
>
<slot :name="option.id" :option="option">
<component :is="option.icon" v-if="option.icon" aria-hidden="true" />
{{ option.label }}
</slot>
</RouterLink>
<a
v-else-if="isLink(option)"
v-tooltip="option.tooltip"
:href="option.disabled ? undefined : option.href"
:target="option.target"
:rel="option.rel ?? (option.target === '_blank' ? 'noopener noreferrer' : undefined)"
:download="option.download"
:aria-disabled="option.disabled || undefined"
:class="menuItemClasses"
:style="getMenuItemStyle(option)"
:data-tone="option.tone && option.tone !== 'default' ? option.tone : undefined"
:data-hover-filled="option.hoverFilled || option.hoverFilledOnly || undefined"
:data-hover-filled-only="option.hoverFilledOnly || undefined"
role="menuitem"
tabindex="-1"
@click="handleLink(option, $event)"
@keydown="handleLinkKeydown(option, $event)"
@focus="selectedIndex = getMenuItems().indexOf($event.currentTarget as HTMLElement)"
>
<slot :name="option.id" :option="option">
<component :is="option.icon" v-if="option.icon" aria-hidden="true" />
{{ option.label }}
</slot>
</a>
<button
v-else
v-tooltip="option.tooltip"
type="button"
:aria-disabled="option.disabled || undefined"
:class="menuItemClasses"
:style="getMenuItemStyle(option)"
:data-tone="option.tone && option.tone !== 'default' ? option.tone : undefined"
:data-hover-filled="option.hoverFilled || option.hoverFilledOnly || undefined"
:data-hover-filled-only="option.hoverFilledOnly || undefined"
role="menuitem"
tabindex="-1"
@click="handleAction(option, $event)"
@focus="selectedIndex = getMenuItems().indexOf($event.currentTarget as HTMLElement)"
>
<slot :name="option.id" :option="option">
<component :is="option.icon" v-if="option.icon" aria-hidden="true" />
{{ option.label }}
</slot>
</button>
</template>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.overflow-menu-arrow {
position: absolute;
width: 0;
height: 0;
pointer-events: none;
}
.overflow-menu-arrow::before {
position: absolute;
width: 10px;
height: 10px;
content: '';
background-color: var(--surface-3);
transform: translate(-50%, -50%) rotate(45deg);
}
.overflow-menu-arrow[data-side='bottom'] {
top: 0;
}
.overflow-menu-arrow[data-side='bottom']::before {
border-top: 1px solid var(--surface-5);
border-left: 1px solid var(--surface-5);
}
.overflow-menu-arrow[data-side='top'] {
bottom: 0;
}
.overflow-menu-arrow[data-side='top']::before {
border-right: 1px solid var(--surface-5);
border-bottom: 1px solid var(--surface-5);
}
.overflow-menu-arrow[data-side='right'] {
left: 0;
}
.overflow-menu-arrow[data-side='right']::before {
border-bottom: 1px solid var(--surface-5);
border-left: 1px solid var(--surface-5);
}
.overflow-menu-arrow[data-side='left'] {
right: 0;
}
.overflow-menu-arrow[data-side='left']::before {
border-top: 1px solid var(--surface-5);
border-right: 1px solid var(--surface-5);
}
.overflow-menu-item[data-tone]:not([data-hover-filled-only]) {
color: var(--overflow-menu-item-tone);
}
.overflow-menu-item[data-tone]:not([data-hover-filled-only]) :deep(svg) {
color: var(--overflow-menu-item-tone);
}
.overflow-menu-item[data-tone][data-hover-filled]:hover,
.overflow-menu-item[data-tone][data-hover-filled]:focus-visible {
color: var(--color-accent-contrast);
background-color: var(--overflow-menu-item-tone);
}
.overflow-menu-item[data-tone][data-hover-filled]:hover :deep(svg),
.overflow-menu-item[data-tone][data-hover-filled]:focus-visible :deep(svg) {
color: inherit;
}
</style>
@@ -0,0 +1,149 @@
<script setup lang="ts">
import { computed, nextTick, ref, useId, watch } from 'vue'
import { useAnchoredTeleport } from '../../../utils/use-anchored-teleport'
import Button from './Button.vue'
import IconButton from './IconButton.vue'
import type {
ButtonColor,
ButtonElementHandle,
ButtonInteraction,
ButtonSize,
ButtonType,
TeleportPlacement,
} from './types'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
label: string
type?: ButtonType
color?: ButtonColor
size?: ButtonSize
interaction?: ButtonInteraction
disabled?: boolean
iconOnly?: boolean
tooltip?: string
autoFocus?: boolean
placement?: TeleportPlacement
panelRole?: 'dialog' | 'region'
}>(),
{
type: 'base',
size: 'md',
disabled: false,
iconOnly: false,
autoFocus: true,
placement: 'bottom-end',
panelRole: 'dialog',
},
)
const emit = defineEmits<{
open: []
close: []
}>()
const triggerButton = ref<ButtonElementHandle | null>(null)
const triggerElement = computed(() => triggerButton.value?.element ?? null)
const panelElement = ref<HTMLElement | null>(null)
const resolvedPlacement = computed(() => props.placement)
const panelId = `button-popout-${useId()}`
const triggerComponent = computed(() => (props.iconOnly ? IconButton : Button))
const { isOpen, panelStyle, open, close } = useAnchoredTeleport(
triggerElement,
panelElement,
resolvedPlacement,
)
function focusPanel() {
const focusable = panelElement.value?.querySelector<HTMLElement>(
'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
)
;(focusable ?? panelElement.value)?.focus()
}
async function openMenu() {
if (props.disabled || isOpen.value) return
await open()
emit('open')
if (props.autoFocus) await nextTick(focusPanel)
}
function closeMenu(restoreFocus = true) {
if (!isOpen.value) return
close(restoreFocus)
}
async function toggleMenu() {
if (isOpen.value) closeMenu()
else await openMenu()
}
function handleTriggerKeydown(event: KeyboardEvent) {
if (event.key !== 'Escape' || !isOpen.value) return
event.preventDefault()
closeMenu()
}
function handlePanelKeydown(event: KeyboardEvent) {
if (event.key !== 'Escape') return
event.preventDefault()
closeMenu(true)
}
watch(isOpen, (openState, previousOpenState) => {
if (!openState && previousOpenState) emit('close')
})
defineExpose({ open: openMenu, close: closeMenu })
</script>
<template>
<component
:is="triggerComponent"
ref="triggerButton"
v-bind="$attrs"
v-tooltip="props.tooltip"
:label="props.iconOnly ? props.label : undefined"
:type="props.type"
:color="props.color"
:size="props.size"
:interaction="props.interaction"
:disabled="props.disabled"
:aria-expanded="isOpen"
:aria-controls="panelId"
:aria-haspopup="props.panelRole === 'dialog' ? 'dialog' : undefined"
@click="toggleMenu"
@keydown="handleTriggerKeydown"
>
<slot name="trigger" />
</component>
<Teleport to="body">
<Transition
enter-active-class="transition duration-125 ease-out"
enter-from-class="scale-95 opacity-0"
enter-to-class="scale-100 opacity-100"
leave-active-class="transition duration-100 ease-in"
leave-from-class="scale-100 opacity-100"
leave-to-class="scale-95 opacity-0"
>
<div
v-if="isOpen"
:id="panelId"
ref="panelElement"
class="fixed isolate z-[9999] rounded-[14px] bg-surface-3 p-4 text-primary shadow-lg ring-1 ring-surface-5"
:style="panelStyle"
:role="props.panelRole"
:aria-label="props.label"
tabindex="-1"
@keydown="handlePanelKeydown"
>
<slot name="panel" :close="closeMenu" />
</div>
</Transition>
</Teleport>
</template>
@@ -0,0 +1,23 @@
export { default as Button } from './Button.vue'
export { default as ButtonGroup } from './ButtonGroup.vue'
export { default as ButtonLink } from './ButtonLink.vue'
export { default as FileButton } from './FileButton.vue'
export { default as IconButton } from './IconButton.vue'
export { default as SplitButton } from './SplitButton.vue'
export { default as TeleportOverflowMenu } from './TeleportOverflowMenu.vue'
export { default as TeleportPopoutMenu } from './TeleportPopoutMenu.vue'
export type {
ButtonColor,
ButtonElementHandle,
ButtonInteraction,
ButtonLinkDestination,
ButtonNativeType,
ButtonSize,
ButtonType,
ButtonVisualProps,
OverflowMenuAction,
OverflowMenuDivider,
OverflowMenuLink,
OverflowMenuOption,
TeleportPlacement,
} from './types'
@@ -0,0 +1,105 @@
import type { Component } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import type { AnchoredTeleportPlacement } from '../../../utils/use-anchored-teleport'
export type ButtonType = 'base' | 'colored' | 'outlined' | 'quiet'
export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
export type ButtonInteraction = 'surface' | 'filled' | 'none'
// TODO: Standardized color string enum props across @modrinth/ui
export type ButtonColor =
| 'brand'
| 'red'
| 'orange'
| 'green'
| 'blue'
| 'purple'
| 'medal_promotion'
export type ButtonVisualProps = {
size?: ButtonSize
interaction?: ButtonInteraction
} & (
| {
type?: 'base'
color?: never
}
| {
type: 'outlined'
color?: ButtonColor
}
| {
type: 'colored'
color?: ButtonColor
}
| {
type: 'quiet'
color?: ButtonColor
}
)
export type ButtonNativeType = 'button' | 'submit' | 'reset'
export interface ButtonProps {
type?: ButtonType
color?: ButtonColor
size?: ButtonSize
interaction?: ButtonInteraction
nativeType?: ButtonNativeType
disabled?: boolean
loading?: boolean
}
export type ButtonLinkDestination =
| {
to: RouteLocationRaw
href?: never
}
| {
href: string
to?: never
}
export type TeleportPlacement = AnchoredTeleportPlacement
export interface OverflowMenuItemBase {
id: string
label: string
icon?: Component
shown?: boolean
disabled?: boolean
tooltip?: string
remainOpen?: boolean
tone?: 'default' | ButtonColor
hoverFilled?: boolean
hoverFilledOnly?: boolean
}
export interface OverflowMenuAction extends OverflowMenuItemBase {
type?: 'action'
action: (event: MouseEvent) => void
}
export interface OverflowMenuLink extends OverflowMenuItemBase {
type: 'link'
to?: RouteLocationRaw
href?: string
target?: string
rel?: string
download?: string | boolean
}
export interface OverflowMenuDivider {
type: 'divider'
id?: string
shown?: boolean
}
export type OverflowMenuOption = OverflowMenuAction | OverflowMenuLink | OverflowMenuDivider
export interface ButtonElementHandle {
element: HTMLElement | null
}
+21 -11
View File
@@ -9,8 +9,27 @@ export { default as Badge } from './Badge.vue'
export { default as BaseTerminal } from './BaseTerminal.vue'
export { default as BigOptionButton } from './BigOptionButton.vue'
export { default as BulletDivider } from './BulletDivider.vue'
export { default as Button } from './Button.vue'
export { default as ButtonStyled } from './ButtonStyled.vue'
export { default as Button } from './buttons/Button.vue'
export { default as ButtonGroup } from './buttons/ButtonGroup.vue'
export { default as ButtonLink } from './buttons/ButtonLink.vue'
export { default as FileButton } from './buttons/FileButton.vue'
export { default as IconButton } from './buttons/IconButton.vue'
export { default as SplitButton } from './buttons/SplitButton.vue'
export { default as TeleportOverflowMenu } from './buttons/TeleportOverflowMenu.vue'
export { default as TeleportPopoutMenu } from './buttons/TeleportPopoutMenu.vue'
export type {
ButtonColor,
ButtonInteraction,
ButtonNativeType,
ButtonSize,
ButtonType,
ButtonVisualProps,
OverflowMenuAction,
OverflowMenuDivider,
OverflowMenuLink,
OverflowMenuOption,
TeleportPlacement,
} from './buttons/types'
export { default as Card } from './Card.vue'
export { default as Checkbox } from './Checkbox.vue'
export { default as Chips } from './Chips.vue'
@@ -46,8 +65,6 @@ export { default as HorizontalRule } from './HorizontalRule.vue'
export { default as I18nDebugPanel } from './I18nDebugPanel.vue'
export { default as IconSelect } from './IconSelect.vue'
export { default as IntlFormatted } from './IntlFormatted.vue'
export type { JoinedButtonAction } from './JoinedButtons.vue'
export { default as JoinedButtons } from './JoinedButtons.vue'
export { default as LoadingBar } from './LoadingBar.vue'
export { default as LoadingIndicator } from './LoadingIndicator.vue'
export { default as ManySelect } from './ManySelect.vue'
@@ -62,8 +79,6 @@ export type { MaybeCtxFn, StageButtonConfig, StageConfigInput } from './MultiSta
export { default as MultiStageModal, resolveCtxFn } from './MultiStageModal.vue'
export { default as NavTabs } from './NavTabs.vue'
export { default as OptionGroup } from './OptionGroup.vue'
export type { Option as OverflowMenuOption } from './OverflowMenu.vue'
export { default as OverflowMenu } from './OverflowMenu.vue'
export { default as Page } from './Page.vue'
export { default as PageHeader } from './page-header/index.vue'
export { default as PageHeaderMetadata } from './page-header/metadata/index.vue'
@@ -106,11 +121,6 @@ export type { TabsTab, TabsValue } from './Tabs.vue'
export { default as Tabs } from './Tabs.vue'
export { default as TagItem } from './TagItem.vue'
export { default as TagTagItem } from './TagTagItem.vue'
export type {
Item as TeleportOverflowMenuItem,
Option as TeleportOverflowMenuOption,
} from './TeleportOverflowMenu.vue'
export { default as TeleportOverflowMenu } from './TeleportOverflowMenu.vue'
export type {
TimeFrameLastUnit,
TimeFrameLastUnitOption,
@@ -3,9 +3,11 @@ import { PlusIcon, XIcon } from '@modrinth/assets'
import type Stripe from 'stripe'
import { nextTick, ref, useTemplateRef } from 'vue'
import { Button } from '#ui/components/base/buttons'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { commonMessages } from '../../utils'
import { ButtonStyled, NewModal } from '../index'
import { NewModal } from '../index'
import type { AddPaymentMethodProps } from './AddPaymentMethod.vue'
import AddPaymentMethod from './AddPaymentMethod.vue'
@@ -57,18 +59,14 @@ defineExpose({
@stop-loading="loading = false"
/>
<div class="input-group mt-auto pt-4">
<ButtonStyled color="brand">
<button :disabled="loading" @click="addPaymentMethod.submit()">
<PlusIcon />
{{ formatMessage(messages.paymentMethodAdd) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="modal.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<Button type="colored" color="brand" :disabled="loading" @click="addPaymentMethod.submit()">
<PlusIcon />
{{ formatMessage(messages.paymentMethodAdd) }}
</Button>
<Button @click="modal.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
</div>
</div>
</NewModal>
@@ -12,12 +12,12 @@ import { useQueryClient } from '@tanstack/vue-query'
import type Stripe from 'stripe'
import { computed, nextTick, onBeforeUnmount, ref, toRef, useTemplateRef, watch } from 'vue'
import { Button } from '#ui/components/base/buttons'
import { injectNotificationManager } from '#ui/providers/web-notifications.ts'
import { defineMessage, type MessageDescriptor, useVIntl } from '../../composables/i18n'
import { useStripe } from '../../composables/stripe'
import { commonMessages } from '../../utils'
import { ButtonStyled } from '../index'
import ModalLoadingIndicator from '../modal/ModalLoadingIndicator.vue'
import NewModal from '../modal/NewModal.vue'
import PlanSelector from './ServersPurchase0Plan.vue'
@@ -555,51 +555,50 @@ function goToBreadcrumbStep(id: string) {
</div>
</div>
<div class="flex gap-2 justify-between mt-4">
<ButtonStyled>
<button v-if="previousStep" @click="previousStep && setStep(previousStep, true)">
<LeftArrowIcon /> {{ formatMessage(commonMessages.backButton) }}
</button>
<button v-else-if="currentStep !== 'plan'" @click="modal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="currentStep !== 'plan'" color="brand">
<button
v-tooltip="
currentStep === 'review' && !acceptedEula && !noPaymentRequired
? 'You must accept the Minecraft EULA to proceed.'
: undefined
"
:disabled="!canProceed"
@click="
noPaymentRequired && currentStep === 'review'
? (async () => {
if (props.onFinalizeNoPaymentChange) {
try {
await props.onFinalizeNoPaymentChange()
} catch (e) {
return
}
<Button v-if="previousStep" @click="previousStep && setStep(previousStep, true)">
<LeftArrowIcon /> {{ formatMessage(commonMessages.backButton) }}
</Button>
<Button v-else-if="currentStep !== 'plan'" @click="modal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button
v-if="currentStep !== 'plan'"
v-tooltip="
currentStep === 'review' && !acceptedEula && !noPaymentRequired
? 'You must accept the Minecraft EULA to proceed.'
: undefined
"
type="colored"
color="brand"
:disabled="!canProceed"
@click="
noPaymentRequired && currentStep === 'review'
? (async () => {
if (props.onFinalizeNoPaymentChange) {
try {
await props.onFinalizeNoPaymentChange()
} catch (e) {
return
}
modal?.hide()
})()
: setStep(nextStep)
"
>
<template v-if="currentStep === 'review'">
<template v-if="noPaymentRequired"><CheckCircleIcon /> Confirm Change</template>
<template v-else>
<SpinnerIcon v-if="completingPurchase" class="animate-spin" />
<CheckCircleIcon v-else />
Subscribe
</template>
</template>
}
modal?.hide()
})()
: setStep(nextStep)
"
>
<template v-if="currentStep === 'review'">
<template v-if="noPaymentRequired"><CheckCircleIcon /> Confirm Change</template>
<template v-else>
{{ formatMessage(commonMessages.nextButton) }} <RightArrowIcon />
<SpinnerIcon v-if="completingPurchase" class="animate-spin" />
<CheckCircleIcon v-else />
Subscribe
</template>
</button>
</ButtonStyled>
</template>
<template v-else>
{{ formatMessage(commonMessages.nextButton) }} <RightArrowIcon />
</template>
</Button>
</div>
</NewModal>
</template>
@@ -78,18 +78,14 @@
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="outlined">
<button @click="handleCancel">
<XIcon />
{{ formatMessage(messages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="!canResubscribe" @click="handleResubscribe">
<RotateCounterClockwiseIcon />
{{ formatMessage(messages.resubscribeButton) }}
</button>
</ButtonStyled>
<Button type="outlined" @click="handleCancel">
<XIcon />
{{ formatMessage(messages.cancelButton) }}
</Button>
<Button type="colored" color="brand" :disabled="!canResubscribe" @click="handleResubscribe">
<RotateCounterClockwiseIcon />
{{ formatMessage(messages.resubscribeButton) }}
</Button>
</div>
</template>
</NewModal>
@@ -100,12 +96,13 @@ import type { Labrinth } from '@modrinth/api-client'
import { RotateCounterClockwiseIcon, XIcon } from '@modrinth/assets'
import { computed, ref, useTemplateRef } from 'vue'
import { Button } from '#ui/components/base/buttons'
import { injectNotificationManager } from '#ui/providers/web-notifications.ts'
import { useFormatDateTime, useFormatPrice } from '../../composables'
import { defineMessages, useVIntl } from '../../composables/i18n'
import IntlFormatted from '../base/IntlFormatted.vue'
import { ButtonStyled, NewModal } from '../index'
import { NewModal } from '../index'
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
@@ -3,7 +3,8 @@ import type { Labrinth } from '@modrinth/api-client'
import { ChevronRightIcon, ExternalIcon, XIcon } from '@modrinth/assets'
import { computed, ref, useTemplateRef } from 'vue'
import ButtonStyled from '../base/ButtonStyled.vue'
import { Button, IconButton } from '#ui/components/base/buttons'
import NewModal from '../modal/NewModal.vue'
import type { ServerBillingInterval } from './ModrinthServersPurchaseModal.vue'
import PlanSelector from './ServersPurchase0Plan.vue'
@@ -143,11 +144,9 @@ defineExpose({
class="absolute inset-x-0 bottom-0 -m-px z-30 rounded-2xl border border-solid border-surface-5 bg-bg-raised p-6 shadow-2xl"
>
<div class="absolute right-4 top-4">
<ButtonStyled circular type="transparent">
<button aria-label="Close sign in prompt" @click="closeSignInPrompt">
<XIcon />
</button>
</ButtonStyled>
<IconButton type="quiet" label="Close sign in prompt" @click="closeSignInPrompt">
<XIcon />
</IconButton>
</div>
<div class="mx-auto flex max-w-xl flex-col items-center gap-4 text-center">
@@ -155,12 +154,10 @@ defineExpose({
<div class="font-semibold text-contrast">Sign in to continue your purchase</div>
<div class="">You need a Modrinth account to add your billing details.</div>
</div>
<ButtonStyled color="brand">
<button class="mt-2" @click="continueToAuth">
Sign in or create an account
<ExternalIcon class="size-4" />
</button>
</ButtonStyled>
<Button type="colored" color="brand" class="mt-2" @click="continueToAuth">
Sign in or create an account
<ExternalIcon class="size-4" />
</Button>
</div>
</div>
</Transition>
@@ -3,10 +3,11 @@ import type { Labrinth } from '@modrinth/api-client'
import { RightArrowIcon } from '@modrinth/assets'
import { computed } from 'vue'
import { Button } from '#ui/components/base/buttons'
import { useFormatPrice } from '../../composables'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { getPriceForInterval, monthsInInterval } from '../../utils/product-utils'
import ButtonStyled from '../base/ButtonStyled.vue'
import OptionGroup from '../base/OptionGroup.vue'
import type { ServerBillingInterval } from './ModrinthServersPurchaseModal.vue'
import ServersSpecs from './ServersSpecs.vue'
@@ -218,19 +219,19 @@ function selectCustom() {
</div>
</div>
<div class="w-full">
<ButtonStyled color="blue">
<button
class="w-full"
:disabled="existingPlan?.id === plansByRam.small.id"
@click="selectPlan(plansByRam.small!)"
>
{{
existingPlan?.id === plansByRam.small.id
? formatMessage(messages.yourCurrentPlan)
: formatMessage(messages.selectPlan)
}}
</button>
</ButtonStyled>
<Button
type="colored"
color="blue"
class="w-full"
:disabled="existingPlan?.id === plansByRam.small.id"
@click="selectPlan(plansByRam.small!)"
>
{{
existingPlan?.id === plansByRam.small.id
? formatMessage(messages.yourCurrentPlan)
: formatMessage(messages.selectPlan)
}}
</Button>
</div>
<ServersSpecs
v-if="smallSpecs"
@@ -281,19 +282,19 @@ function selectCustom() {
</div>
</div>
<div class="w-full">
<ButtonStyled color="brand" class="w-full">
<button
class="w-full"
:disabled="existingPlan?.id === plansByRam.medium.id"
@click="selectPlan(plansByRam.medium!)"
>
{{
existingPlan?.id === plansByRam.medium.id
? formatMessage(messages.yourCurrentPlan)
: formatMessage(messages.selectPlan)
}}
</button>
</ButtonStyled>
<Button
type="colored"
color="brand"
class="w-full"
:disabled="existingPlan?.id === plansByRam.medium.id"
@click="selectPlan(plansByRam.medium!)"
>
{{
existingPlan?.id === plansByRam.medium.id
? formatMessage(messages.yourCurrentPlan)
: formatMessage(messages.selectPlan)
}}
</Button>
</div>
<ServersSpecs
v-if="mediumSpecs"
@@ -329,19 +330,19 @@ function selectCustom() {
</div>
</div>
<div class="w-full">
<ButtonStyled color="purple" class="w-full">
<button
class="w-full"
:disabled="existingPlan?.id === plansByRam.large.id"
@click="selectPlan(plansByRam.large!)"
>
{{
existingPlan?.id === plansByRam.large.id
? formatMessage(messages.yourCurrentPlan)
: formatMessage(messages.selectPlan)
}}
</button>
</ButtonStyled>
<Button
type="colored"
color="purple"
class="w-full"
:disabled="existingPlan?.id === plansByRam.large.id"
@click="selectPlan(plansByRam.large!)"
>
{{
existingPlan?.id === plansByRam.large.id
? formatMessage(messages.yourCurrentPlan)
: formatMessage(messages.selectPlan)
}}
</Button>
</div>
<ServersSpecs
v-if="largeSpecs"
@@ -366,11 +367,9 @@ function selectCustom() {
</div>
</div>
<div class="flex flex-col items-end gap-2 shrink-0">
<ButtonStyled>
<button class="flex items-center gap-2" @click="selectCustom">
{{ formatMessage(messages.getStarted) }} <RightArrowIcon class="h-4 w-4" />
</button>
</ButtonStyled>
<Button class="flex items-center gap-2" @click="selectCustom">
{{ formatMessage(messages.getStarted) }} <RightArrowIcon class="h-4 w-4" />
</Button>
<div class="text-sm text-secondary whitespace-nowrap">
Starting at {{ formatPrice(customStartingPrice, currency, true) }}/mo
</div>
@@ -15,11 +15,12 @@ import dayjs from 'dayjs'
import type Stripe from 'stripe'
import { computed } from 'vue'
import { Button } from '#ui/components/base/buttons'
import { useFormatPrice } from '../../composables'
import { useVIntl } from '../../composables/i18n'
import { getPriceForInterval, monthsInInterval } from '../../utils/product-utils'
import { regionOverrides } from '../../utils/regions'
import ButtonStyled from '../base/ButtonStyled.vue'
import Checkbox from '../base/Checkbox.vue'
import TagItem from '../base/TagItem.vue'
import ModrinthServersIcon from '../servers/ModrinthServersIcon.vue'
@@ -323,12 +324,10 @@ function setInterval(newInterval: ServerBillingInterval) {
No payment method selected
</div>
</template>
<ButtonStyled size="small" type="transparent">
<button class="ml-auto" @click="emit('changePaymentMethod')">
<template v-if="selectedPaymentMethod || hasPaymentMethod"> <EditIcon /> Change </template>
<template v-else> Select payment method <RightArrowIcon /> </template>
</button>
</ButtonStyled>
<Button type="quiet" size="xs" class="ml-auto !h-6" @click="emit('changePaymentMethod')">
<template v-if="selectedPaymentMethod || hasPaymentMethod"> <EditIcon /> Change </template>
<template v-else> Select payment method <RightArrowIcon /> </template>
</Button>
</div>
<p v-if="!noPaymentRequired" class="m-0 mt-4 text-sm text-secondary">
<template v-if="isUpgrade && (total ?? 0) > 0">
+9 -5
View File
@@ -4,7 +4,7 @@ import dayjs from 'dayjs'
import { defineAsyncComponent, onMounted, ref } from 'vue'
import { useFormatNumber } from '../../composables/index.ts'
import Button from '../base/Button.vue'
import { IconButton } from '../base/buttons'
import Checkbox from '../base/Checkbox.vue'
const VueApexCharts = defineAsyncComponent(() => import('vue3-apexcharts'))
@@ -231,12 +231,16 @@ defineExpose({
<div class="title-bar">
<slot />
<div v-if="!hideToolbar" class="toolbar">
<Button v-tooltip="'Download data as CSV'" icon-only @click="downloadCSV">
<IconButton
v-tooltip="'Download data as CSV'"
label="Download data as CSV"
@click="downloadCSV"
>
<!-- <DownloadIcon /> -->
</Button>
<Button v-tooltip="'Reset chart'" icon-only @click="resetChart">
</IconButton>
<IconButton v-tooltip="'Reset chart'" label="Reset chart" @click="resetChart">
<!-- <UpdatedIcon /> -->
</Button>
</IconButton>
<slot name="toolbar" />
</div>
</div>
@@ -3,7 +3,8 @@ import type { Labrinth } from '@modrinth/api-client'
import { CheckIcon, PlusIcon, SearchIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { computed, nextTick, ref } from 'vue'
import { ButtonStyled, NewModal, StyledInput } from '#ui/components'
import { NewModal, StyledInput } from '#ui/components'
import { Button } from '#ui/components/base/buttons'
import { commonMessages } from '#ui/utils'
import { defineMessages, useVIntl } from '../../composables/i18n'
@@ -263,23 +264,21 @@ defineExpose({ show, hide })
{{ formatMessage(messages.addFilesModalSelectedCount, { count: selectedFileCount }) }}
</p>
<div class="flex gap-2 ml-auto">
<ButtonStyled type="outlined">
<button type="button" :disabled="pending" @click="hide">
<XIcon class="size-4 shrink-0" />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button
type="button"
:disabled="selectedFileCount === 0 || pending"
@click="handleConfirm"
>
<SpinnerIcon v-if="pending" class="size-4 shrink-0 animate-spin" />
<PlusIcon v-else class="size-4 shrink-0" />
{{ formatMessage(messages.addFilesModalConfirm, { count: selectedFileCount }) }}
</button>
</ButtonStyled>
<Button type="outlined" native-type="button" :disabled="pending" @click="hide">
<XIcon class="size-4 shrink-0" />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button
type="colored"
color="brand"
native-type="button"
:disabled="selectedFileCount === 0 || pending"
@click="handleConfirm"
>
<SpinnerIcon v-if="pending" class="size-4 shrink-0 animate-spin" />
<PlusIcon v-else class="size-4 shrink-0" />
{{ formatMessage(messages.addFilesModalConfirm, { count: selectedFileCount }) }}
</Button>
</div>
</div>
</template>
@@ -4,7 +4,8 @@ import { PlusIcon, SearchIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { useMutation } from '@tanstack/vue-query'
import { computed, ref, useTemplateRef } from 'vue'
import { Accordion, ButtonStyled, NewModal, StyledInput } from '#ui/components'
import { Accordion, NewModal, StyledInput } from '#ui/components'
import { Button } from '#ui/components/base/buttons'
import { injectModrinthClient, injectNotificationManager } from '../../providers'
import AttributionGroupFilePicker from './AttributionGroupFilePicker.vue'
@@ -228,12 +229,15 @@ defineExpose({ show, hide })
wrapper-class="flex-1 min-w-[12rem]"
:disabled="addFilesMutation.isPending.value"
/>
<ButtonStyled color="brand">
<button type="submit" :disabled="addFilesMutation.isPending.value">
<SearchIcon aria-hidden="true" />
Search
</button>
</ButtonStyled>
<Button
type="colored"
color="brand"
native-type="submit"
:disabled="addFilesMutation.isPending.value"
>
<SearchIcon aria-hidden="true" />
Search
</Button>
</form>
<div
class="flex flex-col min-h-0 max-h-[min(50vh,24rem)] overflow-y-auto overflow-x-hidden"
@@ -269,17 +273,15 @@ defineExpose({ show, hide })
class="mx-4 mt-3"
>
<template #actions>
<ButtonStyled color="brand">
<button
type="button"
:disabled="
selectedProjectId === project.id || addFilesMutation.isPending.value
"
@click="selectProject(project.id)"
>
{{ selectedProjectId === project.id ? 'Selected' : 'Select' }}
</button>
</ButtonStyled>
<Button
type="colored"
color="brand"
native-type="button"
:disabled="selectedProjectId === project.id || addFilesMutation.isPending.value"
@click="selectProject(project.id)"
>
{{ selectedProjectId === project.id ? 'Selected' : 'Select' }}
</Button>
</template>
</ExternalProjectLookupCard>
</div>
@@ -306,22 +308,29 @@ defineExpose({ show, hide })
/>
</Accordion>
<div class="flex justify-end gap-2 w-full">
<ButtonStyled type="outlined">
<button type="button" :disabled="addFilesMutation.isPending.value" @click="hide">
<XIcon class="size-4 shrink-0" />
Cancel
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button type="button" :disabled="!canSubmit" @click="handleSubmit">
<SpinnerIcon
v-if="addFilesMutation.isPending.value"
class="size-4 shrink-0 animate-spin"
/>
<PlusIcon v-else class="size-4 shrink-0" />
Add files to entry
</button>
</ButtonStyled>
<Button
type="outlined"
native-type="button"
:disabled="addFilesMutation.isPending.value"
@click="hide"
>
<XIcon class="size-4 shrink-0" />
Cancel
</Button>
<Button
type="colored"
color="brand"
native-type="button"
:disabled="!canSubmit"
@click="handleSubmit"
>
<SpinnerIcon
v-if="addFilesMutation.isPending.value"
class="size-4 shrink-0 animate-spin"
/>
<PlusIcon v-else class="size-4 shrink-0" />
Add files to entry
</Button>
</div>
</div>
</NewModal>
@@ -4,14 +4,8 @@ import { PlusIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { useMutation } from '@tanstack/vue-query'
import { computed, ref, useTemplateRef } from 'vue'
import {
Accordion,
ButtonStyled,
Combobox,
type ComboboxOption,
NewModal,
StyledInput,
} from '#ui/components'
import { Accordion, Combobox, type ComboboxOption, NewModal, StyledInput } from '#ui/components'
import { Button } from '#ui/components/base/buttons'
import { injectModrinthClient, injectNotificationManager } from '../../providers'
import AttributionGroupFilePicker from './AttributionGroupFilePicker.vue'
@@ -223,22 +217,26 @@ defineExpose({ show, hide })
</div>
</Accordion>
<div class="flex justify-end gap-2 w-full">
<ButtonStyled type="outlined">
<button type="button" :disabled="createMutation.isPending.value" @click="hide">
<XIcon class="size-4 shrink-0" />
Cancel
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button type="button" :disabled="!canSubmit" @click="handleSubmit">
<SpinnerIcon
v-if="createMutation.isPending.value"
class="size-4 shrink-0 animate-spin"
/>
<PlusIcon v-else class="size-4 shrink-0" />
Add to global database
</button>
</ButtonStyled>
<Button
type="outlined"
native-type="button"
:disabled="createMutation.isPending.value"
@click="hide"
>
<XIcon class="size-4 shrink-0" />
Cancel
</Button>
<Button
type="colored"
color="brand"
native-type="button"
:disabled="!canSubmit"
@click="handleSubmit"
>
<SpinnerIcon v-if="createMutation.isPending.value" class="size-4 shrink-0 animate-spin" />
<PlusIcon v-else class="size-4 shrink-0" />
Add to global database
</Button>
</div>
</div>
</NewModal>
@@ -14,8 +14,9 @@ import { builtinLicenses } from '@modrinth/utils'
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import { computed, ref, watch } from 'vue'
import { ButtonStyled, Chips, Combobox, type ComboboxOption, StyledInput } from '#ui/components'
import { Chips, Combobox, type ComboboxOption, StyledInput } from '#ui/components'
import { FileInput } from '#ui/components/base'
import { Button, IconButton } from '#ui/components/base/buttons'
import { commonMessages } from '#ui/utils'
import { defineMessage, defineMessages, useVIntl } from '../../composables/i18n'
@@ -577,15 +578,14 @@ function cancelEditing() {
class="flex w-full object-contain bg-surface-3"
/>
<div class="absolute top-2 right-2">
<ButtonStyled circular>
<button
v-tooltip="formatMessage(messages.proofImageRemove)"
type="button"
@click="removeProofImage(idx)"
>
<TrashIcon />
</button>
</ButtonStyled>
<IconButton
v-tooltip="formatMessage(messages.proofImageRemove)"
:label="formatMessage(messages.proofImageRemove)"
native-type="button"
@click="removeProofImage(idx)"
>
<TrashIcon />
</IconButton>
</div>
</div>
</div>
@@ -633,29 +633,29 @@ function cancelEditing() {
<hr class="mt-1 bg-surface-5 border-none h-[1px] w-full" />
<div class="flex items-center gap-2 justify-end">
<ButtonStyled v-if="isAttributed" type="outlined">
<button
:disabled="saveMutation.isPending.value || uploadProofImageMutation.isPending.value"
@click="cancelEditing"
>
<XIcon /> {{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button
:disabled="saveMutation.isPending.value || uploadProofImageMutation.isPending.value"
@click="handleSave"
>
<template v-if="saveMutation.isPending.value">
<SpinnerIcon class="animate-spin" />
{{ formatMessage(commonMessages.savingButton) }}
</template>
<template v-else-if="isAttributed">
<SaveIcon /> {{ formatMessage(messages.saveAttribution) }}
</template>
<template v-else> <CheckIcon /> {{ formatMessage(messages.addAttribution) }} </template>
</button>
</ButtonStyled>
<Button
v-if="isAttributed"
type="outlined"
:disabled="saveMutation.isPending.value || uploadProofImageMutation.isPending.value"
@click="cancelEditing"
>
<XIcon /> {{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button
type="colored"
color="brand"
:disabled="saveMutation.isPending.value || uploadProofImageMutation.isPending.value"
@click="handleSave"
>
<template v-if="saveMutation.isPending.value">
<SpinnerIcon class="animate-spin" />
{{ formatMessage(commonMessages.savingButton) }}
</template>
<template v-else-if="isAttributed">
<SaveIcon /> {{ formatMessage(messages.saveAttribution) }}
</template>
<template v-else> <CheckIcon /> {{ formatMessage(messages.addAttribution) }} </template>
</Button>
</div>
</div>
</template>
@@ -9,7 +9,8 @@ import {
import { Menu } from 'floating-vue'
import { computed } from 'vue'
import { ButtonStyled, CopyCode } from '#ui/components'
import { CopyCode } from '#ui/components'
import { IconButton } from '#ui/components/base/buttons'
import ExternalProjectLicenseStateTag from './ExternalProjectLicenseStateTag.vue'
import type { ExternalLicenseStatus } from './types.ts'
@@ -58,11 +59,16 @@ async function copyProjectLink() {
</template>
<template v-else> <LinkIcon class="size-5 shrink-0" /> Project link </template>
</a>
<ButtonStyled circular type="transparent" size="small">
<button v-tooltip="'Copy link'" @click="copyProjectLink">
<ClipboardCopyIcon />
</button>
</ButtonStyled>
<IconButton
v-tooltip="'Copy link'"
class="!size-6"
type="quiet"
size="xs"
:label="'Copy link'"
@click="copyProjectLink"
>
<ClipboardCopyIcon />
</IconButton>
</div>
</div>
<slot name="actions" />
@@ -18,8 +18,9 @@ import { renderString } from '@modrinth/utils'
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import { computed, ref, useTemplateRef, watch } from 'vue'
import { ButtonStyled, Collapsible, ConfirmModal, OverflowMenu } from '#ui/components'
import { Collapsible, ConfirmModal } from '#ui/components'
import type { OverflowMenuOption } from '#ui/components/base'
import { Button, IconButton, TeleportOverflowMenu } from '#ui/components/base/buttons'
import { commonMessages } from '#ui/utils'
import { defineMessage, defineMessages, useVIntl } from '../../composables/i18n'
@@ -498,6 +499,7 @@ const visibleQuickReplies = computed<OverflowMenuOption[]>(() => {
(reply) =>
({
id: reply.label,
label: reply.label,
action: () => handleQuickReply(reply),
}) as OverflowMenuOption,
)
@@ -577,28 +579,27 @@ const visibleQuickReplies = computed<OverflowMenuOption[]>(() => {
</span>
</div>
<div class="flex items-center gap-1 my-auto">
<ButtonStyled v-if="group.files.length > 1" circular size="small">
<button
v-tooltip="formatMessage(messages.splitFile)"
class="m-1"
:disabled="splitFileMutation.isPending.value"
@click="handleSplitFile(file.sha1)"
>
<SpinnerIcon
v-if="splitFileMutation.isPending.value && pendingSplitSha1 === file.sha1"
class="size-4 shrink-0 animate-spin"
/>
<XIcon v-else class="size-4 shrink-0" />
</button>
</ButtonStyled>
<IconButton
v-if="group.files.length > 1"
v-tooltip="formatMessage(messages.splitFile)"
class="m-1 !size-6"
size="xs"
:label="formatMessage(messages.splitFile)"
:disabled="splitFileMutation.isPending.value"
@click="handleSplitFile(file.sha1)"
>
<SpinnerIcon
v-if="splitFileMutation.isPending.value && pendingSplitSha1 === file.sha1"
class="size-4 shrink-0 animate-spin"
/>
<XIcon v-else class="size-4 shrink-0" />
</IconButton>
</div>
</span>
<div>
<ButtonStyled>
<button @click="handleAddFilesToGroup($event)">
<PlusIcon class="size-4 shrink-0" /> {{ formatMessage(messages.addFilesToGroup) }}
</button>
</ButtonStyled>
<Button @click="handleAddFilesToGroup($event)">
<PlusIcon class="size-4 shrink-0" /> {{ formatMessage(messages.addFilesToGroup) }}
</Button>
</div>
</div>
<template v-if="(containingVersions?.length ?? 0) > 0">
@@ -644,11 +645,9 @@ const visibleQuickReplies = computed<OverflowMenuOption[]>(() => {
"
#actions
>
<ButtonStyled>
<button @click="startEditingAttribution">
<EditIcon /> {{ formatMessage(commonMessages.editButton) }}
</button>
</ButtonStyled>
<Button @click="startEditingAttribution">
<EditIcon /> {{ formatMessage(commonMessages.editButton) }}
</Button>
</template>
<template
v-if="
@@ -714,81 +713,83 @@ const visibleQuickReplies = computed<OverflowMenuOption[]>(() => {
class="mt-3"
/>
<div class="flex items-center gap-2 flex-wrap mt-3">
<ButtonStyled v-if="visibleQuickReplies.length > 0">
<OverflowMenu :options="visibleQuickReplies">
Reply presets
<ChevronDownIcon />
</OverflowMenu>
</ButtonStyled>
<ButtonStyled color="green" color-fill="text">
<button
:disabled="setModerationStatusMutation.isPending.value"
@click="handleSetModerationStatus('approved')"
>
<SpinnerIcon
v-if="
setModerationStatusMutation.isPending.value &&
pendingModerationStatusKind === 'approved'
"
class="size-4 shrink-0 animate-spin"
/>
<CheckCircleIcon v-else />
Approve
</button>
</ButtonStyled>
<ButtonStyled color="red" color-fill="text">
<button
:disabled="setModerationStatusMutation.isPending.value"
@click="handleSetModerationStatus('bad_proof')"
>
<SpinnerIcon
v-if="
setModerationStatusMutation.isPending.value &&
pendingModerationStatusKind === 'bad_proof'
"
class="size-4 shrink-0 animate-spin"
/>
<XCircleIcon v-else />
Reject: Insufficient proof
</button>
</ButtonStyled>
<ButtonStyled color="red" color-fill="text">
<button
:disabled="setModerationStatusMutation.isPending.value"
@click="handleSetModerationStatus('not_allowed')"
>
<SpinnerIcon
v-if="
setModerationStatusMutation.isPending.value &&
pendingModerationStatusKind === 'not_allowed'
"
class="size-4 shrink-0 animate-spin"
/>
<ReportIcon v-else />
Reject: Not allowed
</button>
</ButtonStyled>
<ButtonStyled v-if="isEditingModerationReview" type="outlined">
<button
:disabled="setModerationStatusMutation.isPending.value"
@click="cancelModerationReviewEditing"
>
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<TeleportOverflowMenu
v-if="visibleQuickReplies.length > 0"
label="More options"
:options="visibleQuickReplies"
class="!w-auto !px-2.5 !rounded-xl"
>
Reply presets
<ChevronDownIcon />
</TeleportOverflowMenu>
<Button
type="quiet"
color="green"
:disabled="setModerationStatusMutation.isPending.value"
class="!text-green [&>svg]:!text-green"
@click="handleSetModerationStatus('approved')"
>
<SpinnerIcon
v-if="
setModerationStatusMutation.isPending.value &&
pendingModerationStatusKind === 'approved'
"
class="size-4 shrink-0 animate-spin"
/>
<CheckCircleIcon v-else />
Approve
</Button>
<Button
type="quiet"
color="red"
:disabled="setModerationStatusMutation.isPending.value"
class="!text-red [&>svg]:!text-red"
@click="handleSetModerationStatus('bad_proof')"
>
<SpinnerIcon
v-if="
setModerationStatusMutation.isPending.value &&
pendingModerationStatusKind === 'bad_proof'
"
class="size-4 shrink-0 animate-spin"
/>
<XCircleIcon v-else />
Reject: Insufficient proof
</Button>
<Button
type="quiet"
color="red"
:disabled="setModerationStatusMutation.isPending.value"
class="!text-red [&>svg]:!text-red"
@click="handleSetModerationStatus('not_allowed')"
>
<SpinnerIcon
v-if="
setModerationStatusMutation.isPending.value &&
pendingModerationStatusKind === 'not_allowed'
"
class="size-4 shrink-0 animate-spin"
/>
<ReportIcon v-else />
Reject: Not allowed
</Button>
<Button
v-if="isEditingModerationReview"
type="outlined"
:disabled="setModerationStatusMutation.isPending.value"
@click="cancelModerationReviewEditing"
>
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
</div>
<div class="flex items-center gap-2 flex-wrap mt-3">
<ButtonStyled>
<button @click="handleAddToGlobalDatabase">
<ScaleIcon /> Add files to database...
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="handleAddToExistingEntry">
<ScaleIcon /> Add to existing entry...
</button>
</ButtonStyled>
<Button @click="handleAddToGlobalDatabase">
<ScaleIcon /> Add files to database...
</Button>
<Button @click="handleAddToExistingEntry">
<ScaleIcon /> Add to existing entry...
</Button>
</div>
</template>
</template>
@@ -808,12 +809,10 @@ const visibleQuickReplies = computed<OverflowMenuOption[]>(() => {
"
class="ml-auto"
>
<ButtonStyled color="orange">
<button @click="startEditingModerationReview">
<ScaleIcon />
{{ formatMessage(commonMessages.editButton) }}
</button>
</ButtonStyled>
<Button type="colored" color="orange" @click="startEditingModerationReview">
<ScaleIcon />
{{ formatMessage(commonMessages.editButton) }}
</Button>
</div>
</div>
</template>
@@ -835,16 +834,19 @@ const visibleQuickReplies = computed<OverflowMenuOption[]>(() => {
/>
</div>
<div v-if="isModerator" class="flex justify-end pt-2">
<ButtonStyled color="red" type="outlined">
<button :disabled="deleteGroupMutation.isPending.value" @click="handleDeleteGroup">
<SpinnerIcon
v-if="deleteGroupMutation.isPending.value"
class="size-4 shrink-0 animate-spin"
/>
<TrashIcon v-else class="size-4 shrink-0" />
{{ formatMessage(messages.removeGroup) }}
</button>
</ButtonStyled>
<Button
type="outlined"
:disabled="deleteGroupMutation.isPending.value"
class="!text-red [&>svg]:!text-red !shadow-[inset_0_0_0_1px_var(--color-red)]"
@click="handleDeleteGroup"
>
<SpinnerIcon
v-if="deleteGroupMutation.isPending.value"
class="size-4 shrink-0 animate-spin"
/>
<TrashIcon v-else class="size-4 shrink-0" />
{{ formatMessage(messages.removeGroup) }}
</Button>
</div>
</div>
</Collapsible>
@@ -4,18 +4,14 @@
<div v-if="ctx.flowType === 'instance'" class="flex items-center gap-4">
<Avatar :src="ctx.instanceIconUrl.value ?? undefined" size="5rem" />
<div class="flex flex-col gap-2">
<ButtonStyled type="outlined">
<button @click="triggerIconInput">
<UploadIcon />
{{ formatMessage(messages.selectIcon) }}
</button>
</ButtonStyled>
<ButtonStyled type="outlined">
<button :disabled="!ctx.instanceIcon.value" @click="removeIcon">
<XIcon />
{{ formatMessage(messages.removeIcon) }}
</button>
</ButtonStyled>
<Button type="outlined" @click="triggerIconInput">
<UploadIcon />
{{ formatMessage(messages.selectIcon) }}
</Button>
<Button type="outlined" :disabled="!ctx.instanceIcon.value" @click="removeIcon">
<XIcon />
{{ formatMessage(messages.removeIcon) }}
</Button>
</div>
</div>
@@ -153,11 +149,11 @@ import { EyeIcon, EyeOffIcon, UploadIcon, XIcon } from '@modrinth/assets'
import { commonMessages, defineMessages, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref, watch } from 'vue'
import { Button } from '#ui/components/base/buttons'
import { useDebugLogger } from '#ui/composables/debug-logger'
import { injectFilePicker, injectModrinthClient, injectTags } from '../../../../providers'
import Avatar from '../../../base/Avatar.vue'
import ButtonStyled from '../../../base/ButtonStyled.vue'
import Chips from '../../../base/Chips.vue'
import Collapsible from '../../../base/Collapsible.vue'
import Combobox, { type ComboboxOption } from '../../../base/Combobox.vue'
@@ -5,13 +5,14 @@
<span class="font-semibold text-contrast">{{
formatMessage(messages.launcherInstancesTitle)
}}</span>
<ButtonStyled
type="transparent"
size="small"
<Button
type="quiet"
size="xs"
class="!h-6"
:class="{ invisible: totalSelectedCount === 0 }"
@click="clearAll"
>{{ formatMessage(messages.clearAll) }}</Button
>
<button @click="clearAll">{{ formatMessage(messages.clearAll) }}</button>
</ButtonStyled>
</div>
<template v-if="loading">
@@ -76,28 +77,22 @@
<!-- Add launcher path -->
<div v-if="!showAddPath">
<ButtonStyled>
<button class="w-full !shadow-none" @click="showAddPath = true">
{{ formatMessage(messages.addLauncherPath) }}
</button>
</ButtonStyled>
<Button class="w-full" @click="showAddPath = true">
{{ formatMessage(messages.addLauncherPath) }}
</Button>
</div>
<div v-else class="flex items-center gap-2">
<ButtonStyled circular>
<button class="!shadow-none" @click="browseForLauncherPath">
<FolderSearchIcon />
</button>
</ButtonStyled>
<IconButton label="Browse for launcher path" @click="browseForLauncherPath">
<FolderSearchIcon />
</IconButton>
<StyledInput
v-model="newLauncherPath"
:placeholder="formatMessage(messages.launcherPathPlaceholder)"
class="flex-1"
/>
<ButtonStyled>
<button class="!shadow-none" :disabled="!newLauncherPath.trim()" @click="addLauncherPath">
{{ formatMessage(messages.add) }}
</button>
</ButtonStyled>
<Button :disabled="!newLauncherPath.trim()" @click="addLauncherPath">
{{ formatMessage(messages.add) }}
</Button>
</div>
</template>
</div>
@@ -108,9 +103,10 @@ import { ChevronRightIcon, FolderSearchIcon, SearchIcon } from '@modrinth/assets
import { defineMessages, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref, watch } from 'vue'
import { Button, IconButton } from '#ui/components/base/buttons'
import { injectInstanceImport, injectNotificationManager } from '../../../../providers'
import type { ImportableLauncher } from '../../../../providers/instance-import'
import ButtonStyled from '../../../base/ButtonStyled.vue'
import Checkbox from '../../../base/Checkbox.vue'
import Collapsible from '../../../base/Collapsible.vue'
import StyledInput from '../../../base/StyledInput.vue'
@@ -30,28 +30,27 @@
<div class="h-[1px] w-full flex-1 bg-surface-5" />
</div>
<div class="flex gap-3">
<ButtonStyled type="outlined">
<button
v-tooltip="ctx.finishDisabled.value ? ctx.finishDisabledTooltip.value : undefined"
class="flex-1"
:disabled="ctx.finishDisabled.value"
@click="triggerFileInput"
>
<ImportIcon />
{{ formatMessage(messages.importModpack) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button
v-tooltip="ctx.finishDisabled.value ? ctx.finishDisabledTooltip.value : undefined"
class="flex-1"
:disabled="ctx.finishDisabled.value"
@click="ctx.browseModpacks()"
>
<CompassIcon />
{{ formatMessage(messages.browseModpacks) }}
</button>
</ButtonStyled>
<Button
v-tooltip="ctx.finishDisabled.value ? ctx.finishDisabledTooltip.value : undefined"
type="outlined"
class="flex-1"
:disabled="ctx.finishDisabled.value"
@click="triggerFileInput"
>
<ImportIcon />
{{ formatMessage(messages.importModpack) }}
</Button>
<Button
v-tooltip="ctx.finishDisabled.value ? ctx.finishDisabledTooltip.value : undefined"
type="colored"
color="brand"
class="flex-1"
:disabled="ctx.finishDisabled.value"
@click="ctx.browseModpacks()"
>
<CompassIcon />
{{ formatMessage(messages.browseModpacks) }}
</Button>
</div>
</div>
</template>
@@ -61,10 +60,10 @@ import { CompassIcon, ImportIcon, RightArrowIcon } from '@modrinth/assets'
import { commonMessages, defineMessages, useVIntl } from '@modrinth/ui'
import { defineAsyncComponent, h, onMounted, ref, watch } from 'vue'
import { Button } from '#ui/components/base/buttons'
import { useDebugLogger } from '#ui/composables/debug-logger'
import { injectFilePicker } from '../../../../providers'
import ButtonStyled from '../../../base/ButtonStyled.vue'
import Combobox from '../../../base/Combobox.vue'
import { injectCreationFlowContext } from '../creation-flow-context'
@@ -8,18 +8,14 @@
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="outlined">
<button @click="cancel">
<XIcon />
{{ localizeIfPossible(stayLabel) }}
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button @click="leave">
<RightArrowIcon />
{{ localizeIfPossible(leaveLabel) }}
</button>
</ButtonStyled>
<Button type="outlined" @click="cancel">
<XIcon />
{{ localizeIfPossible(stayLabel) }}
</Button>
<Button type="colored" color="red" @click="leave">
<RightArrowIcon />
{{ localizeIfPossible(leaveLabel) }}
</Button>
</div>
</template>
</NewModal>
@@ -30,7 +26,7 @@ import { RightArrowIcon, XIcon } from '@modrinth/assets'
import { ref } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import { Button } from '#ui/components/base/buttons'
import { defineMessage, type MessageDescriptor, useVIntl } from '#ui/composables/i18n'
import NewModal from './NewModal.vue'
@@ -31,18 +31,19 @@
wrapper-class="max-w-[20rem]"
/>
<div class="flex gap-2 justify-end">
<ButtonStyled>
<button class="!shadow-none" @click="hide()">
<XIcon />
Cancel
</button>
</ButtonStyled>
<ButtonStyled :color="danger ? 'red' : 'brand'">
<button :disabled="action_disabled" @click="proceed">
<component :is="proceedIcon" />
{{ proceedLabel }}
</button>
</ButtonStyled>
<Button @click="hide()">
<XIcon />
Cancel
</Button>
<Button
type="colored"
:color="danger ? 'red' : 'brand'"
:disabled="action_disabled"
@click="proceed"
>
<component :is="proceedIcon" />
{{ proceedLabel }}
</Button>
</div>
</div>
</NewModal>
@@ -53,7 +54,8 @@ import { TrashIcon, XIcon } from '@modrinth/assets'
import { renderString } from '@modrinth/utils'
import { computed, ref } from 'vue'
import ButtonStyled from '../base/ButtonStyled.vue'
import { Button } from '#ui/components/base/buttons'
import StyledInput from '../base/StyledInput.vue'
import NewModal from './NewModal.vue'
+18 -22
View File
@@ -43,33 +43,28 @@
</div>
<div class="flex items-center gap-2">
<slot name="header-actions" />
<ButtonStyled v-if="closable" circular>
<button
v-tooltip="closeLabel"
:aria-label="closeLabel"
:disabled="disableClose"
@click="hide"
>
<XIcon aria-hidden="true" />
</button>
</ButtonStyled>
<IconButton
v-if="closable"
v-tooltip="closeLabel"
:label="closeLabel"
:disabled="disableClose"
@click="hide"
>
<XIcon aria-hidden="true" />
</IconButton>
</div>
</div>
<ButtonStyled
<IconButton
v-if="props.mergeHeader && closable"
v-tooltip="closeLabel"
:label="closeLabel"
class="absolute top-4 right-4 z-10"
circular
:disabled="disableClose"
@click="hide"
>
<button
v-tooltip="closeLabel"
:aria-label="closeLabel"
:disabled="disableClose"
@click="hide"
>
<XIcon aria-hidden="true" />
</button>
</ButtonStyled>
<XIcon aria-hidden="true" />
</IconButton>
<div v-if="scrollable" class="relative flex-1 min-h-0 flex flex-col">
<Transition
@@ -144,12 +139,13 @@
import { XIcon } from '@modrinth/assets'
import { computed, nextTick, onUnmounted, ref } from 'vue'
import { IconButton } from '#ui/components/base/buttons'
import { useVIntl } from '../../composables/i18n'
import { useModalStack } from '../../composables/modal-stack'
import { useScrollIndicator } from '../../composables/scroll-indicator'
import { injectModalBehavior } from '../../providers'
import { commonMessages } from '../../utils/common-messages'
import ButtonStyled from '../base/ButtonStyled.vue'
const { formatMessage } = useVIntl()
@@ -88,23 +88,21 @@
formatMessage(messages.openingAutomatically)
}}</span>
<div v-else class="grid grid-cols-2 gap-2 w-full">
<ButtonStyled>
<button class="flex-1" @click="hide">
<XIcon />
{{ formatMessage(commonMessages.closeButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<a
class="flex-1"
href="https://modrinth.com/app"
target="_blank"
rel="noopener noreferrer"
>
<DownloadIcon />
{{ formatMessage(messages.getApp) }}
</a>
</ButtonStyled>
<Button class="flex-1" @click="hide">
<XIcon />
{{ formatMessage(commonMessages.closeButton) }}
</Button>
<ButtonLink
type="colored"
color="brand"
class="flex-1"
href="https://modrinth.com/app"
target="_blank"
rel="noopener noreferrer"
>
<DownloadIcon />
{{ formatMessage(messages.getApp) }}
</ButtonLink>
</div>
</div>
</div>
@@ -116,8 +114,10 @@ import { CheckIcon, DownloadIcon, XIcon } from '@modrinth/assets'
import { commonMessages } from '@modrinth/ui'
import { computed, nextTick, onUnmounted, ref } from 'vue'
import { Button, ButtonLink } from '#ui/components/base/buttons'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { Avatar, ButtonStyled } from '../base'
import { Avatar } from '../base'
import ServerOnlinePlayers from '../project/server/ServerOnlinePlayers.vue'
import ServerRegion from '../project/server/ServerRegion.vue'
+81 -90
View File
@@ -12,9 +12,10 @@ import {
import QrcodeVue from 'qrcode.vue'
import { computed, nextTick, ref } from 'vue'
import { ButtonLink, IconButton } from '#ui/components/base/buttons'
import { injectNotificationManager } from '#ui/providers'
import { ButtonStyled, NewModal, StyledInput } from '../index'
import { NewModal, StyledInput } from '../index'
const props = defineProps({
header: {
@@ -156,16 +157,15 @@ defineExpose({
<div ref="qrCode">
<QrcodeVue :value="url" class="!bg-white rounded-[var(--radius-md)]" margin="3" />
</div>
<ButtonStyled circular type="transparent">
<button
v-tooltip="'Copy QR code'"
class="absolute top-0 right-0 m-2"
aria-label="Copy QR code"
@click="copyImage"
>
<ClipboardCopyIcon class="h-5 w-5" aria-hidden="true" />
</button>
</ButtonStyled>
<IconButton
v-tooltip="'Copy QR code'"
type="quiet"
label="Copy QR code"
class="absolute top-0 right-0 m-2"
@click="copyImage"
>
<ClipboardCopyIcon class="h-5 w-5" aria-hidden="true" />
</IconButton>
</div>
<StyledInput
v-else
@@ -175,17 +175,16 @@ defineExpose({
wrapper-class="h-full w-[30rem]"
>
<template #right>
<ButtonStyled circular type="transparent">
<button
v-tooltip="'Copy Text'"
type="button"
aria-label="Copy Text"
class="absolute top-0 right-0 m-2"
@click="copyText"
>
<ClipboardCopyIcon class="h-5 w-5" aria-hidden="true" />
</button>
</ButtonStyled>
<IconButton
v-tooltip="'Copy Text'"
type="quiet"
label="Copy Text"
native-type="button"
class="absolute top-0 right-0 m-2"
@click="copyText"
>
<ClipboardCopyIcon class="h-5 w-5" aria-hidden="true" />
</IconButton>
</template>
</StyledInput>
<div
@@ -207,75 +206,67 @@ defineExpose({
<ClipboardCopyIcon class="h-5 w-5" aria-hidden="true" />
</div>
</button>
<ButtonStyled v-if="link">
<a
:href="url"
target="_blank"
rel="noopener noreferrer"
aria-label="Open in new tab"
class="w-full"
>
Open in new tab
<ExternalIcon aria-hidden="true" />
</a>
</ButtonStyled>
<ButtonLink
v-if="link"
:href="url"
target="_blank"
rel="noopener noreferrer"
aria-label="Open in new tab"
class="w-full"
>
Open in new tab
<ExternalIcon aria-hidden="true" />
</ButtonLink>
<div v-if="socialButtons" class="flex flex-row gap-1">
<ButtonStyled v-if="canShare" circular>
<button v-tooltip="'Share'" aria-label="Share" @click="share">
<ShareIcon aria-hidden="true" />
</button>
</ButtonStyled>
<ButtonStyled circular>
<a
v-tooltip="'Send as an email'"
:href="sendEmail"
:target="targetParameter"
aria-label="Send as an email"
>
<MailIcon aria-hidden="true" />
</a>
</ButtonStyled>
<ButtonStyled circular>
<a
v-if="link"
v-tooltip="'Open link in browser'"
:target="targetParameter"
:href="url"
aria-label="Open link in browser"
>
<GlobeIcon aria-hidden="true" />
</a>
</ButtonStyled>
<ButtonStyled circular>
<a
v-tooltip="'Toot about it'"
:target="targetParameter"
:href="sendToot"
aria-label="Toot about it"
>
<MastodonIcon aria-hidden="true" />
</a>
</ButtonStyled>
<ButtonStyled circular>
<a
v-tooltip="'Tweet about it'"
:target="targetParameter"
:href="sendTweet"
aria-label="Tweet about it"
>
<TwitterIcon aria-hidden="true" />
</a>
</ButtonStyled>
<ButtonStyled circular>
<a
v-tooltip="'Share on Reddit'"
:target="targetParameter"
:href="postOnReddit"
aria-label="Share on Reddit"
>
<RedditIcon aria-hidden="true" />
</a>
</ButtonStyled>
<IconButton v-if="canShare" v-tooltip="'Share'" label="Share" @click="share">
<ShareIcon aria-hidden="true" />
</IconButton>
<ButtonLink
v-tooltip="'Send as an email'"
:href="sendEmail"
:target="targetParameter"
aria-label="Send as an email"
class="!w-9 !px-0 !rounded-full"
>
<MailIcon aria-hidden="true" />
</ButtonLink>
<ButtonLink
v-if="link"
v-tooltip="'Open link in browser'"
:target="targetParameter"
:href="url"
aria-label="Open link in browser"
class="!w-9 !px-0 !rounded-full"
>
<GlobeIcon aria-hidden="true" />
</ButtonLink>
<ButtonLink
v-tooltip="'Toot about it'"
:target="targetParameter"
:href="sendToot"
aria-label="Toot about it"
class="!w-9 !px-0 !rounded-full"
>
<MastodonIcon aria-hidden="true" />
</ButtonLink>
<ButtonLink
v-tooltip="'Tweet about it'"
:target="targetParameter"
:href="sendTweet"
aria-label="Tweet about it"
class="!w-9 !px-0 !rounded-full"
>
<TwitterIcon aria-hidden="true" />
</ButtonLink>
<ButtonLink
v-tooltip="'Share on Reddit'"
:target="targetParameter"
:href="postOnReddit"
aria-label="Share on Reddit"
class="!w-9 !px-0 !rounded-full"
>
<RedditIcon aria-hidden="true" />
</ButtonLink>
</div>
</div>
</div>
@@ -86,17 +86,13 @@
/>
<div class="flex items-center gap-2">
<ButtonStyled type="transparent" color="orange">
<button type="button" @click="continueInstallation">
{{ formatMessage(messages.installAnyway) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button type="button" @click="cancelInstallation">
<BanIcon aria-hidden="true" />
{{ formatMessage(messages.dontInstall) }}
</button>
</ButtonStyled>
<Button type="quiet" color="orange" native-type="button" @click="continueInstallation">
{{ formatMessage(messages.installAnyway) }}
</Button>
<Button type="colored" color="brand" native-type="button" @click="cancelInstallation">
<BanIcon aria-hidden="true" />
{{ formatMessage(messages.dontInstall) }}
</Button>
</div>
</div>
</NewModal>
@@ -106,10 +102,11 @@
import { BanIcon } from '@modrinth/assets'
import { computed, nextTick, ref, useTemplateRef } from 'vue'
import { Button } from '#ui/components/base/buttons'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { useScrollIndicator } from '../../composables/scroll-indicator'
import Admonition from '../base/Admonition.vue'
import ButtonStyled from '../base/ButtonStyled.vue'
import Checkbox from '../base/Checkbox.vue'
import Table, { type TableColumn } from '../base/Table.vue'
import NewModal from './NewModal.vue'
@@ -59,22 +59,27 @@
<div v-if="item.count && item.count > 1" class="text-xs font-bold text-contrast">
x{{ item.count }}
</div>
<ButtonStyled v-if="item.copyable !== false" circular size="small">
<button
v-tooltip="
item.supportData ? 'Copy error details for support' : 'Copy to clipboard'
"
@click="copyToClipboard(item)"
>
<CheckIcon v-if="copied[getCopyKey(item)]" />
<CopyIcon v-else />
</button>
</ButtonStyled>
<ButtonStyled v-if="item.dismissible !== false" circular size="small">
<button v-tooltip="`Dismiss`" @click="dismissNotification(index)">
<XIcon />
</button>
</ButtonStyled>
<IconButton
v-if="item.copyable !== false"
v-tooltip="
item.supportData ? 'Copy error details for support' : 'Copy to clipboard'
"
size="xs"
:label="item.supportData ? 'Copy error details for support' : 'Copy to clipboard'"
@click="copyToClipboard(item)"
>
<CheckIcon v-if="copied[getCopyKey(item)]" />
<CopyIcon v-else />
</IconButton>
<IconButton
v-if="item.dismissible !== false"
v-tooltip="`Dismiss`"
size="xs"
:label="`Dismiss`"
@click="dismissNotification(index)"
>
<XIcon />
</IconButton>
</div>
<div v-if="item.type !== 'neutral'"></div>
<div
@@ -90,16 +95,22 @@
</template>
<template v-if="item.buttons?.length">
<div class="col-span-2 flex flex-wrap gap-1.5 pt-1">
<ButtonStyled
<Button
v-for="(button, buttonIndex) in item.buttons"
:key="buttonIndex"
:color="button.color"
:type="button.color && button.color !== 'standard' ? 'colored' : 'base'"
:color="
button.color && button.color !== 'standard'
? button.color === 'medal-promo'
? 'medal_promotion'
: button.color
: undefined
"
@click="handleButtonClick(item, button)"
>
<button class="!shadow-none" @click="handleButtonClick(item, button)">
<component :is="button.icon" v-if="button.icon" />
{{ button.label }}
</button>
</ButtonStyled>
<component :is="button.icon" v-if="button.icon" />
{{ button.label }}
</Button>
</div>
</template>
</div>
@@ -121,6 +132,7 @@ import {
} from '@modrinth/assets'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { Button, IconButton } from '#ui/components/base/buttons'
import { useModalStack } from '#ui/composables/modal-stack.ts'
import {
@@ -128,7 +140,6 @@ import {
type WebNotification,
type WebNotificationButton,
} from '../../providers'
import ButtonStyled from '../base/ButtonStyled.vue'
const notificationManager = injectNotificationManager()
const notifications = computed<WebNotification[]>(() => notificationManager.getNotifications())
@@ -95,11 +95,15 @@
</div>
</template>
</div>
<ButtonStyled v-if="item.dismissible !== false" type="transparent" circular>
<button class="-m-1.5" @click="dismiss(item.id)">
<XIcon />
</button>
</ButtonStyled>
<IconButton
v-if="item.dismissible !== false"
type="quiet"
label="Close"
class="-m-1.5"
@click="dismiss(item.id)"
>
<XIcon />
</IconButton>
</div>
<span v-if="item.text" class="text-primary">
{{ item.text }}
@@ -142,16 +146,28 @@
full-width
/>
<div v-if="item.buttons?.length" class="flex gap-1.5">
<ButtonStyled
<Button
v-for="(btn, idx) in item.buttons"
:key="idx"
:color="btn.color || (idx === 0 ? 'brand' : undefined)"
:type="
(btn.color || (idx === 0 ? 'brand' : undefined)) &&
(btn.color || (idx === 0 ? 'brand' : undefined)) !== 'standard'
? 'colored'
: 'base'
"
:color="
(btn.color || (idx === 0 ? 'brand' : undefined)) &&
(btn.color || (idx === 0 ? 'brand' : undefined)) !== 'standard'
? (btn.color || (idx === 0 ? 'brand' : undefined)) === 'medal-promo'
? 'medal_promotion'
: btn.color || (idx === 0 ? 'brand' : undefined)
: undefined
"
@click="handleButtonClick(item.id, btn)"
>
<button class="!shadow-none" @click="handleButtonClick(item.id, btn)">
<component :is="btn.icon" v-if="btn.icon" />
{{ btn.label }}
</button>
</ButtonStyled>
<component :is="btn.icon" v-if="btn.icon" />
{{ btn.label }}
</Button>
</div>
</div>
</div>
@@ -171,6 +187,8 @@ import {
} from '@modrinth/assets'
import { computed, ref } from 'vue'
import { Button, IconButton } from '#ui/components/base/buttons'
import { useModalStack } from '../../composables/modal-stack'
import {
injectPopupNotificationManager,
@@ -178,7 +196,6 @@ import {
type PopupNotificationButton,
type PopupNotificationProgressItem,
} from '../../providers'
import ButtonStyled from '../base/ButtonStyled.vue'
import ProgressBar from '../base/ProgressBar.vue'
import NotificationToast from '../notifications/NotificationToast.vue'
@@ -49,31 +49,33 @@
</template>
</template>
</p>
<ButtonStyled v-if="dismissible" size="small" type="transparent" circular>
<button
type="button"
class="notification-toast-dismiss"
aria-label="Dismiss notification"
@click="$emit('dismiss')"
>
<XIcon />
</button>
</ButtonStyled>
<IconButton
v-if="dismissible"
type="quiet"
size="xs"
label="Dismiss notification"
native-type="button"
class="notification-toast-dismiss !size-6"
@click="$emit('dismiss')"
>
<XIcon />
</IconButton>
</div>
<div class="flex items-center gap-2">
<ButtonStyled color="brand">
<button :disabled="actionLoading != null" @click="$emit('accept')">
<SpinnerIcon v-if="actionLoading === 'accept'" class="animate-spin" />
<CheckIcon v-else />
Accept
</button>
</ButtonStyled>
<ButtonStyled type="outlined">
<button :disabled="actionLoading != null" @click="$emit('decline')">
<XIcon />
Decline
</button>
</ButtonStyled>
<Button
type="colored"
color="brand"
:disabled="actionLoading != null"
@click="$emit('accept')"
>
<SpinnerIcon v-if="actionLoading === 'accept'" class="animate-spin" />
<CheckIcon v-else />
Accept
</Button>
<Button type="outlined" :disabled="actionLoading != null" @click="$emit('decline')">
<XIcon />
Decline
</Button>
</div>
</div>
</div>
@@ -96,16 +98,17 @@
{{ entityLabel }}
</p>
<div class="col-start-2 row-start-1 justify-self-end">
<ButtonStyled v-if="dismissible" size="small" type="transparent" circular>
<button
type="button"
class="notification-toast-dismiss"
aria-label="Dismiss notification"
@click="$emit('dismiss')"
>
<XIcon />
</button>
</ButtonStyled>
<IconButton
v-if="dismissible"
type="quiet"
size="xs"
label="Dismiss notification"
native-type="button"
class="notification-toast-dismiss !size-6"
@click="$emit('dismiss')"
>
<XIcon />
</IconButton>
</div>
<div
class="col-start-1 col-end-3 row-start-2 flex min-w-0 items-center justify-between gap-0.5"
@@ -130,12 +133,8 @@
class="col-start-1 col-end-3 row-start-3 mt-2 flex min-w-0 items-center justify-between gap-2"
>
<div class="flex min-w-0 items-center gap-2">
<ButtonStyled color="brand">
<button @click="$emit('launch')">Launch game</button>
</ButtonStyled>
<ButtonStyled type="outlined">
<button @click="$emit('open-instance')">Instance</button>
</ButtonStyled>
<Button type="colored" color="brand" @click="$emit('launch')">Launch game</Button>
<Button type="outlined" @click="$emit('open-instance')">Instance</Button>
</div>
<div v-if="progressLabel" class="notification-inline-progress-label flex-none">
{{ progressLabel }}
@@ -145,16 +144,28 @@
v-if="type === 'instance-download' && actions?.length"
class="col-start-1 col-end-3 row-start-3 mt-2 flex min-w-0 flex-wrap items-center gap-2"
>
<ButtonStyled
<Button
v-for="(action, index) in actions"
:key="index"
:color="action.color || (index === 0 ? 'brand' : undefined)"
:type="
(action.color || (index === 0 ? 'brand' : undefined)) &&
(action.color || (index === 0 ? 'brand' : undefined)) !== 'standard'
? 'colored'
: 'base'
"
:color="
(action.color || (index === 0 ? 'brand' : undefined)) &&
(action.color || (index === 0 ? 'brand' : undefined)) !== 'standard'
? (action.color || (index === 0 ? 'brand' : undefined)) === 'medal-promo'
? 'medal_promotion'
: action.color || (index === 0 ? 'brand' : undefined)
: undefined
"
@click="$emit('action', index)"
>
<button class="!shadow-none" @click="$emit('action', index)">
<component :is="action.icon" v-if="action.icon" />
{{ action.label }}
</button>
</ButtonStyled>
<component :is="action.icon" v-if="action.icon" />
{{ action.label }}
</Button>
</div>
</div>
</div>
@@ -182,11 +193,12 @@
import { CheckIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { computed, ref } from 'vue'
import { Button, IconButton } from '#ui/components/base/buttons'
import { useFormatBytes, useFormatNumber } from '../../composables'
import type { PopupNotificationButton, PopupNotificationProgressType } from '../../providers'
import { truncatedTooltip } from '../../utils/truncate'
import Avatar from '../base/Avatar.vue'
import ButtonStyled from '../base/ButtonStyled.vue'
type NotificationToastType =
| 'friend-request'
@@ -3,6 +3,7 @@ import { injectPageContext } from '@modrinth/ui'
defineProps<{
sidebar?: 'right' | 'left'
fullWidth?: boolean
}>()
const { hierarchicalSidebarAvailable } = injectPageContext()
</script>
@@ -12,6 +13,7 @@ const { hierarchicalSidebarAvailable } = injectPageContext()
:class="{
'ui-normal-page--sidebar-left': sidebar === 'left' && !hierarchicalSidebarAvailable,
'ui-normal-page--sidebar-right': sidebar === 'right' && !hierarchicalSidebarAvailable,
'ui-normal-page--full-width': fullWidth,
}"
>
<div class="ui-normal-page__header">
@@ -46,6 +48,10 @@ const { hierarchicalSidebarAvailable } = injectPageContext()
/ 100%;
}
.ui-normal-page--full-width {
width: calc(100% - 2rem);
}
@media (width >= 64rem) {
.ui-normal-page--sidebar-left {
grid-template:
@@ -9,9 +9,14 @@
@update:query="updateQuery"
/>
<ButtonStyled v-if="openModal" :color="createVersionButtonSecondary ? 'standard' : 'green'">
<button @click="openModal"><PlusIcon /> Create version</button>
</ButtonStyled>
<Button
v-if="openModal"
:type="createVersionButtonSecondary ? 'base' : 'colored'"
:color="createVersionButtonSecondary ? undefined : 'green'"
@click="openModal"
>
<PlusIcon /> Create version
</Button>
<Pagination
v-if="!openModal"
@@ -445,7 +450,6 @@ import {
} from '@modrinth/assets'
import {
AutoLink,
ButtonStyled,
Pagination,
SmartClickable,
Table,
@@ -462,6 +466,8 @@ import { Menu } from 'floating-vue'
import { computed, type Ref, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Button } from '#ui/components/base/buttons'
import { useRelativeTime } from '../../composables'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { formatTag } from '../../utils/tag-messages'
@@ -29,20 +29,25 @@
</div>
</div>
</div>
<ButtonStyled v-if="onclickDownload" circular type="transparent">
<button v-tooltip="formatMessage(messages.downloadModpack)" @click="onclickDownload">
<DownloadIcon />
</button>
</ButtonStyled>
<IconButton
v-if="onclickDownload"
v-tooltip="formatMessage(messages.downloadModpack)"
type="quiet"
:label="formatMessage(messages.downloadModpack)"
@click="onclickDownload"
>
<DownloadIcon />
</IconButton>
</div>
</template>
<script setup lang="ts">
import { DownloadIcon } from '@modrinth/assets/generated-icons'
import { IconButton } from '#ui/components/base/buttons'
import { defineMessages, useVIntl } from '../../../composables'
import Avatar from '../../base/Avatar.vue'
import ButtonStyled from '../../base/ButtonStyled.vue'
defineProps<{
name: string
@@ -8,19 +8,25 @@
</div>
<template #actions>
<div v-if="canSave" class="flex justify-end gap-2 mt-2">
<ButtonStyled v-if="canReset" type="transparent">
<button :disabled="saving || !hasChanges" @click="resetEnvironment">
<HistoryIcon /> {{ formatMessage(commonMessages.resetButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="saving || !hasChanges" @click="saveEnvironment">
<SpinnerIcon v-if="saving" class="animate-spin" />
<CheckIcon v-else-if="needsToVerify" />
<SaveIcon v-else />
{{ saveButtonLabel }}
</button>
</ButtonStyled>
<Button
v-if="canReset"
type="quiet"
:disabled="saving || !hasChanges"
@click="resetEnvironment"
>
<HistoryIcon /> {{ formatMessage(commonMessages.resetButton) }}
</Button>
<Button
type="colored"
color="brand"
:disabled="saving || !hasChanges"
@click="saveEnvironment"
>
<SpinnerIcon v-if="saving" class="animate-spin" />
<CheckIcon v-else-if="needsToVerify" />
<SaveIcon v-else />
{{ saveButtonLabel }}
</Button>
</div>
</template>
</NewModal>
@@ -31,10 +37,10 @@ import { CheckIcon, HistoryIcon, SaveIcon, SpinnerIcon } from '@modrinth/assets'
import { computed, onMounted, unref, useTemplateRef } from 'vue'
import { useRoute } from 'vue-router'
import { Button } from '#ui/components/base/buttons'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '../../../../utils/common-messages'
import ButtonStyled from '../../../base/ButtonStyled.vue'
import { NewModal } from '../../../modal'
import EnvironmentMigration from './EnvironmentMigration.vue'
@@ -54,19 +54,17 @@
<p class="m-0 text-secondary text-sm">
{{ formatMessage(messages.lockedDescription) }}
</p>
<ButtonStyled>
<button
class="w-fit"
@click="
() => {
overriddenProvidedFilterTypes.push(filterType.id)
}
"
>
<LockOpenIcon />
{{ formatMessage(messages.unlockFilterButton) }}
</button>
</ButtonStyled>
<Button
class="w-fit"
@click="
() => {
overriddenProvidedFilterTypes.push(filterType.id)
}
"
>
<LockOpenIcon />
{{ formatMessage(messages.unlockFilterButton) }}
</Button>
</div>
</template>
<template v-else #default>
@@ -157,25 +155,23 @@
@update:model-value="toggleGroup(group.id)"
/>
<div v-if="hasProvidedFilter" class="mt-2 mx-1">
<ButtonStyled>
<button
class="w-fit"
@click="
() => {
overriddenProvidedFilterTypes = overriddenProvidedFilterTypes.filter(
(id) => id !== filterType.id,
)
accordion?.close()
clearFilters()
}
"
>
<UpdatedIcon />
<slot name="sync-button">
{{ formatMessage(messages.syncFilterButton) }}
</slot>
</button>
</ButtonStyled>
<Button
class="w-fit"
@click="
() => {
overriddenProvidedFilterTypes = overriddenProvidedFilterTypes.filter(
(id) => id !== filterType.id,
)
accordion?.close()
clearFilters()
}
"
>
<UpdatedIcon />
<slot name="sync-button">
{{ formatMessage(messages.syncFilterButton) }}
</slot>
</Button>
</div>
</div>
</template>
@@ -186,10 +182,11 @@
import { BanIcon, DropdownIcon, LockOpenIcon, SearchIcon, UpdatedIcon } from '@modrinth/assets'
import { computed, ref } from 'vue'
import { Button } from '#ui/components/base/buttons'
import { defineMessages, useVIntl } from '../../composables/i18n'
import type { FilterOption, FilterType, FilterValue } from '../../utils/search'
import Accordion from '../base/Accordion.vue'
import ButtonStyled from '../base/ButtonStyled.vue'
import { Checkbox, ScrollablePanel, StyledInput } from '../index'
import SearchFilterGroup from './SearchFilterGroup.vue'
import SearchFilterOption from './SearchFilterOption.vue'
@@ -27,18 +27,17 @@
</div>
</div>
<template v-if="contentError" #top-right-actions>
<ButtonStyled color="red" type="outlined">
<button
v-tooltip="retryDisabled ? retryDisabledTooltip : undefined"
class="!border"
type="button"
:disabled="retryDisabled"
@click="emit('retry')"
>
<RotateCounterClockwiseIcon class="size-5" />
{{ formatMessage(commonMessages.retryButton) }}
</button>
</ButtonStyled>
<Button
v-tooltip="retryDisabled ? retryDisabledTooltip : undefined"
type="outlined"
class="!border !text-red [&>svg]:!text-red !shadow-[inset_0_0_0_1px_var(--color-red)]"
native-type="button"
:disabled="retryDisabled"
@click="emit('retry')"
>
<RotateCounterClockwiseIcon class="size-5" />
{{ formatMessage(commonMessages.retryButton) }}
</Button>
</template>
</Admonition>
</template>
@@ -47,11 +46,11 @@
import { RotateCounterClockwiseIcon } from '@modrinth/assets'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { Button } from '#ui/components/base/buttons'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
import Admonition from '../base/Admonition.vue'
import ButtonStyled from '../base/ButtonStyled.vue'
export interface SyncProgress {
phase: 'Analyzing' | 'InstallingPack' | 'InstallingLoader' | 'Addons'
@@ -3,22 +3,29 @@
<FloatingActionBar :shown="props.isVisible">
<p class="m-0 font-semibold text-sm md:text-base">You have unsaved changes.</p>
<div class="ml-auto flex gap-2">
<ButtonStyled type="transparent">
<button :disabled="props.isUpdating" @click="props.reset"><HistoryIcon /> Reset</button>
</ButtonStyled>
<ButtonStyled :color="props.restart ? 'standard' : 'brand'">
<button :disabled="props.isUpdating" @click="props.save">
<SpinnerIcon v-if="props.isUpdating" class="animate-spin" />
<SaveIcon v-else />
{{ props.isUpdating ? 'Saving...' : 'Save' }}
</button>
</ButtonStyled>
<ButtonStyled v-if="props.restart" color="brand">
<button :disabled="props.isUpdating || isTransitioning" @click="saveAndPower">
<SpinnerIcon v-if="props.isUpdating || isTransitioning" class="animate-spin" />
{{ powerButtonLabel }}
</button>
</ButtonStyled>
<Button type="quiet" :disabled="props.isUpdating" @click="props.reset"
><HistoryIcon /> Reset</Button
>
<Button
:type="props.restart ? 'base' : 'colored'"
:color="props.restart ? undefined : 'brand'"
:disabled="props.isUpdating"
@click="props.save"
>
<SpinnerIcon v-if="props.isUpdating" class="animate-spin" />
<SaveIcon v-else />
{{ props.isUpdating ? 'Saving...' : 'Save' }}
</Button>
<Button
v-if="props.restart"
type="colored"
color="brand"
:disabled="props.isUpdating || isTransitioning"
@click="saveAndPower"
>
<SpinnerIcon v-if="props.isUpdating || isTransitioning" class="animate-spin" />
{{ powerButtonLabel }}
</Button>
</div>
</FloatingActionBar>
</Teleport>
@@ -28,7 +35,7 @@
import { HistoryIcon, SaveIcon, SpinnerIcon } from '@modrinth/assets'
import { computed } from 'vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import { Button } from '#ui/components/base/buttons'
import FloatingActionBar from '#ui/components/base/FloatingActionBar.vue'
import { injectModrinthClient, injectModrinthServerContext } from '#ui/providers'
@@ -183,46 +183,53 @@
</div>
<div v-if="noticeButtons" class="flex gap-2">
<ButtonStyled
<IconButton
v-if="noticeButtons.downloadBackup && onDownloadBackup && isBackupDownloadEnabled"
v-tooltip="formatMessage(messages.downloadLatestBackupTooltip)"
type="outlined"
circular
:label="formatMessage(messages.downloadLatestBackupTooltip)"
data-server-listing-button
@click="onDownloadBackup"
>
<button
v-tooltip="formatMessage(messages.downloadLatestBackupTooltip)"
data-server-listing-button
@click="onDownloadBackup"
>
<DownloadIcon />
</button>
</ButtonStyled>
<ButtonStyled v-if="noticeButtons.copyId" type="outlined">
<button
v-tooltip="formatMessage(messages.copyCodeToClipboardTooltip)"
data-server-listing-button
@click="copyToClipboard(server_id)"
>
<template v-if="copied">
{{ formatMessage(messages.copiedLabel) }} <CheckIcon class="text-green" />
</template>
<template v-else> {{ formatMessage(messages.copyIdLabel) }} <CopyIcon /> </template>
</button>
</ButtonStyled>
<ButtonStyled v-if="noticeButtons.support">
<a href="https://support.modrinth.com/en/" target="_blank" data-server-listing-button
><MessagesSquareIcon /> {{ formatMessage(messages.supportLabel) }}
</a>
</ButtonStyled>
<ButtonStyled v-if="noticeButtons.manageBilling" color="brand">
<AutoLink :to="`/settings/billing#server-${server_id}`" data-server-listing-button>
<CardIcon /> {{ formatMessage(messages.manageBillingLabel) }}
</AutoLink>
</ButtonStyled>
<ButtonStyled v-if="noticeButtons.resubscribe && onResubscribe" color="brand">
<button data-server-listing-button @click="onResubscribe">
<RotateCounterClockwiseIcon /> {{ formatMessage(messages.resubscribeLabel) }}
</button>
</ButtonStyled>
<DownloadIcon />
</IconButton>
<Button
v-if="noticeButtons.copyId"
v-tooltip="formatMessage(messages.copyCodeToClipboardTooltip)"
type="outlined"
data-server-listing-button
@click="copyToClipboard(server_id)"
>
<template v-if="copied">
{{ formatMessage(messages.copiedLabel) }} <CheckIcon class="text-green" />
</template>
<template v-else> {{ formatMessage(messages.copyIdLabel) }} <CopyIcon /> </template>
</Button>
<ButtonLink
v-if="noticeButtons.support"
href="https://support.modrinth.com/en/"
target="_blank"
data-server-listing-button
><MessagesSquareIcon /> {{ formatMessage(messages.supportLabel) }}
</ButtonLink>
<ButtonLink
v-if="noticeButtons.manageBilling"
type="colored"
color="brand"
:to="`/settings/billing#server-${server_id}`"
data-server-listing-button
>
<CardIcon /> {{ formatMessage(messages.manageBillingLabel) }}
</ButtonLink>
<Button
v-if="noticeButtons.resubscribe && onResubscribe"
type="colored"
color="brand"
data-server-listing-button
@click="onResubscribe"
>
<RotateCounterClockwiseIcon /> {{ formatMessage(messages.resubscribeLabel) }}
</Button>
</div>
</div>
@@ -261,11 +268,12 @@ import {
SparklesIcon,
SpinnerIcon,
} from '@modrinth/assets'
import { AutoLink, ButtonStyled } from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Button, ButtonLink, IconButton } from '#ui/components/base/buttons'
import {
CardIcon,
CheckIcon,
@@ -1,8 +1,7 @@
<script setup lang="ts">
import { ModrinthIcon, RightArrowIcon, XIcon } from '@modrinth/assets'
import AutoLink from '../base/AutoLink.vue'
import ButtonStyled from '../base/ButtonStyled.vue'
import { ButtonLink, IconButton } from '#ui/components/base/buttons'
const emit = defineEmits<{
(e: 'close'): void
@@ -36,16 +35,21 @@ withDefaults(
<span class="text-sm font-medium">Create a server with Modrinth in just a few clicks.</span>
</div>
<div class="flex flex-col items-end justify-end z-10">
<ButtonStyled color="brand">
<AutoLink :to="link"> View plans <RightArrowIcon /> </AutoLink>
</ButtonStyled>
<ButtonLink type="colored" color="brand" :to="link">
View plans <RightArrowIcon />
</ButtonLink>
</div>
<div class="absolute top-2 right-2 z-10">
<ButtonStyled v-if="closable" size="small" circular>
<button v-tooltip="`Don't show again`" @click="emit('close')">
<XIcon aria-hidden="true" />
</button>
</ButtonStyled>
<IconButton
v-if="closable"
v-tooltip="`Don't show again`"
class="!size-6"
size="xs"
:label="`Don't show again`"
@click="emit('close')"
>
<XIcon aria-hidden="true" />
</IconButton>
</div>
</div>
</template>
@@ -74,29 +74,28 @@
<template #cell-actions="{ row: member }">
<div v-if="!member.isOwner" class="flex items-center justify-end gap-1">
<ButtonStyled v-if="member.pending" circular type="transparent">
<button
v-tooltip="resendInviteTooltip(member)"
:aria-label="resendInviteLabel(member)"
:disabled="resendInviteDisabled(member)"
class="text-secondary hover:!filter-none hover:text-contrast focus-visible:!filter-none active:!scale-100 active:!filter-none disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:text-secondary"
@click="handleResendInvite(member)"
>
<SendIcon aria-hidden="true" />
</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<button
v-tooltip="memberAccessActionTooltip(member)"
:aria-label="memberAccessActionLabel(member)"
:disabled="!canManageUsers"
class="text-secondary hover:!filter-none hover:text-red focus-visible:!filter-none active:!scale-100 active:!filter-none disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:text-secondary"
@click="member.pending ? handleCancelInvite(member) : handleRemoveMember(member)"
>
<XIcon v-if="member.pending" aria-hidden="true" />
<UserXIcon v-else aria-hidden="true" />
</button>
</ButtonStyled>
<IconButton
v-if="member.pending"
v-tooltip="resendInviteTooltip(member)"
type="quiet"
:label="resendInviteLabel(member)"
:disabled="resendInviteDisabled(member)"
class="text-secondary hover:!filter-none hover:text-contrast focus-visible:!filter-none active:!scale-100 active:!filter-none disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:text-secondary"
@click="handleResendInvite(member)"
>
<SendIcon aria-hidden="true" />
</IconButton>
<IconButton
v-tooltip="memberAccessActionTooltip(member)"
type="quiet"
:label="memberAccessActionLabel(member)"
:disabled="!canManageUsers"
class="text-secondary hover:!filter-none hover:text-red focus-visible:!filter-none active:!scale-100 active:!filter-none disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:text-secondary"
@click="member.pending ? handleCancelInvite(member) : handleRemoveMember(member)"
>
<XIcon v-if="member.pending" aria-hidden="true" />
<UserXIcon v-else aria-hidden="true" />
</IconButton>
</div>
</template>
</Table>
@@ -223,29 +222,30 @@
<span v-else>{{ formatMessage(messages.unknownJoinedDate) }}</span>
</div>
<div class="flex min-w-0 items-center justify-end pr-4">
<ButtonStyled v-if="!member.isOwner" circular type="transparent">
<TeleportOverflowMenu
:options="memberActionOptions(member)"
btn-class="hover:!filter-none focus-visible:!filter-none active:!scale-100 active:!filter-none"
>
<MoreVerticalIcon aria-hidden="true" class="size-5" />
<span class="sr-only">
{{ formatMessage(messages.memberActionsLabel, { username: member.user.username }) }}
</span>
<template #resend-invite>
<SendIcon aria-hidden="true" />
{{ resendInviteLabel(member) }}
</template>
<template #cancel-invite>
<XIcon aria-hidden="true" />
{{ formatMessage(messages.cancelInvite) }}
</template>
<template #remove-user>
<UserXIcon aria-hidden="true" />
{{ formatMessage(messages.removeUser) }}
</template>
</TeleportOverflowMenu>
</ButtonStyled>
<TeleportOverflowMenu
v-if="!member.isOwner"
type="quiet"
:label="formatMessage(messages.memberActionsLabel, { username: member.user.username })"
:options="memberActionOptions(member)"
class="hover:!filter-none focus-visible:!filter-none active:!scale-100 active:!filter-none"
>
<MoreVerticalIcon aria-hidden="true" class="size-5" />
<span class="sr-only">
{{ formatMessage(messages.memberActionsLabel, { username: member.user.username }) }}
</span>
<template #resend-invite>
<SendIcon aria-hidden="true" />
{{ resendInviteLabel(member) }}
</template>
<template #cancel-invite>
<XIcon aria-hidden="true" />
{{ formatMessage(messages.cancelInvite) }}
</template>
<template #remove-user>
<UserXIcon aria-hidden="true" />
{{ formatMessage(messages.removeUser) }}
</template>
</TeleportOverflowMenu>
</div>
</div>
</div>
@@ -284,17 +284,21 @@ import {
UserXIcon,
XIcon,
} from '@modrinth/assets'
import { type Component, computed, onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import {
IconButton,
type OverflowMenuOption,
TeleportOverflowMenu,
} from '#ui/components/base/buttons'
import { useFormatDateTime, useRelativeTime } from '../../../composables'
import { defineMessages, useVIntl } from '../../../composables/i18n'
import { commonMessages } from '../../../utils/common-messages'
import AutoLink from '../../base/AutoLink.vue'
import Avatar from '../../base/Avatar.vue'
import ButtonStyled from '../../base/ButtonStyled.vue'
import Combobox, { type ComboboxOption } from '../../base/Combobox.vue'
import Table, { type SortDirection, type TableColumn } from '../../base/Table.vue'
import TeleportOverflowMenu from '../../base/TeleportOverflowMenu.vue'
import type {
ServerAccessMember,
ServerAccessRole,
@@ -396,16 +400,6 @@ const messages = defineMessages({
type AccessTableColumn = 'user' | 'role' | 'joined' | 'actions'
type AccessTableSortableColumn = Exclude<AccessTableColumn, 'actions'>
type AccessTableRow = ServerAccessMember & Record<string, unknown>
type OverflowMenuOption = {
id: string
icon?: Component
action: () => void
shown?: boolean
color?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
disabled?: boolean
tooltip?: string
}
const columns = computed<TableColumn<AccessTableColumn>[]>(() => [
{ key: 'user', label: formatMessage(messages.userColumn), width: '32%', enableSorting: true },
{ key: 'role', label: formatMessage(messages.roleColumn), width: '28%', enableSorting: true },
@@ -609,6 +603,7 @@ function memberActionOptions(member: ServerAccessMember): OverflowMenuOption[] {
return [
{
id: 'resend-invite',
label: resendInviteLabel(member),
icon: SendIcon,
action: () => handleResendInvite(member),
shown: member.pending,
@@ -617,18 +612,20 @@ function memberActionOptions(member: ServerAccessMember): OverflowMenuOption[] {
},
{
id: 'cancel-invite',
label: formatMessage(messages.cancelInvite),
icon: XIcon,
action: () => handleCancelInvite(member),
color: 'red',
tone: 'red',
shown: member.pending,
disabled: !canManageUsers.value,
tooltip: memberAccessActionTooltip(member),
},
{
id: 'remove-user',
label: formatMessage(messages.removeUser),
icon: UserXIcon,
action: () => handleRemoveMember(member),
color: 'red',
tone: 'red',
shown: !member.pending,
disabled: !canManageUsers.value,
tooltip: memberAccessActionTooltip(member),
@@ -9,6 +9,7 @@
v-model:custom-start-date="timeframeCustomStartDate"
v-model:custom-end-date="timeframeCustomEndDate"
:class="timeframePickerClass"
trigger-size="lg"
:trigger-class="timeframePickerTriggerClass"
/>
<template v-if="slots.filters">
@@ -363,7 +364,7 @@ const timeframePickerClass = computed(() =>
slots.filters ? '!w-full @[640px]:!w-[225px] shrink-0' : '!w-full @[640px]:!w-[225px]',
)
const timeframePickerTriggerClass =
'!h-10 !min-h-10 !w-full !rounded-[14px] !bg-surface-4 !py-2.5 !pl-4 !pr-3 !text-base shadow-[0px_1px_1px_rgba(0,0,0,0.3),0px_1px_1.5px_rgba(0,0,0,0.15)]'
'!w-full !rounded-[14px] !bg-surface-4 !py-2.5 !pl-4 !pr-3 !text-base shadow-[0px_1px_1px_rgba(0,0,0,0.3),0px_1px_1.5px_rgba(0,0,0,0.15)]'
onMounted(() => {
updateLoadMoreObserver()
@@ -126,18 +126,20 @@
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="outlined">
<button @click="hide">
<XIcon aria-hidden="true" />
{{ formatMessage(messages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button v-tooltip="grantPermissionTooltip" :disabled="!canSubmit" @click="submit">
<UserPlusIcon aria-hidden="true" />
{{ formatMessage(messages.inviteButton) }}
</button>
</ButtonStyled>
<Button type="outlined" @click="hide">
<XIcon aria-hidden="true" />
{{ formatMessage(messages.cancelButton) }}
</Button>
<Button
v-tooltip="grantPermissionTooltip"
type="colored"
color="brand"
:disabled="!canSubmit"
@click="submit"
>
<UserPlusIcon aria-hidden="true" />
{{ formatMessage(messages.inviteButton) }}
</Button>
</div>
</template>
</NewModal>
@@ -148,10 +150,11 @@ import { EyeIcon, PencilIcon, UserPlusIcon, XIcon } from '@modrinth/assets'
import { useDebounceFn } from '@vueuse/core'
import { computed, ref } from 'vue'
import { Button } from '#ui/components/base/buttons'
import { defineMessages, useVIntl } from '../../../composables/i18n'
import { commonMessages } from '../../../utils/common-messages'
import Avatar from '../../base/Avatar.vue'
import ButtonStyled from '../../base/ButtonStyled.vue'
import Checkbox from '../../base/Checkbox.vue'
import Combobox, { type ComboboxOption } from '../../base/Combobox.vue'
import IntlFormatted from '../../base/IntlFormatted.vue'
@@ -62,21 +62,23 @@
</div>
<div class="flex justify-end gap-2 pt-1">
<ButtonStyled type="outlined">
<button class="!border !border-surface-5" @click="hide">
<XIcon aria-hidden="true" />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="orange">
<button v-tooltip="removePermissionTooltip" :disabled="!canRemove" @click="confirm">
<TrashIcon v-if="modalState.shouldCancel" aria-hidden="true" />
<UserXIcon v-else aria-hidden="true" />
{{
formatMessage(modalState.shouldCancel ? messages.cancelButton : messages.removeButton)
}}
</button>
</ButtonStyled>
<Button type="outlined" class="!border !border-surface-5" @click="hide">
<XIcon aria-hidden="true" />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button
v-tooltip="removePermissionTooltip"
type="colored"
color="orange"
:disabled="!canRemove"
@click="confirm"
>
<TrashIcon v-if="modalState.shouldCancel" aria-hidden="true" />
<UserXIcon v-else aria-hidden="true" />
{{
formatMessage(modalState.shouldCancel ? messages.cancelButton : messages.removeButton)
}}
</Button>
</div>
</div>
</NewModal>
@@ -86,12 +88,13 @@
import { TrashIcon, UserXIcon, XIcon } from '@modrinth/assets'
import { computed, ref, watch } from 'vue'
import { Button } from '#ui/components/base/buttons'
import { useRelativeTime } from '../../../composables'
import { defineMessages, useVIntl } from '../../../composables/i18n'
import { commonMessages } from '../../../utils/common-messages'
import Admonition from '../../base/Admonition.vue'
import Avatar from '../../base/Avatar.vue'
import ButtonStyled from '../../base/ButtonStyled.vue'
import NewModal from '../../modal/NewModal.vue'
import type { ServerAccessRole } from './types'
@@ -8,7 +8,7 @@ import {
} from '@modrinth/assets'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import { Button } from '#ui/components/base/buttons'
import type { MessageDescriptor } from '#ui/composables/i18n'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
@@ -279,29 +279,29 @@ function getDescription(item: BackupAdmonitionEntry): string {
</span>
</div>
<template #top-right-actions>
<ButtonStyled v-if="canCancel(item)" type="outlined" color="blue">
<button
v-tooltip="canManageBackups === false ? permissionDeniedMessage : undefined"
class="!border"
type="button"
:disabled="cancelling || canManageBackups === false"
@click="$emit('cancel')"
>
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="canRetry(item)" color="red" type="outlined">
<button
v-tooltip="canManageBackups === false ? permissionDeniedMessage : undefined"
class="!border"
type="button"
:disabled="canManageBackups === false"
@click="$emit('retry')"
>
<RotateCounterClockwiseIcon class="size-5" />
{{ formatMessage(commonMessages.retryButton) }}
</button>
</ButtonStyled>
<Button
v-if="canCancel(item)"
v-tooltip="canManageBackups === false ? permissionDeniedMessage : undefined"
type="outlined"
class="!border !text-blue [&>svg]:!text-blue !shadow-[inset_0_0_0_1px_var(--color-blue)]"
native-type="button"
:disabled="cancelling || canManageBackups === false"
@click="$emit('cancel')"
>
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button
v-if="canRetry(item)"
v-tooltip="canManageBackups === false ? permissionDeniedMessage : undefined"
type="outlined"
class="!border !text-red [&>svg]:!text-red !shadow-[inset_0_0_0_1px_var(--color-red)]"
native-type="button"
:disabled="canManageBackups === false"
@click="$emit('retry')"
>
<RotateCounterClockwiseIcon class="size-5" />
{{ formatMessage(commonMessages.retryButton) }}
</Button>
</template>
</Admonition>
</template>
@@ -24,17 +24,17 @@
</span>
</span>
<template v-if="op.id" #top-right-actions>
<ButtonStyled v-if="!isTerminal" type="outlined" color="blue">
<button
v-tooltip="!canWriteFiles ? permissionDeniedMessage : undefined"
class="!border"
type="button"
:disabled="!canWriteFiles"
@click="cancelOperation"
>
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<Button
v-if="!isTerminal"
v-tooltip="!canWriteFiles ? permissionDeniedMessage : undefined"
type="outlined"
class="!border !text-blue [&>svg]:!text-blue !shadow-[inset_0_0_0_1px_var(--color-blue)]"
native-type="button"
:disabled="!canWriteFiles"
@click="cancelOperation"
>
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
</template>
</Admonition>
</template>
@@ -44,7 +44,7 @@ import { PackageOpenIcon } from '@modrinth/assets'
import { computed } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import { Button } from '#ui/components/base/buttons'
import { useFormatBytes } from '#ui/composables'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { useServerPermissions } from '#ui/composables/server-permissions'
@@ -16,11 +16,15 @@
}}%)
</span>
<template v-if="cancelable" #top-right-actions>
<ButtonStyled type="outlined" color="blue">
<button class="!border" type="button" :disabled="cancelling" @click="$emit('cancel')">
Cancel
</button>
</ButtonStyled>
<Button
type="outlined"
class="!border !text-blue [&>svg]:!text-blue !shadow-[inset_0_0_0_1px_var(--color-blue)]"
native-type="button"
:disabled="cancelling"
@click="$emit('cancel')"
>
Cancel
</Button>
</template>
</Admonition>
</template>
@@ -30,7 +34,7 @@ import { UploadIcon } from '@modrinth/assets'
import { computed } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import { Button } from '#ui/components/base/buttons'
import { useFormatBytes } from '#ui/composables'
import { injectModrinthServerContext } from '#ui/providers'
@@ -46,22 +46,20 @@
</div>
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled type="outlined">
<button @click="hideModal">
<XIcon />
Cancel
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button
v-tooltip="createDisabledTooltip"
:disabled="createDisabled"
@click="createBackup"
>
<PlusIcon />
Create backup
</button>
</ButtonStyled>
<Button type="outlined" @click="hideModal">
<XIcon />
Cancel
</Button>
<Button
v-tooltip="createDisabledTooltip"
type="colored"
color="brand"
:disabled="createDisabled"
@click="createBackup"
>
<PlusIcon />
Create backup
</Button>
</div>
</template>
</NewModal>
@@ -73,6 +71,8 @@ import { IssuesIcon, PlusIcon, XIcon } from '@modrinth/assets'
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import { computed, nextTick, ref } from 'vue'
import { Button } from '#ui/components/base/buttons'
import { useVIntl } from '../../../composables/i18n'
import {
injectModrinthClient,
@@ -80,7 +80,6 @@ import {
injectNotificationManager,
} from '../../../providers'
import { commonMessages } from '../../../utils'
import ButtonStyled from '../../base/ButtonStyled.vue'
import StyledInput from '../../base/StyledInput.vue'
import NewModal from '../../modal/NewModal.vue'
@@ -60,22 +60,20 @@
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="outlined">
<button @click="modal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button
v-tooltip="deleteDisabledTooltip"
:disabled="!props.canDelete"
@click="confirmDelete"
>
<TrashIcon />
{{ formatMessage(messages.confirm, { count }) }}
</button>
</ButtonStyled>
<Button type="outlined" @click="modal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button
v-tooltip="deleteDisabledTooltip"
type="colored"
color="red"
:disabled="!props.canDelete"
@click="confirmDelete"
>
<TrashIcon />
{{ formatMessage(messages.confirm, { count }) }}
</Button>
</div>
</template>
</NewModal>
@@ -86,11 +84,12 @@ import type { Archon } from '@modrinth/api-client'
import { TrashIcon, XIcon } from '@modrinth/assets'
import { computed, nextTick, ref } from 'vue'
import { Button } from '#ui/components/base/buttons'
import { defineMessages, useVIntl } from '../../../composables/i18n'
import { useScrollIndicator } from '../../../composables/scroll-indicator'
import { commonMessages } from '../../../utils'
import Admonition from '../../base/Admonition.vue'
import ButtonStyled from '../../base/ButtonStyled.vue'
import NewModal from '../../modal/NewModal.vue'
import BackupItem from './BackupItem.vue'
@@ -13,13 +13,14 @@ import {
} from '@modrinth/assets'
import { computed, ref } from 'vue'
import type { OverflowMenuOption } from '#ui/components/base/buttons'
import { Button, TeleportOverflowMenu } from '#ui/components/base/buttons'
import { useFormatDateTime } from '../../../composables'
import { defineMessages, useVIntl } from '../../../composables/i18n'
import { commonMessages, truncatedTooltip } from '../../../utils'
import AutoLink from '../../base/AutoLink.vue'
import Avatar from '../../base/Avatar.vue'
import ButtonStyled from '../../base/ButtonStyled.vue'
import OverflowMenu, { type Option as OverflowOption } from '../../base/OverflowMenu.vue'
const { formatMessage } = useVIntl()
const formatDateTime = useFormatDateTime({
@@ -93,38 +94,41 @@ const itemBorderClass = computed(() => {
return 'border-transparent'
})
const overflowMenuOptions = computed<OverflowOption[]>(() => {
const options: OverflowOption[] = []
const overflowMenuOptions = computed<OverflowMenuOption[]>(() => {
const options: OverflowMenuOption[] = []
if (props.showCopyIdAction) {
options.push({
id: 'copy-id',
label: formatMessage(commonMessages.copyIdButton),
action: () => copyId(),
})
}
if (options.length > 0) {
options.push({ divider: true })
options.push({ type: 'divider' })
}
options.push({
id: 'download',
label: formatMessage(commonMessages.downloadButton),
action: () => emit('download'),
link: `https://${props.kyrosUrl}/modrinth/v0/backups/${props.backup.id}/download?auth=${props.jwt}`,
disabled: !props.kyrosUrl || !props.jwt,
})
options.push({
id: 'rename',
label: formatMessage(messages.rename),
action: () => emit('rename'),
disabled: props.writeDisabled,
tooltip: props.writeDisabled ? props.writeDisabledTooltip : undefined,
})
options.push({ divider: true })
options.push({ type: 'divider' })
options.push({
id: 'delete',
color: 'red',
label: formatMessage(commonMessages.deleteLabel),
tone: 'red',
action: () => emit('delete'),
disabled: props.writeDisabled,
tooltip: props.writeDisabled ? props.writeDisabledTooltip : undefined,
@@ -265,35 +269,32 @@ const creatorAvatarSrc = computed(() =>
<!-- Right side actions -->
<div v-if="!preview" class="flex min-w-0 flex-1 items-center justify-end gap-2">
<ButtonStyled color="brand" type="outlined">
<button
v-tooltip="props.restoreDisabled"
class="!border"
:disabled="!!props.restoreDisabled"
@click="() => emit('restore')"
>
<RotateCounterClockwiseIcon class="size-5" />
{{ formatMessage(messages.restore) }}
</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<OverflowMenu :options="overflowMenuOptions">
<MoreVerticalIcon class="size-5" />
<template #copy-id>
<ClipboardCopyIcon class="size-5" />
{{ formatMessage(commonMessages.copyIdButton) }}
</template>
<template #download>
<DownloadIcon class="size-5" /> {{ formatMessage(commonMessages.downloadButton) }}
</template>
<template #rename>
<EditIcon class="size-5" /> {{ formatMessage(messages.rename) }}
</template>
<template #delete>
<TrashIcon class="size-5" /> {{ formatMessage(commonMessages.deleteLabel) }}
</template>
</OverflowMenu>
</ButtonStyled>
<Button
v-tooltip="props.restoreDisabled"
type="outlined"
class="!border !text-brand [&>svg]:!text-brand !shadow-[inset_0_0_0_1px_var(--color-brand)]"
:disabled="!!props.restoreDisabled"
@click="() => emit('restore')"
>
<RotateCounterClockwiseIcon class="size-5" />
{{ formatMessage(messages.restore) }}
</Button>
<TeleportOverflowMenu type="quiet" label="More options" :options="overflowMenuOptions">
<MoreVerticalIcon class="size-5" />
<template #copy-id>
<ClipboardCopyIcon class="size-5" />
{{ formatMessage(commonMessages.copyIdButton) }}
</template>
<template #download>
<DownloadIcon class="size-5" /> {{ formatMessage(commonMessages.downloadButton) }}
</template>
<template #rename>
<EditIcon class="size-5" /> {{ formatMessage(messages.rename) }}
</template>
<template #delete>
<TrashIcon class="size-5" /> {{ formatMessage(commonMessages.deleteLabel) }}
</template>
</TeleportOverflowMenu>
</div>
<pre v-if="!preview && showDebugInfo" class="w-full rounded-xl bg-surface-4 p-2 text-xs">{{
@@ -22,28 +22,26 @@
</div>
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled type="outlined">
<button @click="hide">
<XIcon />
Cancel
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button
v-tooltip="renameDisabledTooltip"
:disabled="renameDisabled"
@click="renameBackup"
>
<template v-if="renameMutation.isPending.value">
<SpinnerIcon class="animate-spin" />
Renaming...
</template>
<template v-else>
<SaveIcon />
Save changes
</template>
</button>
</ButtonStyled>
<Button type="outlined" @click="hide">
<XIcon />
Cancel
</Button>
<Button
v-tooltip="renameDisabledTooltip"
type="colored"
color="brand"
:disabled="renameDisabled"
@click="renameBackup"
>
<template v-if="renameMutation.isPending.value">
<SpinnerIcon class="animate-spin" />
Renaming...
</template>
<template v-else>
<SaveIcon />
Save changes
</template>
</Button>
</div>
</template>
</NewModal>
@@ -55,6 +53,8 @@ import { IssuesIcon, SaveIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import { computed, nextTick, ref } from 'vue'
import { Button } from '#ui/components/base/buttons'
import { useVIntl } from '../../../composables/i18n'
import {
injectModrinthClient,
@@ -62,7 +62,6 @@ import {
injectNotificationManager,
} from '../../../providers'
import { commonMessages } from '../../../utils'
import ButtonStyled from '../../base/ButtonStyled.vue'
import StyledInput from '../../base/StyledInput.vue'
import NewModal from '../../modal/NewModal.vue'
@@ -17,23 +17,21 @@
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled type="outlined">
<button @click="modal?.hide()">
<XIcon />
Cancel
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button
v-tooltip="restoreDisabledTooltip"
:disabled="restoreDisabled"
@click="restoreBackup"
>
<SpinnerIcon v-if="isRestoring" class="animate-spin" />
<RotateCounterClockwiseIcon v-else />
{{ isRestoring ? 'Restoring...' : 'Restore backup' }}
</button>
</ButtonStyled>
<Button type="outlined" @click="modal?.hide()">
<XIcon />
Cancel
</Button>
<Button
v-tooltip="restoreDisabledTooltip"
type="colored"
color="red"
:disabled="restoreDisabled"
@click="restoreBackup"
>
<SpinnerIcon v-if="isRestoring" class="animate-spin" />
<RotateCounterClockwiseIcon v-else />
{{ isRestoring ? 'Restoring...' : 'Restore backup' }}
</Button>
</div>
</template>
</NewModal>
@@ -45,6 +43,8 @@ import { RotateCounterClockwiseIcon, SpinnerIcon, XIcon } from '@modrinth/assets
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import { Button } from '#ui/components/base/buttons'
import { useVIntl } from '../../../composables/i18n'
import {
injectModrinthClient,
@@ -53,7 +53,6 @@ import {
} from '../../../providers'
import { commonMessages } from '../../../utils'
import Admonition from '../../base/Admonition.vue'
import ButtonStyled from '../../base/ButtonStyled.vue'
import NewModal from '../../modal/NewModal.vue'
import BackupItem from './BackupItem.vue'
@@ -2,19 +2,25 @@
<div class="flex flex-col gap-2.5">
<span class="text-lg font-semibold text-contrast">Icon</span>
<div class="group relative w-fit">
<OverflowMenu
v-tooltip="editIconTooltip"
class="m-0 cursor-pointer appearance-none border-none bg-transparent p-0 transition-transform group-active:scale-95"
<TeleportOverflowMenu
:label="editIconTooltip"
:tooltip="editIconTooltip"
:icon-only="false"
type="quiet"
interaction="none"
class="m-0 !h-auto cursor-pointer appearance-none border-none bg-transparent !p-0 transition-transform group-active:scale-95"
:disabled="isIconActionDisabled"
:options="[
{
id: 'upload',
label: 'Upload icon',
action: () => triggerFileInput(),
disabled: !props.canEdit,
tooltip: !props.canEdit ? editIconTooltip : undefined,
},
{
id: 'sync',
label: 'Sync icon',
action: () => resetIcon(),
disabled: !props.canEdit,
tooltip: !props.canEdit ? editIconTooltip : undefined,
@@ -39,7 +45,7 @@
</div>
<template #upload> <UploadIcon /> Upload icon </template>
<template #sync> <TransferIcon /> Sync icon </template>
</OverflowMenu>
</TeleportOverflowMenu>
</div>
</div>
</template>
@@ -49,7 +55,7 @@ import { EditIcon, SpinnerIcon, TransferIcon, UploadIcon } from '@modrinth/asset
import { useQueryClient } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import { OverflowMenu, ServerIcon } from '#ui/components'
import { ServerIcon, TeleportOverflowMenu } from '#ui/components'
import { useServerImage } from '#ui/composables'
import { useVIntl } from '#ui/composables/i18n'
import {
@@ -30,9 +30,10 @@
</Transition>
</div>
<ButtonStyled color="medal-promo" type="outlined" size="large">
<button class="z-10 my-auto" @click="openUpgradeModal"><RocketIcon /> Upgrade</button>
</ButtonStyled>
<Button type="outlined" size="xl" class="z-10 my-auto" @click="openUpgradeModal">
<RocketIcon aria-hidden="true" />
Upgrade
</Button>
</div>
<ServersUpgradeModalWrapper
ref="upgradeModal"
@@ -50,7 +51,7 @@ import dayjs from 'dayjs'
import dayjsDuration from 'dayjs/plugin/duration'
import { type ComponentPublicInstance, computed, onMounted, onUnmounted, ref } from 'vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import { Button } from '#ui/components/base/buttons'
import ServersUpgradeModalWrapper from '#ui/components/billing/ServersUpgradeModalWrapper.vue'
import { injectModrinthClient } from '#ui/providers'
@@ -120,11 +120,17 @@
</div>
<div class="z-10 ml-auto">
<ButtonStyled color="medal-promo" type="outlined" size="large">
<button class="my-auto" data-server-listing-button @click="handleUpgrade">
<RocketIcon /> {{ formatMessage(messages.upgradeButton) }}
</button>
</ButtonStyled>
<Button
type="outlined"
color="medal_promotion"
size="xl"
class="my-auto"
data-server-listing-button
@click="handleUpgrade"
>
<RocketIcon aria-hidden="true" />
{{ formatMessage(messages.upgradeButton) }}
</Button>
</div>
</div>
@@ -168,10 +174,11 @@ import dayjsDuration from 'dayjs/plugin/duration'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { Button } from '#ui/components/base/buttons'
import { defineMessages, useVIntl } from '../../../composables/i18n'
import { injectModrinthClient } from '../../../providers/api-client'
import Avatar from '../../base/Avatar.vue'
import ButtonStyled from '../../base/ButtonStyled.vue'
import CopyCode from '../../base/CopyCode.vue'
import IntlFormatted from '../../base/IntlFormatted.vue'
import type { ServerListingOwner } from '../access/types'
@@ -1,60 +1,75 @@
<template>
<div class="contents">
<div class="flex flex-row items-center gap-2 rounded-lg">
<ButtonStyled v-if="isInstalling" type="standard" color="brand" size="large">
<button disabled class="flex-shrink-0">
<LoaderCircleIcon class="size-5 animate-spin" /> Installing...
</button>
</ButtonStyled>
<Button
v-if="isInstalling"
type="colored"
color="brand"
size="xl"
disabled
class="flex-shrink-0"
>
<LoaderCircleIcon class="size-5 animate-spin" /> Installing...
</Button>
<template v-else-if="showRestartButton">
<ButtonStyled type="standard" color="orange" size="large">
<button v-tooltip="busyTooltip" :disabled="!canTakeAction" @click="handlePrimaryAction">
<UpdatedIcon />
<span>{{ primaryActionText }}</span>
</button>
</ButtonStyled>
<JoinedButtons
color="red"
size="large"
:actions="stopSplitActions"
:primary-disabled="!canTakeAction"
:dropdown-disabled="!canKill"
:primary-tooltip="busyTooltip"
:dropdown-tooltip="busyTooltip"
<Button
v-tooltip="busyTooltip"
type="colored"
color="orange"
size="xl"
:disabled="!canTakeAction"
@click="handlePrimaryAction"
>
<template #kill_server>
<SlashIcon class="h-5 w-5" />
Kill server
</template>
</JoinedButtons>
<UpdatedIcon />
<span>{{ primaryActionText }}</span>
</Button>
<SplitButton
v-tooltip="busyTooltip"
type="colored"
color="red"
size="xl"
:options="stopMenuOptions"
menu-label="Force stop options"
:primary-disabled="!canTakeAction"
:menu-disabled="!canKill"
@click="initiateAction('Stop')"
>
<StopCircleIcon />
{{ isStopping ? 'Stopping' : 'Stop' }}
</SplitButton>
</template>
<template v-else-if="isStopping">
<JoinedButtons
<SplitButton
v-tooltip="busyTooltip"
type="colored"
color="red"
size="large"
:actions="stopSplitActions"
size="xl"
:options="stopMenuOptions"
menu-label="Force stop options"
:primary-disabled="true"
:dropdown-disabled="!canKill"
:primary-muted="true"
:dropdown-tooltip="busyTooltip"
:menu-disabled="!canKill"
class="opacity-60"
>
<template #kill_server>
<SlashIcon class="h-5 w-5" />
Kill server
</template>
</JoinedButtons>
<StopCircleIcon />
Stopping
</SplitButton>
</template>
<template v-else>
<ButtonStyled type="standard" color="brand" size="large">
<button v-tooltip="busyTooltip" :disabled="!canTakeAction" @click="handlePrimaryAction">
<PlayIcon />
<span>{{ primaryActionText }}</span>
</button>
</ButtonStyled>
<Button
v-tooltip="busyTooltip"
type="colored"
color="brand"
size="xl"
:disabled="!canTakeAction"
@click="handlePrimaryAction"
>
<PlayIcon />
<span>{{ primaryActionText }}</span>
</Button>
</template>
</div>
</div>
@@ -70,7 +85,8 @@ import {
} from '@modrinth/assets'
import { computed } from 'vue'
import { ButtonStyled, type JoinedButtonAction, JoinedButtons } from '#ui/components'
import type { OverflowMenuOption } from '#ui/components/base/buttons'
import { Button, SplitButton } from '#ui/components/base/buttons'
import { useServerPowerAction } from './use-server-power-action'
@@ -97,13 +113,7 @@ const {
disabled: computed(() => props.disabled),
})
const stopSplitActions = computed<JoinedButtonAction[]>(() => [
{
id: 'stop',
label: isStopping.value ? 'Stopping' : 'Stop',
icon: StopCircleIcon,
action: () => initiateAction('Stop'),
},
const stopMenuOptions = computed<OverflowMenuOption[]>(() => [
{
id: 'kill_server',
label: 'Kill server',
@@ -66,12 +66,10 @@
<!-- CTA section -->
<div class="flex flex-col gap-6 items-start">
<div class="flex flex-col gap-3 items-start">
<ButtonStyled color="brand">
<button @click="onClickNewServer?.()">
<PlusIcon aria-hidden="true" />
{{ formatMessage(messages.newServerButton) }}
</button>
</ButtonStyled>
<Button type="colored" color="brand" @click="onClickNewServer?.()">
<PlusIcon aria-hidden="true" />
{{ formatMessage(messages.newServerButton) }}
</Button>
<AutoLink
to="https://modrinth.com/hosting"
@@ -90,12 +88,10 @@
<p class="text-base font-normal text-primary">
{{ formatMessage(messages.alreadyHaveServerLabel) }}
</p>
<ButtonStyled>
<button @click="onClickSignIn?.()">
<LogInIcon aria-hidden="true" />
{{ formatMessage(messages.signInButton) }}
</button>
</ButtonStyled>
<Button @click="onClickSignIn?.()">
<LogInIcon aria-hidden="true" />
{{ formatMessage(messages.signInButton) }}
</Button>
</div>
</template>
</div>
@@ -153,7 +149,7 @@ import {
} from '@modrinth/assets'
import { AutoLink } from '@modrinth/ui'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import { Button } from '#ui/components/base/buttons'
import { defineMessages, useVIntl } from '../../../composables/i18n'
import imgAircraft from './grid-images/aircraft.png'
@@ -54,17 +54,17 @@
</div>
</template>
</Combobox>
<ButtonStyled color="brand">
<button
v-tooltip="searchInviteTooltip"
class="shrink-0"
:disabled="!canInviteSearchTarget"
@click="inviteSearchTarget"
>
<PlusIcon aria-hidden="true" />
{{ addButtonLabel }}
</button>
</ButtonStyled>
<Button
v-tooltip="searchInviteTooltip"
type="colored"
color="brand"
class="shrink-0"
:disabled="!canInviteSearchTarget"
@click="inviteSearchTarget"
>
<PlusIcon aria-hidden="true" />
{{ addButtonLabel }}
</Button>
</div>
</div>
@@ -102,18 +102,17 @@
<div class="text-base font-semibold text-contrast">
{{ inviteLinkHeading }}
</div>
<ButtonStyled>
<button
type="button"
class="!h-10 w-full !justify-between !px-4 text-left !shadow-none"
@click="copyInviteLink"
>
<span class="min-w-0 truncate text-base font-semibold text-primary">
{{ link }}
</span>
<ClipboardCopyIcon class="size-5 shrink-0 text-secondary" aria-hidden="true" />
</button>
</ButtonStyled>
<Button
native-type="button"
size="lg"
class="w-full !justify-between text-left"
@click="copyInviteLink"
>
<span class="min-w-0 truncate text-base font-semibold text-primary">
{{ link }}
</span>
<ClipboardCopyIcon class="size-5 shrink-0 text-secondary" aria-hidden="true" />
</Button>
<p v-if="link && linkExpiryDescription" class="m-0 text-base text-primary">
{{ linkExpiryDescription }}
<button
@@ -144,10 +143,11 @@
import { ClipboardCopyIcon, PlusIcon } from '@modrinth/assets'
import { computed, ref } from 'vue'
import { Button } from '#ui/components/base/buttons'
import { defineMessages, useVIntl } from '../../../composables/i18n'
import { injectNotificationManager } from '../../../providers'
import Avatar from '../../base/Avatar.vue'
import ButtonStyled from '../../base/ButtonStyled.vue'
import Combobox from '../../base/Combobox.vue'
import NewModal from '../../modal/NewModal.vue'
import InvitePlayersModalInviteLinkEditor from './invite-players-modal-invite-link-editor.vue'
@@ -31,20 +31,18 @@
calendar-class="!border-none"
/>
<div class="flex justify-end gap-2 p-3 pt-1">
<ButtonStyled type="outlined">
<button type="button" @click="cancelCustomExpiry">
{{ formatMessage(messages.cancel) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button
type="button"
:disabled="!canApplyCustomExpiry"
@click="applyCustomExpiry"
>
{{ formatMessage(messages.apply) }}
</button>
</ButtonStyled>
<Button type="outlined" native-type="button" @click="cancelCustomExpiry">
{{ formatMessage(messages.cancel) }}
</Button>
<Button
type="colored"
color="brand"
native-type="button"
:disabled="!canApplyCustomExpiry"
@click="applyCustomExpiry"
>
{{ formatMessage(messages.apply) }}
</Button>
</div>
</div>
<button
@@ -73,19 +71,15 @@
</div>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled>
<button :disabled="saving" @click="modal?.hide()">
<XIcon aria-hidden="true" />
{{ formatMessage(messages.cancel) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="!canSave" @click="save">
<SpinnerIcon v-if="saving" class="animate-spin" aria-hidden="true" />
<SaveIcon v-else aria-hidden="true" />
{{ formatMessage(messages.save) }}
</button>
</ButtonStyled>
<Button :disabled="saving" @click="modal?.hide()">
<XIcon aria-hidden="true" />
{{ formatMessage(messages.cancel) }}
</Button>
<Button type="colored" color="brand" :disabled="!canSave" @click="save">
<SpinnerIcon v-if="saving" class="animate-spin" aria-hidden="true" />
<SaveIcon v-else aria-hidden="true" />
{{ formatMessage(messages.save) }}
</Button>
</div>
</template>
</NewModal>
@@ -95,10 +89,11 @@
import { SaveIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { computed, ref, watch } from 'vue'
import { Button } from '#ui/components/base/buttons'
import { useFormatDateTime } from '../../../composables'
import { defineMessages, useVIntl } from '../../../composables/i18n'
import { injectNotificationManager } from '../../../providers'
import ButtonStyled from '../../base/ButtonStyled.vue'
import Combobox, { type ComboboxOption } from '../../base/Combobox.vue'
import DatePicker from '../../base/DatePicker.vue'
import StyledInput from '../../base/StyledInput.vue'
@@ -30,29 +30,26 @@
</AutoLink>
</div>
<ButtonStyled v-if="status === 'added'" type="standard" color-fill="none">
<button disabled>
<CheckIcon aria-hidden="true" />
{{ addedLabel }}
</button>
</ButtonStyled>
<ButtonStyled v-else-if="status === 'pending'" type="outlined">
<button :disabled="disabled" @click="$emit('cancel', user)">
{{ cancelLabel }}
</button>
</ButtonStyled>
<Button v-if="status === 'added'" type="quiet" disabled>
<CheckIcon aria-hidden="true" />
{{ addedLabel }}
</Button>
<Button
v-else-if="status === 'pending'"
type="outlined"
:disabled="disabled"
@click="$emit('cancel', user)"
>
{{ cancelLabel }}
</Button>
<span v-else-if="status === 'requested'" v-tooltip="requestedTooltip" class="inline-flex">
<ButtonStyled type="standard" color-fill="none">
<button disabled>
{{ requestedLabel }}
</button>
</ButtonStyled>
<Button type="quiet" disabled>
{{ requestedLabel }}
</Button>
</span>
<ButtonStyled v-else color-fill="none">
<button :disabled="disabled" @click="$emit('invite', user)">
{{ inviteLabel }}
</button>
</ButtonStyled>
<Button v-else type="quiet" :disabled="disabled" @click="$emit('invite', user)">
{{ inviteLabel }}
</Button>
</div>
</template>
@@ -60,9 +57,10 @@
import { CheckIcon } from '@modrinth/assets'
import { computed } from 'vue'
import { Button } from '#ui/components/base/buttons'
import AutoLink from '../../base/AutoLink.vue'
import Avatar from '../../base/Avatar.vue'
import ButtonStyled from '../../base/ButtonStyled.vue'
import type { InvitePlayersUser, InvitePlayersUserProfileLink } from './types'
const props = withDefaults(
@@ -57,21 +57,19 @@
<template #actions>
<PageHeaderActions>
<ButtonStyled v-if="isSelf" size="large">
<AutoLink :to="editProfileLink">
<EditIcon />
{{ formatMessage(commonMessages.editButton) }}
</AutoLink>
</ButtonStyled>
<ButtonStyled circular size="large" type="transparent">
<TeleportOverflowMenu
:options="moreActions"
:tooltip="formatMessage(commonMessages.moreOptionsButton)"
:aria-label="formatMessage(commonMessages.moreOptionsButton)"
>
<MoreVerticalIcon />
</TeleportOverflowMenu>
</ButtonStyled>
<ButtonLink v-if="isSelf" size="xl" :to="editProfileLink">
<EditIcon />
{{ formatMessage(commonMessages.editButton) }}
</ButtonLink>
<TeleportOverflowMenu
type="quiet"
size="xl"
:label="formatMessage(commonMessages.moreOptionsButton)"
:tooltip="formatMessage(commonMessages.moreOptionsButton)"
:options="moreActions"
>
<MoreVerticalIcon />
</TeleportOverflowMenu>
</PageHeaderActions>
</template>
</PageHeader>
@@ -96,17 +94,15 @@ import {
} from '@modrinth/assets'
import { computed } from 'vue'
import AutoLink from '#ui/components/base/AutoLink.vue'
import Avatar from '#ui/components/base/Avatar.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import type { OverflowMenuOption } from '#ui/components/base/buttons'
import { ButtonLink, TeleportOverflowMenu } from '#ui/components/base/buttons'
import PageHeader from '#ui/components/base/page-header/index.vue'
import PageHeaderMetadata from '#ui/components/base/page-header/metadata/index.vue'
import PageHeaderMetadataNumberItem from '#ui/components/base/page-header/metadata/page-header-metadata-number-item.vue'
import PageHeaderMetadataTimeItem from '#ui/components/base/page-header/metadata/page-header-metadata-time-item.vue'
import PageHeaderActions from '#ui/components/base/page-header/page-header-actions.vue'
import PageHeaderBadgeItem from '#ui/components/base/page-header/page-header-badge-item.vue'
import type { Item as TeleportOverflowMenuItem } from '#ui/components/base/TeleportOverflowMenu.vue'
import TeleportOverflowMenu from '#ui/components/base/TeleportOverflowMenu.vue'
import { defineMessages, useFormatDateTime, useFormatNumber, useVIntl } from '#ui/composables'
import type { AuthUser } from '#ui/providers/auth'
import { commonMessages } from '#ui/utils'
@@ -228,7 +224,7 @@ const formatDateTime = useFormatDateTime({
const downloadsTooltip = computed(() => formatNumber(props.downloads))
const joinedTooltip = computed(() => formatDateTime(props.user.created))
const moreActions = computed<TeleportOverflowMenuItem[]>(() => [
const moreActions = computed<OverflowMenuOption[]>(() => [
{
id: 'manage-projects',
label: formatMessage(messages.profileManageProjectsButton),
@@ -236,16 +232,13 @@ const moreActions = computed<TeleportOverflowMenuItem[]>(() => [
action: () => emit('manageProjects'),
shown: props.isSelf,
},
{
divider: true,
shown: props.isSelf,
},
{ type: 'divider', shown: props.isSelf },
{
id: 'report',
label: formatMessage(commonMessages.reportButton),
icon: ReportIcon,
action: () => emit('report'),
color: 'red',
tone: 'red',
shown: props.authUser?.id !== props.user.id,
},
{
@@ -253,7 +246,7 @@ const moreActions = computed<TeleportOverflowMenuItem[]>(() => [
label: formatMessage(props.isBlocked ? messages.unblockButton : messages.blockButton),
icon: BanIcon,
action: () => emit('block'),
color: 'red',
tone: 'red',
shown: props.authUser?.id !== props.user.id,
},
{
@@ -268,15 +261,13 @@ const moreActions = computed<TeleportOverflowMenuItem[]>(() => [
icon: ClipboardCopyIcon,
action: () => emit('copyPermalink'),
},
{
divider: true,
shown: props.showStaffActions && (props.isAdmin || props.isStaff),
},
{ type: 'divider', shown: props.showStaffActions && (props.isAdmin || props.isStaff) },
{
id: 'open-billing',
label: formatMessage(messages.billingButton),
icon: CurrencyIcon,
action: () => emit('openBilling'),
tone: 'orange',
shown: props.showStaffActions && props.isStaff,
},
{
@@ -287,14 +278,15 @@ const moreActions = computed<TeleportOverflowMenuItem[]>(() => [
icon: AffiliateIcon,
action: () => emit('toggleAffiliate'),
shown: props.showStaffActions && props.isAdmin,
remainOnClick: true,
color: props.isAffiliate ? 'red' : 'orange',
remainOpen: true,
tone: props.isAffiliate ? 'red' : 'orange',
},
{
id: 'open-info',
label: formatMessage(messages.infoButton),
icon: InfoIcon,
action: () => emit('openInfo'),
tone: 'orange',
shown: props.showStaffActions && props.isStaff,
},
{
@@ -302,6 +294,7 @@ const moreActions = computed<TeleportOverflowMenuItem[]>(() => [
label: formatMessage(messages.analyticsButton),
icon: ChartIcon,
action: () => emit('openAnalytics'),
tone: 'orange',
shown: props.showStaffActions && props.isAdmin,
},
{
@@ -309,6 +302,7 @@ const moreActions = computed<TeleportOverflowMenuItem[]>(() => [
label: formatMessage(messages.editRoleButton),
icon: EditIcon,
action: () => emit('editRole'),
tone: 'orange',
shown: props.showStaffActions && props.isAdmin,
},
])
@@ -7,13 +7,13 @@
:options="platformOptions"
fit-content
:dropdown-min-width="180"
trigger-class="!min-h-9 !px-3 !py-0"
trigger-type="base"
@update:model-value="updateSelectedPlatforms"
>
<template #input-content="{ isOpen, openDirection }">
<div class="flex items-center gap-2">
<FilterIcon class="h-5 w-5 text-secondary" />
<span class="font-semibold text-primary">Platforms</span>
<span class="font-semibold text-inherit">Platforms</span>
<ChevronLeftIcon
class="h-5 w-5 text-secondary transition-transform duration-150"
:class="
@@ -31,13 +31,13 @@
search-placeholder="Search..."
fit-content
:dropdown-min-width="240"
trigger-class="!min-h-9 !px-3 !py-0"
trigger-type="base"
@update:model-value="updateSelectedGameVersions"
>
<template #input-content="{ isOpen, openDirection }">
<div class="flex items-center gap-2">
<FilterIcon class="h-5 w-5 text-secondary" />
<span class="font-semibold text-primary">Game versions</span>
<span class="font-semibold text-inherit">Game versions</span>
<ChevronLeftIcon
class="h-5 w-5 text-secondary transition-transform duration-150"
:class="
@@ -63,13 +63,13 @@
:options="channelOptions"
fit-content
:dropdown-min-width="180"
trigger-class="!min-h-9 !px-3 !py-0"
trigger-type="base"
@update:model-value="updateSelectedChannels"
>
<template #input-content="{ isOpen, openDirection }">
<div class="flex items-center gap-2">
<FilterIcon class="h-5 w-5 text-secondary" />
<span class="font-semibold text-primary">Channels</span>
<span class="font-semibold text-inherit">Channels</span>
<ChevronLeftIcon
class="h-5 w-5 text-secondary transition-transform duration-150"
:class="
@@ -11,27 +11,25 @@
{{ version.name }}
</p>
</div>
<ButtonStyled color="brand">
<a
:href="downloadUrl"
:download="primaryFilename"
class="min-w-0"
@click="emit('onDownload')"
>
<DownloadIcon aria-hidden="true" /> Download
</a>
</ButtonStyled>
<ButtonStyled circular>
<button
class="min-w-0"
aria-label="View version"
@click="
emit('onNavigate', `/project/${props.version.project_id}/version/${props.version.id}`)
"
>
<ExternalIcon aria-hidden="true" />
</button>
</ButtonStyled>
<ButtonLink
type="colored"
color="brand"
:href="downloadUrl"
:download="primaryFilename"
class="min-w-0"
@click="emit('onDownload')"
>
<DownloadIcon aria-hidden="true" /> Download
</ButtonLink>
<IconButton
label="View version"
class="min-w-0"
@click="
emit('onNavigate', `/project/${props.version.project_id}/version/${props.version.id}`)
"
>
<ExternalIcon aria-hidden="true" />
</IconButton>
</div>
</template>
@@ -40,7 +38,9 @@ import { DownloadIcon, ExternalIcon } from '@modrinth/assets'
import type { Version, VersionFile } from '@modrinth/utils'
import { computed } from 'vue'
import { ButtonStyled, VersionChannelIndicator } from '../index'
import { ButtonLink, IconButton } from '#ui/components/base/buttons'
import { VersionChannelIndicator } from '../index'
const props = defineProps<{
version: Version