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
+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,