mirror of
https://github.com/modrinth/code.git
synced 2026-08-30 19:46:33 +00:00
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:
@@ -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'
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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" />
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user