mirror of
https://github.com/modrinth/code.git
synced 2026-08-31 12:05:53 +00:00
menu refactor + web account switcher (#7307)
* add account switcher, migrate context menus to overflow menu system, update version filter controls, only pad instance icons * app account switcher, improve some context menus, theme stuff * prepr * handle reauthentication * fix a couple sign in issues, admin page cleanup, fix org status badges, fix official account badge, open in local keybind, publish plus icons * rename generic menus to ButtonMenu (& types) * fix hydration issue w/ dropdowns + middleware error
This commit is contained in:
@@ -6,9 +6,9 @@
|
||||
:style="`--_size: ${cssSize}`"
|
||||
:class="{
|
||||
circle: circle,
|
||||
detecting: !hasDetectedCorners,
|
||||
detecting: padTransparentCorners && !hasDetectedCorners,
|
||||
'no-shadow': noShadow,
|
||||
padded: hasTransparentCorners && !circle && !disableConditionalIconPadding,
|
||||
padded: padTransparentCorners && hasTransparentCorners,
|
||||
raised: raised,
|
||||
pixelated: pixelated,
|
||||
}"
|
||||
@@ -78,7 +78,7 @@ const props = withDefaults(
|
||||
size?: string
|
||||
circle?: boolean
|
||||
noShadow?: boolean
|
||||
disableConditionalIconPadding?: boolean
|
||||
padTransparentCorners?: boolean
|
||||
loading?: 'eager' | 'lazy'
|
||||
raised?: boolean
|
||||
tintBy?: string | null
|
||||
@@ -90,7 +90,7 @@ const props = withDefaults(
|
||||
size: '2rem',
|
||||
circle: false,
|
||||
noShadow: false,
|
||||
disableConditionalIconPadding: false,
|
||||
padTransparentCorners: false,
|
||||
loading: 'eager',
|
||||
raised: false,
|
||||
tintBy: null,
|
||||
@@ -150,8 +150,6 @@ function onLoad() {
|
||||
if (!image) return
|
||||
const source = image.currentSrc
|
||||
if (detectingSource === source) return
|
||||
detectingSource = source
|
||||
clearDetectionTimeout()
|
||||
|
||||
if (image.naturalWidth && image.naturalWidth < 32) {
|
||||
pixelated.value = true
|
||||
@@ -159,6 +157,13 @@ function onLoad() {
|
||||
pixelated.value = false
|
||||
}
|
||||
|
||||
if (!props.padTransparentCorners) {
|
||||
return
|
||||
}
|
||||
|
||||
detectingSource = source
|
||||
clearDetectionTimeout()
|
||||
|
||||
if (canReadImagePixels(source)) {
|
||||
const transparentCorners = detectTransparentCorners(image)
|
||||
if (transparentCorners !== null) {
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
</div>
|
||||
</ButtonFrame>
|
||||
|
||||
<Teleport to="#teleports">
|
||||
<Teleport v-if="isClient" to="#teleports">
|
||||
<Transition name="floating-expand">
|
||||
<div
|
||||
v-if="shouldRenderDropdown"
|
||||
@@ -105,7 +105,7 @@
|
||||
props.dropdownClass,
|
||||
openDirection === 'up' ? 'shadow-[0_-25px_50px_-12px_rgb(0,0,0,0.25)]' : 'shadow-2xl',
|
||||
]"
|
||||
:style="dropdownStyle"
|
||||
:style="[dropdownStyle, { transformOrigin: dropdownTransformOrigin }]"
|
||||
:role="listbox ? 'listbox' : 'menu'"
|
||||
@mousedown.stop
|
||||
@keydown="handleDropdownKeydown"
|
||||
@@ -387,6 +387,9 @@ const dropdownStyle = ref({
|
||||
})
|
||||
|
||||
const openDirection = ref<'down' | 'up'>('down')
|
||||
const dropdownTransformOrigin = computed(() =>
|
||||
openDirection.value === 'up' ? 'bottom center' : 'top center',
|
||||
)
|
||||
|
||||
const selectedOption = computed<ComboboxOption<T> | undefined>(() => {
|
||||
return props.options.find(
|
||||
@@ -475,11 +478,85 @@ function setInitialFocus() {
|
||||
? props.options.findIndex((opt) => isDropdownOption(opt) && opt.value === props.modelValue)
|
||||
: -1
|
||||
|
||||
if (focusedIndex.value >= 0 && optionRefs.value[focusedIndex.value]) {
|
||||
optionRefs.value[focusedIndex.value]?.scrollIntoView({ block: 'center' })
|
||||
if (focusedIndex.value >= 0) {
|
||||
scrollOptionIntoView(focusedIndex.value, 'center')
|
||||
}
|
||||
}
|
||||
|
||||
function getOptionsViewport(): HTMLElement | undefined {
|
||||
return optionsOverlayScrollbars.value?.elements().viewport ?? optionsContainerRef.value
|
||||
}
|
||||
|
||||
function scrollOptionIntoView(index: number, block: 'center' | 'nearest') {
|
||||
const option = optionRefs.value[index]
|
||||
const viewport = getOptionsViewport()
|
||||
if (!option || !viewport) return
|
||||
|
||||
const optionRect = option.getBoundingClientRect()
|
||||
const viewportRect = viewport.getBoundingClientRect()
|
||||
const optionTop = optionRect.top - viewportRect.top + viewport.scrollTop
|
||||
const optionHeight = optionRect.height
|
||||
const viewHeight = viewport.clientHeight
|
||||
const current = viewport.scrollTop
|
||||
|
||||
if (block === 'center') {
|
||||
viewport.scrollTop = Math.max(0, optionTop - (viewHeight - optionHeight) / 2)
|
||||
return
|
||||
}
|
||||
|
||||
if (optionTop < current) {
|
||||
viewport.scrollTop = optionTop
|
||||
} else if (optionTop + optionHeight > current + viewHeight) {
|
||||
viewport.scrollTop = optionTop + optionHeight - viewHeight
|
||||
}
|
||||
}
|
||||
|
||||
function estimateDropdownHeight(): number {
|
||||
const optionHeight = 44
|
||||
const optionWithSubLabelHeight = 68
|
||||
const dividerHeight = 1
|
||||
let height = 0
|
||||
|
||||
if (filteredOptions.value.length === 0) {
|
||||
if (searchQuery.value) {
|
||||
height += 52
|
||||
}
|
||||
} else {
|
||||
for (const item of filteredOptions.value) {
|
||||
if (isDivider(item)) {
|
||||
height += dividerHeight
|
||||
} else {
|
||||
height += item.subLabel ? optionWithSubLabelHeight : optionHeight
|
||||
}
|
||||
}
|
||||
|
||||
height = Math.min(height, props.maxHeight)
|
||||
}
|
||||
|
||||
if (slots['dropdown-footer']) {
|
||||
height += 48
|
||||
}
|
||||
|
||||
return height
|
||||
}
|
||||
|
||||
function previewOpenDirection() {
|
||||
if (props.forceDirection) {
|
||||
openDirection.value = props.forceDirection
|
||||
return
|
||||
}
|
||||
|
||||
if (!effectiveTriggerEl.value) return
|
||||
|
||||
const triggerRect = getTriggerRect(effectiveTriggerEl.value)
|
||||
const viewport = getViewportRect()
|
||||
openDirection.value = determineOpenDirection(
|
||||
triggerRect,
|
||||
{ width: triggerRect.width, height: estimateDropdownHeight() },
|
||||
viewport,
|
||||
)
|
||||
}
|
||||
|
||||
function determineOpenDirection(
|
||||
triggerRect: DOMRect,
|
||||
dropdownRect: { width: number; height: number },
|
||||
@@ -542,6 +619,26 @@ function getViewportRect(): ViewportRect {
|
||||
}
|
||||
}
|
||||
|
||||
function getTriggerRect(el: HTMLElement): DOMRect {
|
||||
const rect = el.getBoundingClientRect()
|
||||
const transform = getComputedStyle(el).transform
|
||||
if (!transform || transform === 'none') return rect
|
||||
|
||||
const matrix = new DOMMatrixReadOnly(transform)
|
||||
const scaleX = Math.hypot(matrix.a, matrix.b) || 1
|
||||
const scaleY = Math.hypot(matrix.c, matrix.d) || 1
|
||||
if (Math.abs(scaleX - 1) < 0.001 && Math.abs(scaleY - 1) < 0.001) return rect
|
||||
|
||||
const width = rect.width / scaleX
|
||||
const height = rect.height / scaleY
|
||||
return new DOMRect(
|
||||
rect.left + (rect.width - width) / 2,
|
||||
rect.top + (rect.height - height) / 2,
|
||||
width,
|
||||
height,
|
||||
)
|
||||
}
|
||||
|
||||
function resolveDropdownWidth(triggerWidth: number): string {
|
||||
if (props.dropdownWidth === undefined) return `${triggerWidth}px`
|
||||
if (typeof props.dropdownWidth === 'number') return `${props.dropdownWidth}px`
|
||||
@@ -559,8 +656,9 @@ async function updateDropdownPosition() {
|
||||
|
||||
await nextTick()
|
||||
|
||||
const triggerRect = effectiveTriggerEl.value.getBoundingClientRect()
|
||||
const width = resolveDropdownWidth(effectiveTriggerEl.value.offsetWidth)
|
||||
const trigger = effectiveTriggerEl.value
|
||||
let triggerRect = getTriggerRect(trigger)
|
||||
const width = resolveDropdownWidth(triggerRect.width)
|
||||
const minWidth = resolveCssSize(props.dropdownMinWidth) ?? '0px'
|
||||
|
||||
dropdownStyle.value = {
|
||||
@@ -571,6 +669,7 @@ async function updateDropdownPosition() {
|
||||
|
||||
await nextTick()
|
||||
|
||||
triggerRect = getTriggerRect(trigger)
|
||||
const dropdownRect = {
|
||||
width: dropdownRef.value.offsetWidth,
|
||||
height: dropdownRef.value.offsetHeight,
|
||||
@@ -635,15 +734,16 @@ async function openDropdown() {
|
||||
if (props.disabled || isOpen.value || !hasMinimumSearchLength.value || !hasDropdownContent.value)
|
||||
return
|
||||
|
||||
previewOpenDirection()
|
||||
isOpen.value = true
|
||||
emit('open')
|
||||
|
||||
await nextTick()
|
||||
await updateDropdownPosition()
|
||||
await initializeOptionsOverlayScrollbars()
|
||||
|
||||
setInitialFocus()
|
||||
startPositionTracking()
|
||||
setInitialFocus()
|
||||
scheduleDropdownPositionUpdate()
|
||||
}
|
||||
|
||||
function closeDropdown() {
|
||||
@@ -735,7 +835,7 @@ function focusOption(index: number) {
|
||||
if (isDivider(option) || option.disabled) return
|
||||
|
||||
focusedIndex.value = index
|
||||
optionRefs.value[index]?.scrollIntoView({ block: 'nearest' })
|
||||
scrollOptionIntoView(index, 'nearest')
|
||||
}
|
||||
|
||||
function focusNextOption() {
|
||||
@@ -930,7 +1030,10 @@ onClickOutside(
|
||||
{ ignore: outsideClickIgnoreTargets },
|
||||
)
|
||||
|
||||
const isClient = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
isClient.value = true
|
||||
window.addEventListener('resize', handleWindowResize)
|
||||
})
|
||||
|
||||
|
||||
@@ -150,7 +150,7 @@
|
||||
}}</Button>
|
||||
</div>
|
||||
|
||||
<Teleport to="#teleports">
|
||||
<Teleport v-if="isClient" to="#teleports">
|
||||
<Transition name="floating-expand" :css="!isMobileAddMenuLayout">
|
||||
<div
|
||||
v-if="isAddMenuOpen && !isMobileActiveSubmenu"
|
||||
@@ -192,7 +192,7 @@
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<Teleport to="#teleports">
|
||||
<Teleport v-if="isClient" to="#teleports">
|
||||
<Transition name="floating-expand" :css="!isMobileAddMenuLayout">
|
||||
<div
|
||||
v-if="isAddMenuOpen && activeCategory && (isMobileAddMenuLayout || hasSubmenuPosition)"
|
||||
@@ -425,7 +425,7 @@ import {
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { OverlayScrollbars, type PartialOptions } from 'overlayscrollbars'
|
||||
import type { Component, ComponentPublicInstance, CSSProperties } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { Button, type ButtonElementHandle, type ButtonSize } from '#ui/components/base/buttons'
|
||||
|
||||
@@ -607,6 +607,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const isAddMenuOpen = ref(false)
|
||||
const isClient = ref(false)
|
||||
const activeCategoryKey = ref<string | null>(null)
|
||||
const pendingCategoryKey = ref<string | null>(null)
|
||||
const draftSelectedFilters = ref<DropdownFilterBarValue>(cloneSelectedFilters(props.modelValue))
|
||||
@@ -1980,6 +1981,10 @@ watch(
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
isClient.value = true
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearPendingCategoryTimeout()
|
||||
stopAddMenuPositionTracking()
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
</template>
|
||||
</component>
|
||||
|
||||
<Teleport to="#teleports">
|
||||
<Teleport v-if="isClient" to="#teleports">
|
||||
<Transition name="floating-expand">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
@@ -125,7 +125,7 @@
|
||||
<div class="empty:hidden">
|
||||
<div
|
||||
v-if="searchable"
|
||||
class="px-0 py-1.5 border-0 border-solid border-b border-b-surface-5 flex"
|
||||
class="px-0 border-0 border-solid border-b border-b-surface-5 flex"
|
||||
>
|
||||
<Input
|
||||
ref="searchInputRef"
|
||||
@@ -133,7 +133,7 @@
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
:placeholder="searchPlaceholder"
|
||||
wrapper-class="grow"
|
||||
wrapper-class="grow m-2"
|
||||
@input="handleSearchInput"
|
||||
@keydown="handleSearchKeydown"
|
||||
/>
|
||||
@@ -1390,7 +1390,10 @@ onClickOutside(
|
||||
{ ignore: [triggerElement, containerRef, '.v-popper__popper'] },
|
||||
)
|
||||
|
||||
const isClient = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
isClient.value = true
|
||||
window.addEventListener('resize', handleWindowResize)
|
||||
calculateVisibleTags()
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
role="switch"
|
||||
:aria-checked="modelValue"
|
||||
:disabled="disabled"
|
||||
class="group inline-flex shrink-0 touch-manipulation items-center rounded-full m-0 p-1 transition-all duration-200 cursor-pointer border-none"
|
||||
class="group inline-flex shrink-0 touch-manipulation items-center rounded-full m-0 p-1 transition-all duration-200 cursor-pointer border border-solid border-surface-5"
|
||||
:class="[
|
||||
small ? 'h-5 !w-[40px]' : 'h-6 !w-[48px]',
|
||||
modelValue ? 'bg-brand' : 'bg-button-bg',
|
||||
@@ -21,7 +21,7 @@
|
||||
? small
|
||||
? 'translate-x-[20px] bg-black/90'
|
||||
: 'translate-x-[24px] bg-black/90'
|
||||
: 'bg-gray',
|
||||
: 'bg-secondary',
|
||||
disabled
|
||||
? ''
|
||||
: small
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref, useId, watch } from 'vue'
|
||||
|
||||
import type { AnchoredTeleportAnchor } from '../../../utils/use-anchored-teleport'
|
||||
import { pointAnchor, useAnchoredTeleport } from '../../../utils/use-anchored-teleport'
|
||||
import {
|
||||
isDivider,
|
||||
isHeading,
|
||||
isMenuRow,
|
||||
isSubmenu,
|
||||
useMenuKeyboard,
|
||||
visibleOptions,
|
||||
} from './button-menu/button-menu'
|
||||
import ButtonMenuItem from './button-menu/ButtonMenuItem.vue'
|
||||
import ButtonMenuPanel from './button-menu/ButtonMenuPanel.vue'
|
||||
import ButtonMenuSubmenu from './button-menu/ButtonMenuSubmenu.vue'
|
||||
import type { ButtonMenuAction, ButtonMenuLink, ButtonMenuOption } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [option: ButtonMenuAction | ButtonMenuLink]
|
||||
open: []
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const anchor = ref<AnchoredTeleportAnchor | null>(null)
|
||||
const panel = ref<InstanceType<typeof ButtonMenuPanel> | null>(null)
|
||||
const panelElement = computed(() => panel.value?.element ?? null)
|
||||
const placement = ref('bottom-start' as const)
|
||||
const distance = ref(0)
|
||||
const menuId = `context-menu-${useId()}`
|
||||
const currentOptions = ref<ButtonMenuOption[]>([])
|
||||
|
||||
const options = computed(() => visibleOptions(currentOptions.value))
|
||||
const rows = computed(() => options.value.filter(isMenuRow))
|
||||
|
||||
const { isOpen, panelStyle, expandOrigin, open, close, updatePosition } = useAnchoredTeleport(
|
||||
anchor,
|
||||
panelElement,
|
||||
placement,
|
||||
distance,
|
||||
)
|
||||
|
||||
const { focusedIndex, getItems, handleKeydown, reset } = useMenuKeyboard({
|
||||
panel: panelElement,
|
||||
rows: () => rows.value,
|
||||
onEscape: () => closeMenu(),
|
||||
onTab: () => closeMenu(),
|
||||
})
|
||||
|
||||
async function openMenu(event: MouseEvent, menuOptions: ButtonMenuOption[]) {
|
||||
currentOptions.value = menuOptions
|
||||
anchor.value = pointAnchor(event.clientX, event.clientY)
|
||||
|
||||
if (isOpen.value) {
|
||||
await nextTick()
|
||||
updatePosition()
|
||||
return
|
||||
}
|
||||
|
||||
await open()
|
||||
emit('open')
|
||||
// focus the panel so keys work without highlighting a row
|
||||
await nextTick()
|
||||
panelElement.value?.focus()
|
||||
window.getSelection()?.removeAllRanges()
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
if (!isOpen.value) return
|
||||
reset()
|
||||
close()
|
||||
}
|
||||
|
||||
function handleSelect(option: ButtonMenuAction | ButtonMenuLink) {
|
||||
emit('select', option)
|
||||
if (!option.remainOpen) closeMenu()
|
||||
}
|
||||
|
||||
watch(isOpen, (openState, previousOpenState) => {
|
||||
if (!openState && previousOpenState) emit('close')
|
||||
})
|
||||
|
||||
const isClient = ref(false)
|
||||
onMounted(() => {
|
||||
isClient.value = true
|
||||
})
|
||||
|
||||
defineExpose({ open: openMenu, close: closeMenu })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport v-if="isClient" to="body">
|
||||
<ButtonMenuPanel
|
||||
ref="panel"
|
||||
:open="isOpen"
|
||||
:panel-id="menuId"
|
||||
:label="props.label"
|
||||
:panel-style="panelStyle"
|
||||
:origin="expandOrigin"
|
||||
tabindex="-1"
|
||||
class="focus-visible:outline-none"
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<template v-for="(option, index) in options" :key="option.id ?? `${option.type}-${index}`">
|
||||
<div v-if="isDivider(option)" role="separator" class="my-1 h-px bg-surface-5" />
|
||||
|
||||
<div
|
||||
v-else-if="isHeading(option)"
|
||||
class="px-3 pb-1 pt-2 text-xs font-bold uppercase tracking-wide text-secondary first:pt-1"
|
||||
>
|
||||
{{ option.label }}
|
||||
</div>
|
||||
|
||||
<ButtonMenuSubmenu v-else-if="isSubmenu(option)" :option="option" @select="handleSelect">
|
||||
<template #trigger>
|
||||
<slot :name="option.id" :option="option">
|
||||
<component :is="option.icon" v-if="option.icon" aria-hidden="true" />
|
||||
{{ option.label }}
|
||||
</slot>
|
||||
</template>
|
||||
<template #item="{ option: child }">
|
||||
<slot :name="child.id" :option="child">
|
||||
<component :is="child.icon" v-if="child.icon" aria-hidden="true" />
|
||||
{{ child.label }}
|
||||
</slot>
|
||||
</template>
|
||||
</ButtonMenuSubmenu>
|
||||
|
||||
<ButtonMenuItem
|
||||
v-else
|
||||
:option="option"
|
||||
@select="handleSelect"
|
||||
@focus="focusedIndex = getItems().indexOf($event)"
|
||||
>
|
||||
<slot :name="option.id" :option="option">
|
||||
<component :is="option.icon" v-if="option.icon" aria-hidden="true" />
|
||||
{{ option.label }}
|
||||
</slot>
|
||||
</ButtonMenuItem>
|
||||
</template>
|
||||
</ButtonMenuPanel>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -8,19 +8,19 @@ import TeleportOverflowMenu from './TeleportOverflowMenu.vue'
|
||||
import type {
|
||||
ButtonColor,
|
||||
ButtonInteraction,
|
||||
ButtonMenuAction,
|
||||
ButtonMenuLink,
|
||||
ButtonMenuOption,
|
||||
ButtonNativeType,
|
||||
ButtonSize,
|
||||
ButtonType,
|
||||
OverflowMenuAction,
|
||||
OverflowMenuLink,
|
||||
OverflowMenuOption,
|
||||
TeleportPlacement,
|
||||
} from './types'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
menuLabel: string
|
||||
options: OverflowMenuOption[]
|
||||
options: ButtonMenuOption[]
|
||||
groupLabel?: string
|
||||
type?: ButtonType
|
||||
color?: ButtonColor
|
||||
@@ -46,7 +46,7 @@ const props = withDefaults(
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [event: MouseEvent]
|
||||
select: [option: OverflowMenuAction | OverflowMenuLink]
|
||||
select: [option: ButtonMenuAction | ButtonMenuLink]
|
||||
}>()
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
@@ -1,29 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue'
|
||||
import { computed, nextTick, onUnmounted, ref, useId, watch } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { computed, nextTick, onMounted, ref, toRef, useId, watch } from 'vue'
|
||||
|
||||
import { useAnchoredTeleport } from '../../../utils/use-anchored-teleport'
|
||||
import Button from './Button.vue'
|
||||
import {
|
||||
isDivider,
|
||||
isHeading,
|
||||
isLink,
|
||||
isMenuRow,
|
||||
isSubmenu,
|
||||
useHoverIntent,
|
||||
useMenuKeyboard,
|
||||
visibleOptions,
|
||||
} from './button-menu/button-menu'
|
||||
import ButtonMenuItem from './button-menu/ButtonMenuItem.vue'
|
||||
import ButtonMenuPanel from './button-menu/ButtonMenuPanel.vue'
|
||||
import ButtonMenuSubmenu from './button-menu/ButtonMenuSubmenu.vue'
|
||||
import IconButton from './IconButton.vue'
|
||||
import type {
|
||||
ButtonColor,
|
||||
ButtonElementHandle,
|
||||
ButtonInteraction,
|
||||
ButtonMenuAction,
|
||||
ButtonMenuLink,
|
||||
ButtonMenuOption,
|
||||
ButtonSize,
|
||||
ButtonType,
|
||||
OverflowMenuAction,
|
||||
OverflowMenuLink,
|
||||
OverflowMenuOption,
|
||||
TeleportPlacement,
|
||||
} from './types'
|
||||
|
||||
const HOVER_CLOSE_DELAY = 250
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label: string
|
||||
options: OverflowMenuOption[]
|
||||
options: ButtonMenuOption[]
|
||||
type?: ButtonType
|
||||
color?: ButtonColor
|
||||
size?: ButtonSize
|
||||
@@ -49,108 +62,56 @@ const props = withDefaults(
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [option: OverflowMenuAction | OverflowMenuLink]
|
||||
select: [option: ButtonMenuAction | ButtonMenuLink]
|
||||
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 panel = ref<InstanceType<typeof ButtonMenuPanel> | null>(null)
|
||||
const panelElement = computed(() => panel.value?.element ?? null)
|
||||
const resolvedPlacement = toRef(props, 'placement')
|
||||
const resolvedDistance = toRef(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 options = computed(() => visibleOptions(props.options))
|
||||
const rowOptions = computed(() => options.value.filter(isMenuRow))
|
||||
|
||||
const { isOpen, panelStyle, anchorStyle, resolvedSide, open, close } = useAnchoredTeleport(
|
||||
triggerElement,
|
||||
panelElement,
|
||||
resolvedPlacement,
|
||||
resolvedDistance,
|
||||
)
|
||||
const { isOpen, panelStyle, anchorStyle, resolvedSide, expandOrigin, open, close } =
|
||||
useAnchoredTeleport(triggerElement, panelElement, resolvedPlacement, resolvedDistance)
|
||||
|
||||
const menuTransformOrigin = computed(() => {
|
||||
switch (resolvedSide.value) {
|
||||
case 'top':
|
||||
return 'bottom center'
|
||||
case 'left':
|
||||
return 'right center'
|
||||
case 'right':
|
||||
return 'left center'
|
||||
default:
|
||||
return 'top center'
|
||||
}
|
||||
const { focusedIndex, getItems, focusItem, handleKeydown, reset } = useMenuKeyboard({
|
||||
panel: panelElement,
|
||||
rows: () => rowOptions.value,
|
||||
onEscape: () => closeMenu(true),
|
||||
onTab: () => {
|
||||
triggerElement.value?.focus()
|
||||
closeMenu()
|
||||
},
|
||||
})
|
||||
|
||||
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()
|
||||
}
|
||||
const { handleMouseEnter, handleMouseLeave, cancelLeave } = useHoverIntent({
|
||||
closeDelay: HOVER_CLOSE_DELAY,
|
||||
enabled: () => props.hoverable,
|
||||
onEnter: () => openMenu('first', false),
|
||||
onLeave: () => closeMenu(),
|
||||
})
|
||||
|
||||
async function openMenu(position: 'first' | 'last' = 'first', focus = true) {
|
||||
if (props.disabled || isOpen.value) return
|
||||
cancelLeave()
|
||||
await open()
|
||||
emit('open')
|
||||
if (!focus) return
|
||||
await nextTick()
|
||||
focusItem(position === 'first' ? 0 : getMenuItems().length - 1)
|
||||
focusItem(position === 'first' ? 0 : getItems().length - 1)
|
||||
}
|
||||
|
||||
function closeMenu(restoreFocus = false) {
|
||||
if (!isOpen.value) return
|
||||
selectedIndex.value = -1
|
||||
reset()
|
||||
close(restoreFocus)
|
||||
}
|
||||
|
||||
@@ -160,123 +121,30 @@ async function toggleMenu(event?: MouseEvent) {
|
||||
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)
|
||||
function handleItemSelect(option: ButtonMenuAction | ButtonMenuLink) {
|
||||
emit('select', option)
|
||||
if (!option.remainOpen) closeMenu(true)
|
||||
if (!option.remainOpen) closeMenu(!isLink(option)) // don't steal focus from a navigating link
|
||||
}
|
||||
|
||||
function handleLink(option: OverflowMenuLink, event: MouseEvent) {
|
||||
if (option.disabled) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
function handleSubmenuSelect(option: ButtonMenuAction | ButtonMenuLink) {
|
||||
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)
|
||||
const isClient = ref(false)
|
||||
onMounted(() => {
|
||||
isClient.value = true
|
||||
})
|
||||
|
||||
defineExpose({ open: openMenu, close: closeMenu })
|
||||
</script>
|
||||
@@ -306,179 +174,61 @@ defineExpose({ open: openMenu, close: closeMenu })
|
||||
<slot />
|
||||
</component>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition name="floating-expand">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
:id="menuId"
|
||||
ref="panelElement"
|
||||
class="fixed isolate z-[9999] rounded-[14px] bg-surface-3 shadow-lg ring-1 ring-surface-5"
|
||||
:style="[panelStyle, { transformOrigin: menuTransformOrigin }]"
|
||||
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"
|
||||
/>
|
||||
<Teleport v-if="isClient" to="body">
|
||||
<ButtonMenuPanel
|
||||
ref="panel"
|
||||
:open="isOpen"
|
||||
:panel-id="menuId"
|
||||
:label="props.label"
|
||||
:panel-style="panelStyle"
|
||||
:side="resolvedSide"
|
||||
:anchor-style="anchorStyle"
|
||||
:origin="expandOrigin"
|
||||
@keydown="handleKeydown"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
>
|
||||
<template v-for="(option, index) in options" :key="option.id ?? `${option.type}-${index}`">
|
||||
<div v-if="isDivider(option)" role="separator" class="my-1 h-px bg-surface-5" />
|
||||
|
||||
<div
|
||||
data-anchored-scroll-region
|
||||
class="flex min-w-48 flex-col gap-1 overflow-y-auto p-2"
|
||||
:style="{ maxHeight: panelStyle.maxHeight }"
|
||||
v-else-if="isHeading(option)"
|
||||
class="px-3 pb-1 pt-2 text-xs font-bold uppercase tracking-wide text-secondary first:pt-1"
|
||||
>
|
||||
<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>
|
||||
{{ option.label }}
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<ButtonMenuSubmenu
|
||||
v-else-if="isSubmenu(option)"
|
||||
:option="option"
|
||||
@select="handleSubmenuSelect"
|
||||
>
|
||||
<template #trigger>
|
||||
<slot :name="option.id" :option="option">
|
||||
<component :is="option.icon" v-if="option.icon" aria-hidden="true" />
|
||||
{{ option.label }}
|
||||
</slot>
|
||||
</template>
|
||||
<template #item="{ option: child }">
|
||||
<slot :name="child.id" :option="child">
|
||||
<component :is="child.icon" v-if="child.icon" aria-hidden="true" />
|
||||
{{ child.label }}
|
||||
</slot>
|
||||
</template>
|
||||
</ButtonMenuSubmenu>
|
||||
|
||||
<ButtonMenuItem
|
||||
v-else
|
||||
:option="option"
|
||||
@select="handleItemSelect"
|
||||
@focus="focusedIndex = getItems().indexOf($event)"
|
||||
>
|
||||
<slot :name="option.id" :option="option">
|
||||
<component :is="option.icon" v-if="option.icon" aria-hidden="true" />
|
||||
{{ option.label }}
|
||||
</slot>
|
||||
</ButtonMenuItem>
|
||||
</template>
|
||||
</ButtonMenuPanel>
|
||||
</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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, useId, watch } from 'vue'
|
||||
import { computed, nextTick, onMounted, ref, useId, watch } from 'vue'
|
||||
|
||||
import { useAnchoredTeleport } from '../../../utils/use-anchored-teleport'
|
||||
import Button from './Button.vue'
|
||||
@@ -98,6 +98,11 @@ watch(isOpen, (openState, previousOpenState) => {
|
||||
if (!openState && previousOpenState) emit('close')
|
||||
})
|
||||
|
||||
const isClient = ref(false)
|
||||
onMounted(() => {
|
||||
isClient.value = true
|
||||
})
|
||||
|
||||
defineExpose({ open: openMenu, close: closeMenu })
|
||||
</script>
|
||||
|
||||
@@ -122,7 +127,7 @@ defineExpose({ open: openMenu, close: closeMenu })
|
||||
<slot name="trigger" />
|
||||
</component>
|
||||
|
||||
<Teleport to="body">
|
||||
<Teleport v-if="isClient" to="body">
|
||||
<Transition name="floating-expand">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
<script setup lang="ts">
|
||||
import { RadioButtonCheckedIcon, RadioButtonIcon } from '@modrinth/assets'
|
||||
import { computed, ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
import type { ButtonMenuAction, ButtonMenuLink } from '../types'
|
||||
import {
|
||||
buttonMenuItemClasses,
|
||||
buttonMenuTones,
|
||||
getButtonMenuItemAttrs,
|
||||
isLink,
|
||||
} from './button-menu'
|
||||
|
||||
const trailingActionClasses =
|
||||
'button-menu-trailing relative flex size-10 shrink-0 cursor-pointer items-center justify-center border-0 bg-transparent p-0 opacity-0 ' +
|
||||
"pointer-events-none before:pointer-events-none before:absolute before:size-8 before:rounded-full before:content-[''] " +
|
||||
'focus-visible:outline-none ' +
|
||||
'group-hover/button-menu-item:pointer-events-auto group-hover/button-menu-item:opacity-100 ' +
|
||||
'focus-visible:pointer-events-auto focus-visible:opacity-100 ' +
|
||||
'[&>svg]:relative [&>svg]:z-[1] [&>svg]:size-5'
|
||||
|
||||
const props = defineProps<{
|
||||
option: ButtonMenuAction | ButtonMenuLink
|
||||
submenuItem?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [option: ButtonMenuAction | ButtonMenuLink, event: MouseEvent]
|
||||
focus: [element: HTMLElement]
|
||||
}>()
|
||||
|
||||
const wrapperElement = ref<HTMLElement | null>(null)
|
||||
const trailingElement = ref<HTMLElement | null>(null)
|
||||
|
||||
const trailingActionStyle = computed(() => {
|
||||
const color = props.option.trailingAction?.color
|
||||
if (!color) return undefined
|
||||
return { '--button-menu-trailing-color': buttonMenuTones[color] }
|
||||
})
|
||||
|
||||
const itemAttrs = computed(() => ({
|
||||
...getButtonMenuItemAttrs(props.option),
|
||||
'data-button-menu-submenu-item': props.submenuItem || undefined,
|
||||
'aria-checked': typeof props.option.selected === 'boolean' ? props.option.selected : undefined,
|
||||
'aria-current': props.option.selected || undefined,
|
||||
}))
|
||||
|
||||
function handleClick(event: MouseEvent) {
|
||||
if (props.option.disabled) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (!isLink(props.option)) props.option.action(event)
|
||||
emit('select', props.option, event)
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'ArrowRight' && props.option.trailingAction) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
trailingElement.value?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
// links don't fire on space the way buttons do
|
||||
if (event.key !== ' ' || !isLink(props.option)) return
|
||||
event.preventDefault()
|
||||
if (props.option.disabled) return
|
||||
;(event.currentTarget as HTMLElement).click()
|
||||
}
|
||||
|
||||
function focusRow() {
|
||||
wrapperElement.value?.querySelector<HTMLElement>('[role="menuitem"]')?.focus()
|
||||
}
|
||||
|
||||
function handleTrailingKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
focusRow()
|
||||
return
|
||||
}
|
||||
|
||||
if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) focusRow() // so the menu still moves from this row
|
||||
}
|
||||
|
||||
function handleFocus(event: FocusEvent) {
|
||||
emit('focus', event.currentTarget as HTMLElement)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="wrapperElement" class="group/button-menu-item flex items-center">
|
||||
<RouterLink
|
||||
v-if="isLink(props.option) && props.option.to !== undefined && !props.option.disabled"
|
||||
v-tooltip="props.option.tooltip"
|
||||
v-bind="itemAttrs"
|
||||
:to="props.option.to"
|
||||
:class="[buttonMenuItemClasses, 'flex-1']"
|
||||
@click="handleClick"
|
||||
@keydown="handleKeydown"
|
||||
@focus="handleFocus"
|
||||
>
|
||||
<RadioButtonCheckedIcon
|
||||
v-if="props.option.selected === true"
|
||||
aria-hidden="true"
|
||||
class="!text-brand"
|
||||
/>
|
||||
<RadioButtonIcon
|
||||
v-else-if="props.option.selected === false"
|
||||
aria-hidden="true"
|
||||
class="!text-secondary"
|
||||
/>
|
||||
<slot />
|
||||
</RouterLink>
|
||||
|
||||
<a
|
||||
v-else-if="isLink(props.option)"
|
||||
v-tooltip="props.option.tooltip"
|
||||
v-bind="itemAttrs"
|
||||
:href="props.option.disabled ? undefined : props.option.href"
|
||||
:target="props.option.target"
|
||||
:rel="
|
||||
props.option.rel ?? (props.option.target === '_blank' ? 'noopener noreferrer' : undefined)
|
||||
"
|
||||
:download="props.option.download"
|
||||
:aria-disabled="props.option.disabled || undefined"
|
||||
:class="[buttonMenuItemClasses, 'flex-1']"
|
||||
@click="handleClick"
|
||||
@keydown="handleKeydown"
|
||||
@focus="handleFocus"
|
||||
>
|
||||
<RadioButtonCheckedIcon
|
||||
v-if="props.option.selected === true"
|
||||
aria-hidden="true"
|
||||
class="!text-brand"
|
||||
/>
|
||||
<RadioButtonIcon
|
||||
v-else-if="props.option.selected === false"
|
||||
aria-hidden="true"
|
||||
class="!text-secondary"
|
||||
/>
|
||||
<slot />
|
||||
</a>
|
||||
|
||||
<button
|
||||
v-else
|
||||
v-tooltip="props.option.tooltip"
|
||||
v-bind="itemAttrs"
|
||||
type="button"
|
||||
:aria-disabled="props.option.disabled || undefined"
|
||||
:class="[buttonMenuItemClasses, 'flex-1']"
|
||||
@click="handleClick"
|
||||
@keydown="handleKeydown"
|
||||
@focus="handleFocus"
|
||||
>
|
||||
<RadioButtonCheckedIcon
|
||||
v-if="props.option.selected === true"
|
||||
aria-hidden="true"
|
||||
class="!text-brand"
|
||||
/>
|
||||
<RadioButtonIcon
|
||||
v-else-if="props.option.selected === false"
|
||||
aria-hidden="true"
|
||||
class="!text-secondary"
|
||||
/>
|
||||
<slot />
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="props.option.trailingAction"
|
||||
ref="trailingElement"
|
||||
v-tooltip="props.option.trailingAction.label"
|
||||
type="button"
|
||||
:aria-label="props.option.trailingAction.label"
|
||||
:class="trailingActionClasses"
|
||||
:style="trailingActionStyle"
|
||||
tabindex="-1"
|
||||
@click="props.option.trailingAction.action($event)"
|
||||
@keydown="handleTrailingKeydown"
|
||||
>
|
||||
<component :is="props.option.trailingAction.icon" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,164 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { AnchoredTeleportSide } from '../../../../utils/use-anchored-teleport'
|
||||
import { buttonMenuPanelClasses } from './button-menu'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
panelId: string
|
||||
label: string
|
||||
panelStyle: CSSProperties
|
||||
side?: AnchoredTeleportSide
|
||||
anchorStyle?: CSSProperties
|
||||
origin?: string
|
||||
expand?: 'vertical' | 'horizontal'
|
||||
// covers the gap so hovering from the trigger doesn't count as leaving
|
||||
bridge?: { side: AnchoredTeleportSide; size: number }
|
||||
}>()
|
||||
|
||||
const element = ref<HTMLElement | null>(null)
|
||||
|
||||
const expandStyle = computed(() => {
|
||||
const origin = props.origin ?? 'top center'
|
||||
return {
|
||||
transformOrigin: origin,
|
||||
'--floating-expand-origin': origin,
|
||||
...(props.expand === 'horizontal'
|
||||
? {
|
||||
'--floating-expand-x': '0.3',
|
||||
'--floating-expand-y': '0.8',
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
})
|
||||
|
||||
defineExpose({ element })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="floating-expand">
|
||||
<div
|
||||
v-if="props.open"
|
||||
:id="props.panelId"
|
||||
ref="element"
|
||||
v-bind="$attrs"
|
||||
:class="buttonMenuPanelClasses"
|
||||
:style="[props.panelStyle, expandStyle]"
|
||||
role="menu"
|
||||
:aria-label="props.label"
|
||||
>
|
||||
<span
|
||||
v-if="props.side && props.anchorStyle"
|
||||
aria-hidden="true"
|
||||
class="button-menu-arrow"
|
||||
:data-side="props.side"
|
||||
:style="props.anchorStyle"
|
||||
/>
|
||||
<span
|
||||
v-if="props.bridge"
|
||||
aria-hidden="true"
|
||||
class="button-menu-bridge"
|
||||
:data-side="props.bridge.side"
|
||||
:style="{ '--button-menu-bridge-size': `${props.bridge.size}px` }"
|
||||
/>
|
||||
<div
|
||||
data-anchored-scroll-region
|
||||
class="flex min-w-48 flex-col p-2 overflow-y-auto"
|
||||
:style="{ maxHeight: props.panelStyle.maxHeight }"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.button-menu-arrow {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.button-menu-arrow::before {
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
content: '';
|
||||
background-color: var(--surface-3);
|
||||
transform: translate(-50%, -50%) rotate(45deg);
|
||||
}
|
||||
|
||||
.button-menu-arrow[data-side='bottom'] {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.button-menu-arrow[data-side='bottom']::before {
|
||||
border-top: 1px solid var(--surface-5);
|
||||
border-left: 1px solid var(--surface-5);
|
||||
border-radius: 0 0 99999px 0;
|
||||
}
|
||||
|
||||
.button-menu-arrow[data-side='top'] {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.button-menu-arrow[data-side='top']::before {
|
||||
border-right: 1px solid var(--surface-5);
|
||||
border-bottom: 1px solid var(--surface-5);
|
||||
}
|
||||
|
||||
.button-menu-arrow[data-side='right'] {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.button-menu-arrow[data-side='right']::before {
|
||||
border-bottom: 1px solid var(--surface-5);
|
||||
border-left: 1px solid var(--surface-5);
|
||||
}
|
||||
|
||||
.button-menu-arrow[data-side='left'] {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.button-menu-arrow[data-side='left']::before {
|
||||
border-top: 1px solid var(--surface-5);
|
||||
border-right: 1px solid var(--surface-5);
|
||||
}
|
||||
|
||||
.button-menu-bridge {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.button-menu-bridge[data-side='right'] {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: calc(-1 * var(--button-menu-bridge-size));
|
||||
width: var(--button-menu-bridge-size);
|
||||
}
|
||||
|
||||
.button-menu-bridge[data-side='left'] {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: calc(-1 * var(--button-menu-bridge-size));
|
||||
width: var(--button-menu-bridge-size);
|
||||
}
|
||||
|
||||
.button-menu-bridge[data-side='bottom'] {
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: calc(-1 * var(--button-menu-bridge-size));
|
||||
height: var(--button-menu-bridge-size);
|
||||
}
|
||||
|
||||
.button-menu-bridge[data-side='top'] {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: calc(-1 * var(--button-menu-bridge-size));
|
||||
height: var(--button-menu-bridge-size);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,188 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronRightIcon } from '@modrinth/assets'
|
||||
import { computed, ref, toRef, useId } from 'vue'
|
||||
|
||||
import { useAnchoredTeleport } from '../../../../utils/use-anchored-teleport'
|
||||
import type {
|
||||
ButtonMenuAction,
|
||||
ButtonMenuLink,
|
||||
ButtonMenuSubmenu,
|
||||
TeleportPlacement,
|
||||
} from '../types'
|
||||
import {
|
||||
buttonMenuItemClasses,
|
||||
getButtonMenuItemAttrs,
|
||||
isDivider,
|
||||
isHeading,
|
||||
menuItemSelector,
|
||||
menuPanelPadding,
|
||||
submenuGap,
|
||||
useButtonMenuNavigation,
|
||||
useHoverIntent,
|
||||
visibleOptions,
|
||||
} from './button-menu'
|
||||
import ButtonMenuItem from './ButtonMenuItem.vue'
|
||||
import ButtonMenuPanel from './ButtonMenuPanel.vue'
|
||||
|
||||
const HOVER_CLOSE_DELAY = 200
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
option: ButtonMenuSubmenu
|
||||
placement?: TeleportPlacement
|
||||
distance?: number
|
||||
}>(),
|
||||
{
|
||||
placement: 'right-start',
|
||||
distance: submenuGap,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [option: ButtonMenuAction | ButtonMenuLink]
|
||||
}>()
|
||||
|
||||
const triggerElement = ref<HTMLElement | null>(null)
|
||||
const panel = ref<InstanceType<typeof ButtonMenuPanel> | null>(null)
|
||||
const panelElement = computed(() => panel.value?.element ?? null)
|
||||
const resolvedPlacement = toRef(props, 'placement')
|
||||
const resolvedDistance = toRef(props, 'distance')
|
||||
const alignOffset = ref(-menuPanelPadding)
|
||||
const panelId = `button-menu-submenu-${useId()}`
|
||||
|
||||
const options = computed(() => visibleOptions(props.option.options))
|
||||
const triggerAttrs = computed(() => getButtonMenuItemAttrs(props.option))
|
||||
|
||||
const { isOpen, panelStyle, resolvedSide, expandOrigin, open, close } = useAnchoredTeleport(
|
||||
triggerElement,
|
||||
panelElement,
|
||||
resolvedPlacement,
|
||||
resolvedDistance,
|
||||
alignOffset,
|
||||
)
|
||||
|
||||
const bridge = computed(() => ({ side: resolvedSide.value, size: resolvedDistance.value }))
|
||||
|
||||
const { focusItem, handleNavigationKeydown } = useButtonMenuNavigation(
|
||||
panelElement,
|
||||
menuItemSelector,
|
||||
)
|
||||
|
||||
const { handleMouseEnter, handleMouseLeave, cancelLeave } = useHoverIntent({
|
||||
closeDelay: HOVER_CLOSE_DELAY,
|
||||
onEnter: () => openSubmenu(false),
|
||||
onLeave: () => closeSubmenu(),
|
||||
})
|
||||
|
||||
async function openSubmenu(focus = true) {
|
||||
cancelLeave()
|
||||
if (props.option.disabled) return
|
||||
if (!isOpen.value) await open()
|
||||
if (focus) focusItem(0)
|
||||
}
|
||||
|
||||
function closeSubmenu(restoreFocus = false) {
|
||||
cancelLeave()
|
||||
close(restoreFocus)
|
||||
}
|
||||
|
||||
function handleTriggerClick(event: MouseEvent) {
|
||||
if (event.detail && window.matchMedia('(hover: hover)').matches) return
|
||||
toggleSubmenu()
|
||||
}
|
||||
|
||||
function toggleSubmenu() {
|
||||
if (isOpen.value) closeSubmenu()
|
||||
else openSubmenu()
|
||||
}
|
||||
|
||||
function handleTriggerKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'ArrowRight') {
|
||||
event.preventDefault()
|
||||
openSubmenu()
|
||||
return
|
||||
}
|
||||
if (event.key === 'ArrowLeft' && isOpen.value) {
|
||||
event.preventDefault()
|
||||
closeSubmenu(true)
|
||||
return
|
||||
}
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return
|
||||
event.preventDefault()
|
||||
toggleSubmenu()
|
||||
}
|
||||
|
||||
function handleSelect(option: ButtonMenuAction | ButtonMenuLink) {
|
||||
emit('select', option)
|
||||
if (!option.remainOpen) closeSubmenu()
|
||||
}
|
||||
|
||||
function handlePanelKeydown(event: KeyboardEvent) {
|
||||
if (handleNavigationKeydown(event)) {
|
||||
event.stopPropagation() // parent menu is listening too
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key !== 'Escape' && event.key !== 'ArrowLeft') return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
closeSubmenu(true)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
ref="triggerElement"
|
||||
v-tooltip="props.option.tooltip"
|
||||
v-bind="triggerAttrs"
|
||||
type="button"
|
||||
:aria-disabled="props.option.disabled || undefined"
|
||||
:aria-expanded="isOpen"
|
||||
:aria-controls="panelId"
|
||||
aria-haspopup="menu"
|
||||
:class="buttonMenuItemClasses"
|
||||
@click="handleTriggerClick"
|
||||
@keydown="handleTriggerKeydown"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
>
|
||||
<slot name="trigger" :option="props.option">
|
||||
<component :is="props.option.icon" v-if="props.option.icon" aria-hidden="true" />
|
||||
{{ props.option.label }}
|
||||
</slot>
|
||||
<ChevronRightIcon aria-hidden="true" class="ml-auto !text-secondary" />
|
||||
</button>
|
||||
|
||||
<ButtonMenuPanel
|
||||
ref="panel"
|
||||
:open="isOpen"
|
||||
:panel-id="panelId"
|
||||
:label="props.option.label"
|
||||
:panel-style="panelStyle"
|
||||
:side="resolvedSide"
|
||||
:origin="expandOrigin"
|
||||
expand="horizontal"
|
||||
:bridge="bridge"
|
||||
@keydown="handlePanelKeydown"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
>
|
||||
<template v-for="(child, index) in options" :key="child.id ?? `${child.type}-${index}`">
|
||||
<div v-if="isDivider(child)" role="separator" class="my-1 h-px bg-surface-5" />
|
||||
|
||||
<div
|
||||
v-else-if="isHeading(child)"
|
||||
class="px-3 pb-1 pt-2 text-xs font-bold uppercase tracking-wide text-secondary first:pt-1"
|
||||
>
|
||||
{{ child.label }}
|
||||
</div>
|
||||
|
||||
<ButtonMenuItem v-else :option="child" submenu-item @select="handleSelect">
|
||||
<slot name="item" :option="child">
|
||||
<component :is="child.icon" v-if="child.icon" aria-hidden="true" />
|
||||
{{ child.label }}
|
||||
</slot>
|
||||
</ButtonMenuItem>
|
||||
</template>
|
||||
</ButtonMenuPanel>
|
||||
</template>
|
||||
@@ -0,0 +1,239 @@
|
||||
import type { CSSProperties, Ref } from 'vue'
|
||||
import { onUnmounted, ref } from 'vue'
|
||||
|
||||
import type {
|
||||
ButtonColor,
|
||||
ButtonMenuAction,
|
||||
ButtonMenuDivider,
|
||||
ButtonMenuHeading,
|
||||
ButtonMenuLink,
|
||||
ButtonMenuOption,
|
||||
ButtonMenuSubmenu,
|
||||
} from '../types'
|
||||
|
||||
export const buttonMenuItemClasses =
|
||||
'button-menu-item flex min-h-10 z-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'
|
||||
|
||||
export const buttonMenuPanelClasses =
|
||||
'fixed isolate z-[9999] rounded-[14px] bg-surface-3 shadow-lg ring-1 ring-surface-5 select-none'
|
||||
|
||||
export const menuPanelPadding = 8
|
||||
export const submenuGap = 2
|
||||
export const menuItemSelector = '[role="menuitem"]'
|
||||
// submenu items render inside this panel, so skip them when moving focus
|
||||
export const topLevelMenuItemSelector = `${menuItemSelector}:not([data-button-menu-submenu-item])`
|
||||
|
||||
const TYPEAHEAD_RESET_DELAY = 500
|
||||
|
||||
export const buttonMenuTones: 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))',
|
||||
}
|
||||
|
||||
export function isDivider(option: ButtonMenuOption): option is ButtonMenuDivider {
|
||||
return option.type === 'divider'
|
||||
}
|
||||
|
||||
export function isHeading(option: ButtonMenuOption): option is ButtonMenuHeading {
|
||||
return option.type === 'heading'
|
||||
}
|
||||
|
||||
export function isLink(option: ButtonMenuOption): option is ButtonMenuLink {
|
||||
return option.type === 'link'
|
||||
}
|
||||
|
||||
export function isSubmenu(option: ButtonMenuOption): option is ButtonMenuSubmenu {
|
||||
return option.type === 'submenu'
|
||||
}
|
||||
|
||||
export function isMenuRow(
|
||||
option: ButtonMenuOption,
|
||||
): option is ButtonMenuAction | ButtonMenuLink | ButtonMenuSubmenu {
|
||||
return option.type !== 'divider' && option.type !== 'heading'
|
||||
}
|
||||
|
||||
export function visibleOptions<T extends { shown?: boolean }>(options: T[]): T[] {
|
||||
return options.filter((option) => option.shown !== false)
|
||||
}
|
||||
|
||||
export function getButtonMenuItemAttrs(
|
||||
option: ButtonMenuAction | ButtonMenuLink | ButtonMenuSubmenu,
|
||||
) {
|
||||
const tone = option.tone && option.tone !== 'default' ? option.tone : undefined
|
||||
|
||||
return {
|
||||
role: 'menuitem',
|
||||
tabindex: '-1',
|
||||
style: tone
|
||||
? ({ '--button-menu-item-tone': buttonMenuTones[tone] } as CSSProperties)
|
||||
: undefined,
|
||||
'data-tone': tone,
|
||||
'data-hover-filled': option.hoverFilled || option.hoverFilledOnly || undefined,
|
||||
'data-hover-filled-only': option.hoverFilledOnly || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function useButtonMenuNavigation(
|
||||
panel: Readonly<Ref<HTMLElement | null>>,
|
||||
itemSelector: string,
|
||||
) {
|
||||
const focusedIndex = ref(-1)
|
||||
|
||||
function getItems() {
|
||||
if (!panel.value) return []
|
||||
return Array.from(panel.value.querySelectorAll<HTMLElement>(itemSelector))
|
||||
}
|
||||
|
||||
function focusItem(index: number) {
|
||||
const items = getItems()
|
||||
if (items.length === 0) return
|
||||
focusedIndex.value = (index + items.length) % items.length
|
||||
items[focusedIndex.value]?.focus()
|
||||
}
|
||||
|
||||
function handleNavigationKeydown(event: KeyboardEvent) {
|
||||
const items = getItems()
|
||||
if (items.length === 0) return false
|
||||
|
||||
const activeIndex = items.indexOf(document.activeElement as HTMLElement)
|
||||
const currentIndex = activeIndex === -1 ? focusedIndex.value : activeIndex
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
focusItem(currentIndex + 1)
|
||||
break
|
||||
case 'ArrowUp':
|
||||
focusItem(currentIndex === -1 ? items.length - 1 : currentIndex - 1)
|
||||
break
|
||||
case 'Home':
|
||||
focusItem(0)
|
||||
break
|
||||
case 'End':
|
||||
focusItem(items.length - 1)
|
||||
break
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
|
||||
return { focusedIndex, getItems, focusItem, handleNavigationKeydown }
|
||||
}
|
||||
|
||||
export function useMenuKeyboard(options: {
|
||||
panel: Readonly<Ref<HTMLElement | null>>
|
||||
rows: () => { label: string }[]
|
||||
onEscape: () => void
|
||||
onTab?: () => void
|
||||
}) {
|
||||
const navigation = useButtonMenuNavigation(options.panel, topLevelMenuItemSelector)
|
||||
const typeahead = ref('')
|
||||
let typeaheadTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
function clearTypeahead() {
|
||||
if (typeaheadTimer) clearTimeout(typeaheadTimer)
|
||||
typeaheadTimer = undefined
|
||||
typeahead.value = ''
|
||||
}
|
||||
|
||||
function focusTypeaheadMatch(event: KeyboardEvent) {
|
||||
if (
|
||||
event.key === ' ' ||
|
||||
event.key.length !== 1 ||
|
||||
event.ctrlKey ||
|
||||
event.metaKey ||
|
||||
event.altKey
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const character = event.key.toLocaleLowerCase()
|
||||
// repeating a letter cycles matches instead of appending
|
||||
const query = typeahead.value === character ? character : `${typeahead.value}${character}`
|
||||
const startIndex = query.length === 1 ? navigation.focusedIndex.value + 1 : 0
|
||||
const rows = options.rows()
|
||||
typeahead.value = query
|
||||
|
||||
for (let offset = 0; offset < rows.length; offset++) {
|
||||
const index = (startIndex + offset) % rows.length
|
||||
if (rows[index]?.label.toLocaleLowerCase().startsWith(query)) {
|
||||
navigation.focusItem(index)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (typeaheadTimer) clearTimeout(typeaheadTimer)
|
||||
typeaheadTimer = setTimeout(clearTypeahead, TYPEAHEAD_RESET_DELAY)
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (navigation.handleNavigationKeydown(event)) return
|
||||
|
||||
switch (event.key) {
|
||||
case 'Escape':
|
||||
event.preventDefault()
|
||||
options.onEscape()
|
||||
break
|
||||
case 'Tab':
|
||||
options.onTab?.()
|
||||
break
|
||||
default:
|
||||
focusTypeaheadMatch(event)
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
navigation.focusedIndex.value = -1
|
||||
clearTypeahead()
|
||||
}
|
||||
|
||||
onUnmounted(clearTypeahead)
|
||||
|
||||
return { ...navigation, handleKeydown, reset }
|
||||
}
|
||||
|
||||
export function useHoverIntent(options: {
|
||||
closeDelay: number
|
||||
enabled?: () => boolean
|
||||
onEnter: () => void
|
||||
onLeave: () => void
|
||||
}) {
|
||||
let leaveTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
function cancelLeave() {
|
||||
if (leaveTimer === undefined) return
|
||||
clearTimeout(leaveTimer)
|
||||
leaveTimer = undefined
|
||||
}
|
||||
|
||||
function isIgnored() {
|
||||
// touch reports hover on tap, which would open menus you meant to scroll past
|
||||
return options.enabled?.() === false || !window.matchMedia('(hover: hover)').matches
|
||||
}
|
||||
|
||||
function handleMouseEnter() {
|
||||
if (isIgnored()) return
|
||||
cancelLeave()
|
||||
options.onEnter()
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
if (isIgnored()) return
|
||||
cancelLeave()
|
||||
leaveTimer = setTimeout(options.onLeave, options.closeDelay)
|
||||
}
|
||||
|
||||
onUnmounted(cancelLeave)
|
||||
|
||||
return { handleMouseEnter, handleMouseLeave, cancelLeave }
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export { default as Button } from './Button.vue'
|
||||
export { default as ButtonGroup } from './ButtonGroup.vue'
|
||||
export { default as ButtonLink } from './ButtonLink.vue'
|
||||
export { default as ContextMenu } from './ContextMenu.vue'
|
||||
export { default as FileButton } from './FileButton.vue'
|
||||
export { default as IconButton } from './IconButton.vue'
|
||||
export { default as SplitButton } from './SplitButton.vue'
|
||||
@@ -11,13 +12,16 @@ export type {
|
||||
ButtonElementHandle,
|
||||
ButtonInteraction,
|
||||
ButtonLinkDestination,
|
||||
ButtonMenuAction,
|
||||
ButtonMenuDivider,
|
||||
ButtonMenuHeading,
|
||||
ButtonMenuLeafOption,
|
||||
ButtonMenuLink,
|
||||
ButtonMenuOption,
|
||||
ButtonMenuSubmenu,
|
||||
ButtonNativeType,
|
||||
ButtonSize,
|
||||
ButtonType,
|
||||
ButtonVisualProps,
|
||||
OverflowMenuAction,
|
||||
OverflowMenuDivider,
|
||||
OverflowMenuLink,
|
||||
OverflowMenuOption,
|
||||
TeleportPlacement,
|
||||
} from './types'
|
||||
|
||||
@@ -69,7 +69,7 @@ export type ButtonLinkDestination =
|
||||
|
||||
export type TeleportPlacement = AnchoredTeleportPlacement
|
||||
|
||||
export interface OverflowMenuItemBase {
|
||||
export interface ButtonMenuItemBase {
|
||||
id: string
|
||||
label: string
|
||||
icon?: Component
|
||||
@@ -80,14 +80,21 @@ export interface OverflowMenuItemBase {
|
||||
tone?: 'default' | ButtonColor
|
||||
hoverFilled?: boolean
|
||||
hoverFilledOnly?: boolean
|
||||
selected?: boolean
|
||||
trailingAction?: {
|
||||
label: string
|
||||
icon: Component
|
||||
color?: ButtonColor
|
||||
action: (event: MouseEvent) => void
|
||||
}
|
||||
}
|
||||
|
||||
export interface OverflowMenuAction extends OverflowMenuItemBase {
|
||||
export interface ButtonMenuAction extends ButtonMenuItemBase {
|
||||
type?: 'action'
|
||||
action: (event: MouseEvent) => void
|
||||
}
|
||||
|
||||
export interface OverflowMenuLink extends OverflowMenuItemBase {
|
||||
export interface ButtonMenuLink extends ButtonMenuItemBase {
|
||||
type: 'link'
|
||||
to?: RouteLocationRaw
|
||||
href?: string
|
||||
@@ -96,13 +103,31 @@ export interface OverflowMenuLink extends OverflowMenuItemBase {
|
||||
download?: string | boolean
|
||||
}
|
||||
|
||||
export interface OverflowMenuDivider {
|
||||
export interface ButtonMenuDivider {
|
||||
type: 'divider'
|
||||
id?: string
|
||||
shown?: boolean
|
||||
}
|
||||
|
||||
export type OverflowMenuOption = OverflowMenuAction | OverflowMenuLink | OverflowMenuDivider
|
||||
export interface ButtonMenuHeading {
|
||||
type: 'heading'
|
||||
id?: string
|
||||
label: string
|
||||
shown?: boolean
|
||||
}
|
||||
|
||||
export type ButtonMenuLeafOption =
|
||||
| ButtonMenuAction
|
||||
| ButtonMenuLink
|
||||
| ButtonMenuDivider
|
||||
| ButtonMenuHeading
|
||||
|
||||
export interface ButtonMenuSubmenu extends ButtonMenuItemBase {
|
||||
type: 'submenu'
|
||||
options: ButtonMenuLeafOption[]
|
||||
}
|
||||
|
||||
export type ButtonMenuOption = ButtonMenuLeafOption | ButtonMenuSubmenu
|
||||
|
||||
export interface ButtonElementHandle {
|
||||
element: HTMLElement | null
|
||||
|
||||
@@ -13,6 +13,7 @@ export { default as BulletDivider } from './BulletDivider.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 ContextMenu } from './buttons/ContextMenu.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'
|
||||
@@ -21,14 +22,17 @@ export { default as TeleportPopoutMenu } from './buttons/TeleportPopoutMenu.vue'
|
||||
export type {
|
||||
ButtonColor,
|
||||
ButtonInteraction,
|
||||
ButtonMenuAction,
|
||||
ButtonMenuDivider,
|
||||
ButtonMenuHeading,
|
||||
ButtonMenuLeafOption,
|
||||
ButtonMenuLink,
|
||||
ButtonMenuOption,
|
||||
ButtonMenuSubmenu,
|
||||
ButtonNativeType,
|
||||
ButtonSize,
|
||||
ButtonType,
|
||||
ButtonVisualProps,
|
||||
OverflowMenuAction,
|
||||
OverflowMenuDivider,
|
||||
OverflowMenuLink,
|
||||
OverflowMenuOption,
|
||||
TeleportPlacement,
|
||||
} from './buttons/types'
|
||||
export { default as Card } from './Card.vue'
|
||||
|
||||
@@ -19,7 +19,7 @@ import { useMutation, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, ref, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import { Collapsible, ConfirmModal } from '#ui/components'
|
||||
import type { OverflowMenuOption } from '#ui/components/base'
|
||||
import type { ButtonMenuOption } from '#ui/components/base'
|
||||
import { Button, IconButton, TeleportOverflowMenu } from '#ui/components/base/buttons'
|
||||
import { commonMessages } from '#ui/utils'
|
||||
|
||||
@@ -485,7 +485,7 @@ async function handleQuickReply(reply: QuickReply) {
|
||||
reviewReasonInput.value = message
|
||||
}
|
||||
|
||||
const visibleQuickReplies = computed<OverflowMenuOption[]>(() => {
|
||||
const visibleQuickReplies = computed<ButtonMenuOption[]>(() => {
|
||||
const replies = attributionModeration?.attributionQuickReplies
|
||||
|
||||
if (!replies) return []
|
||||
@@ -501,7 +501,7 @@ const visibleQuickReplies = computed<OverflowMenuOption[]>(() => {
|
||||
id: reply.label,
|
||||
label: reply.label,
|
||||
action: () => handleQuickReply(reply),
|
||||
}) as OverflowMenuOption,
|
||||
}) as ButtonMenuOption,
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
+6
-1
@@ -43,7 +43,12 @@
|
||||
<!-- Instance-specific: Icon upload -->
|
||||
<div v-if="ctx.flowType === 'instance'" class="flex items-center gap-2.5">
|
||||
<div class="group relative size-[7.75rem] shrink-0">
|
||||
<Avatar :src="ctx.instanceIconUrl.value ?? undefined" size="100%" no-shadow />
|
||||
<Avatar
|
||||
:src="ctx.instanceIconUrl.value ?? undefined"
|
||||
size="100%"
|
||||
no-shadow
|
||||
pad-transparent-corners
|
||||
/>
|
||||
<div
|
||||
v-if="ctx.instanceIconUrl.value"
|
||||
class="pointer-events-none absolute right-1.5 top-1.5 opacity-0 transition-opacity group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100"
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
:server-region="project.minecraft_server?.region"
|
||||
:server-status-online="!!project.minecraft_java_server?.ping?.data"
|
||||
:server-modpack-content="getServerModpackContent(project, openModpackProject)"
|
||||
:status="showStatus ? project.status : undefined"
|
||||
:status="statusFor(project)"
|
||||
:max-tags="2"
|
||||
:layout="cardLayout"
|
||||
is-server-project
|
||||
@@ -38,7 +38,7 @@
|
||||
:color="project.color"
|
||||
:environment="project.environment?.[0]"
|
||||
:layout="cardLayout"
|
||||
:status="showStatus ? project.status : undefined"
|
||||
:status="statusFor(project)"
|
||||
>
|
||||
<template v-if="$slots.actions" #actions>
|
||||
<slot name="actions" :project="project" />
|
||||
@@ -71,7 +71,7 @@ const props = withDefaults(
|
||||
projects: Labrinth.Projects.v3.Project[]
|
||||
layout?: 'list' | 'grid' | 'gallery'
|
||||
linkMode?: ProjectLinkMode
|
||||
showStatus?: boolean
|
||||
showStatus?: boolean | ((project: Labrinth.Projects.v3.Project) => boolean)
|
||||
}>(),
|
||||
{
|
||||
layout: 'list',
|
||||
@@ -87,6 +87,12 @@ defineSlots<{
|
||||
const router = useRouter()
|
||||
const cardLayout = computed(() => (props.layout === 'list' ? 'list' : 'grid'))
|
||||
|
||||
function statusFor(project: Labrinth.Projects.v3.Project) {
|
||||
const visible =
|
||||
typeof props.showStatus === 'function' ? props.showStatus(project) : !!props.showStatus
|
||||
return visible ? project.status : undefined
|
||||
}
|
||||
|
||||
function openModpackProject(projectId: string): void {
|
||||
const path = `/project/${projectId}`
|
||||
if (props.linkMode === 'app') {
|
||||
|
||||
@@ -221,8 +221,10 @@
|
||||
<TagItem
|
||||
v-for="(tag, tagIdx) in getEnvironmentTags(version.environment)"
|
||||
:key="`env-tag-${tagIdx}`"
|
||||
v-tooltip="getFilterTooltip(formatMessage(tag.label))"
|
||||
data-no-row-click
|
||||
class="w-fit max-w-full truncate text-center"
|
||||
:action="() => toggleEnvironmentFilter(version.environment)"
|
||||
>
|
||||
<component :is="tag.icon" />
|
||||
<span class="min-w-0 truncate">{{ formatMessage(tag.label).replace('and', '&') }}</span>
|
||||
@@ -406,7 +408,9 @@
|
||||
<TagItem
|
||||
v-for="(tag, tagIdx) in getEnvironmentTags(version.environment)"
|
||||
:key="`env-tag-${tagIdx}`"
|
||||
class="text-center"
|
||||
v-tooltip="getFilterTooltip(formatMessage(tag.label))"
|
||||
class="text-center smart-clickable:allow-pointer-events"
|
||||
:action="() => toggleEnvironmentFilter(version.environment)"
|
||||
>
|
||||
<component :is="tag.icon" />
|
||||
{{ formatMessage(tag.label).replace('and', '&') }}
|
||||
@@ -485,7 +489,7 @@ import { Button } from '#ui/components/base/buttons'
|
||||
import { useRelativeTime } from '../../composables'
|
||||
import { defineMessages, useVIntl } from '../../composables/i18n'
|
||||
import { formatTag } from '../../utils/tag-messages'
|
||||
import { getEnvironmentTags } from './settings/environment/environments'
|
||||
import { getEnvironmentFilterValue, getEnvironmentTags } from './settings/environment/environments'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const formatRelativeTime = useRelativeTime({ style: 'narrow' })
|
||||
@@ -653,6 +657,13 @@ function getPlatformTooltip(platform: string): string {
|
||||
return getFilterTooltip(formatTag(formatMessage, platform, 'loader'))
|
||||
}
|
||||
|
||||
function toggleEnvironmentFilter(environment?: Labrinth.Projects.v3.Environment) {
|
||||
const value = getEnvironmentFilterValue(environment)
|
||||
if (value) {
|
||||
versionFilters.value?.toggleFilter('environment', value)
|
||||
}
|
||||
}
|
||||
|
||||
function isFileRowVisible(version: VersionTableRow): boolean {
|
||||
return props.showFiles && Array.isArray(version.files) && version.files.length > 0
|
||||
}
|
||||
@@ -683,13 +694,20 @@ const selectedPlatforms: Ref<string[]> = computed(
|
||||
() => versionFilters.value?.selectedPlatforms ?? [],
|
||||
)
|
||||
const selectedChannels: Ref<string[]> = computed(() => versionFilters.value?.selectedChannels ?? [])
|
||||
const selectedEnvironments: Ref<string[]> = computed(
|
||||
() => versionFilters.value?.selectedEnvironments ?? [],
|
||||
)
|
||||
|
||||
const filteredVersions = computed(() => {
|
||||
return normalizedVersions.value.filter(
|
||||
(version) =>
|
||||
hasAnySelected(version.game_versions, selectedGameVersions.value) &&
|
||||
hasAnySelected(version.loaders, selectedPlatforms.value) &&
|
||||
isAnySelected(version.version_type, selectedChannels.value),
|
||||
isAnySelected(version.version_type, selectedChannels.value) &&
|
||||
isAnySelected(
|
||||
getEnvironmentFilterValue(version.environment) ?? '',
|
||||
selectedEnvironments.value,
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -210,3 +210,34 @@ export function getEnvironmentTags(
|
||||
return [{ label: ENVIRONMENT_TAG_LABELS.notApplicable, icon: null }]
|
||||
}
|
||||
}
|
||||
|
||||
export type EnvironmentFilterValue = 'client' | 'server' | 'client_and_server' | 'singleplayer'
|
||||
|
||||
export const ENVIRONMENT_FILTER_VALUES: EnvironmentFilterValue[] = [
|
||||
'client',
|
||||
'server',
|
||||
'client_and_server',
|
||||
'singleplayer',
|
||||
]
|
||||
|
||||
export function getEnvironmentFilterValue(
|
||||
environment?: Labrinth.Projects.v3.Environment | null,
|
||||
): EnvironmentFilterValue | undefined {
|
||||
switch (environment) {
|
||||
case 'client_only':
|
||||
return 'client'
|
||||
case 'server_only':
|
||||
case 'dedicated_server_only':
|
||||
return 'server'
|
||||
case 'client_and_server':
|
||||
case 'client_only_server_optional':
|
||||
case 'server_only_client_optional':
|
||||
case 'client_or_server':
|
||||
case 'client_or_server_prefers_both':
|
||||
return 'client_and_server'
|
||||
case 'singleplayer_only':
|
||||
return 'singleplayer'
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
export { default as EnvironmentMigration } from './environment/EnvironmentMigration.vue'
|
||||
export { ENVIRONMENTS_COPY } from './environment/environments'
|
||||
export {
|
||||
ENVIRONMENT_FILTER_VALUES,
|
||||
type EnvironmentFilterValue,
|
||||
ENVIRONMENTS_COPY,
|
||||
getEnvironmentFilterValue,
|
||||
} from './environment/environments'
|
||||
export { default as EnvironmentSelector } from './environment/EnvironmentSelector.vue'
|
||||
export { default as ProjectEnvironmentModal } from './environment/ProjectEnvironmentModal.vue'
|
||||
|
||||
@@ -287,8 +287,8 @@ import {
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import {
|
||||
type ButtonMenuOption,
|
||||
IconButton,
|
||||
type OverflowMenuOption,
|
||||
TeleportOverflowMenu,
|
||||
} from '#ui/components/base/buttons'
|
||||
|
||||
@@ -599,7 +599,7 @@ function handleRemoveMember(member: ServerAccessMember) {
|
||||
emit('removeMember', member)
|
||||
}
|
||||
|
||||
function memberActionOptions(member: ServerAccessMember): OverflowMenuOption[] {
|
||||
function memberActionOptions(member: ServerAccessMember): ButtonMenuOption[] {
|
||||
return [
|
||||
{
|
||||
id: 'resend-invite',
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from '@modrinth/assets'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { OverflowMenuOption } from '#ui/components/base/buttons'
|
||||
import type { ButtonMenuOption } from '#ui/components/base/buttons'
|
||||
import { Button, TeleportOverflowMenu } from '#ui/components/base/buttons'
|
||||
|
||||
import { useFormatDateTime } from '../../../composables'
|
||||
@@ -94,8 +94,8 @@ const itemBorderClass = computed(() => {
|
||||
return 'border-transparent'
|
||||
})
|
||||
|
||||
const overflowMenuOptions = computed<OverflowMenuOption[]>(() => {
|
||||
const options: OverflowMenuOption[] = []
|
||||
const overflowMenuOptions = computed<ButtonMenuOption[]>(() => {
|
||||
const options: ButtonMenuOption[] = []
|
||||
|
||||
if (props.showCopyIdAction) {
|
||||
options.push({
|
||||
|
||||
@@ -85,7 +85,7 @@ import {
|
||||
} from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { OverflowMenuOption } from '#ui/components/base/buttons'
|
||||
import type { ButtonMenuOption } from '#ui/components/base/buttons'
|
||||
import { Button, SplitButton } from '#ui/components/base/buttons'
|
||||
|
||||
import { useServerPowerAction } from './use-server-power-action'
|
||||
@@ -113,7 +113,7 @@ const {
|
||||
disabled: computed(() => props.disabled),
|
||||
})
|
||||
|
||||
const stopMenuOptions = computed<OverflowMenuOption[]>(() => [
|
||||
const stopMenuOptions = computed<ButtonMenuOption[]>(() => [
|
||||
{
|
||||
id: 'kill_server',
|
||||
label: 'Kill server',
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div class="flex w-full flex-col rounded-2xl border border-solid border-surface-5 bg-surface-4">
|
||||
<button
|
||||
v-for="account in accounts"
|
||||
:key="account.id"
|
||||
type="button"
|
||||
data-button
|
||||
:class="rowClass"
|
||||
@click="emit('select', account)"
|
||||
>
|
||||
<Avatar :src="account.avatarUrl" size="32px" circle />
|
||||
<span class="min-w-0 truncate font-medium text-contrast">{{ account.username }}</span>
|
||||
<UserRoleIcon :role="account.role" class="!size-5" />
|
||||
<RightArrowIcon aria-hidden="true" class="ml-auto h-5 w-5 shrink-0 text-secondary" />
|
||||
</button>
|
||||
<button v-if="addAccountLabel" type="button" data-button :class="rowClass" @click="emit('add')">
|
||||
<span
|
||||
class="flex size-8 shrink-0 items-center justify-center rounded-full bg-surface-5 text-secondary"
|
||||
>
|
||||
<PlusIcon aria-hidden="true" class="h-5 w-5" />
|
||||
</span>
|
||||
<span class="font-medium text-contrast">{{ addAccountLabel }}</span>
|
||||
<RightArrowIcon aria-hidden="true" class="ml-auto h-5 w-5 shrink-0 text-secondary" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { PlusIcon, RightArrowIcon } from '@modrinth/assets'
|
||||
|
||||
import Avatar from '../base/Avatar.vue'
|
||||
import UserRoleIcon from './UserRoleIcon.vue'
|
||||
|
||||
export type AccountChoice = {
|
||||
id: string
|
||||
username: string
|
||||
avatarUrl?: string | null
|
||||
role?: Labrinth.Users.v2.Role | null
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
accounts: AccountChoice[]
|
||||
addAccountLabel?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [account: AccountChoice]
|
||||
add: []
|
||||
}>()
|
||||
|
||||
const rowClass =
|
||||
'flex w-full !w-full items-center gap-2 border-0 border-t border-solid border-surface-5 bg-surface-4 px-4 py-2 text-left transition-colors first:rounded-t-2xl first:border-t-0 last:rounded-b-2xl hover:bg-surface-5'
|
||||
</script>
|
||||
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="show"
|
||||
class="account-switch-overlay fixed inset-0 z-[10000] flex items-center justify-center backdrop-blur"
|
||||
role="status"
|
||||
>
|
||||
<span
|
||||
class="flex cursor-default select-none items-center gap-4 text-xl font-semibold text-contrast"
|
||||
>
|
||||
<RefreshCwIcon aria-hidden="true" class="h-6 w-6 animate-spin" />
|
||||
{{ formatMessage(messages.switchingAccounts) }}
|
||||
</span>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RefreshCwIcon } from '@modrinth/assets'
|
||||
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
|
||||
defineProps<{
|
||||
show: boolean
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
switchingAccounts: {
|
||||
id: 'layout.account-switcher.switching',
|
||||
defaultMessage: 'Switching accounts...',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.account-switch-overlay {
|
||||
background-color: color-mix(in srgb, var(--color-bg) 82%, transparent);
|
||||
}
|
||||
|
||||
.fade-enter-active {
|
||||
transition: 0.25s ease-in-out;
|
||||
}
|
||||
|
||||
.fade-enter-from {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div class="user-avatar relative inline-flex shrink-0" :style="{ '--_size': cssSize }">
|
||||
<div class="flex" :class="{ 'user-avatar-cutout': badge }">
|
||||
<Avatar
|
||||
:src="src"
|
||||
:alt="alt"
|
||||
:size="size"
|
||||
:loading="loading"
|
||||
:tint-by="tintBy"
|
||||
:no-shadow="noShadow"
|
||||
:raised="raised"
|
||||
:class="{ grayscale }"
|
||||
circle
|
||||
/>
|
||||
</div>
|
||||
<span v-if="badge" class="user-avatar-badge" aria-hidden="true">
|
||||
<slot>
|
||||
<span class="block size-full rounded-full bg-brand" />
|
||||
</slot>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Avatar from '../base/Avatar.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
src?: string | null
|
||||
alt?: string
|
||||
size?: string
|
||||
badge?: boolean
|
||||
grayscale?: boolean
|
||||
loading?: 'eager' | 'lazy'
|
||||
raised?: boolean
|
||||
tintBy?: string | null
|
||||
noShadow?: boolean
|
||||
}>(),
|
||||
{
|
||||
src: null,
|
||||
alt: '',
|
||||
size: '2rem',
|
||||
badge: false,
|
||||
grayscale: false,
|
||||
loading: 'eager',
|
||||
raised: false,
|
||||
tintBy: null,
|
||||
noShadow: true,
|
||||
},
|
||||
)
|
||||
|
||||
const LEGACY_PRESETS: Record<string, string> = {
|
||||
xxs: '1.25rem',
|
||||
xs: '2.5rem',
|
||||
sm: '3rem',
|
||||
md: '6rem',
|
||||
lg: '9rem',
|
||||
}
|
||||
|
||||
const cssSize = computed(() => LEGACY_PRESETS[props.size] ?? props.size)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.user-avatar {
|
||||
--user-avatar-badge-size: calc(var(--_size) * 11 / 32);
|
||||
--user-avatar-badge-gap: max(2px, calc(var(--_size) * 3 / 32));
|
||||
}
|
||||
|
||||
.user-avatar-cutout {
|
||||
--cutout: calc(var(--user-avatar-badge-size) / 2 + var(--user-avatar-badge-gap));
|
||||
|
||||
-webkit-mask-image: radial-gradient(
|
||||
circle at calc(100% - var(--user-avatar-badge-size) / 2)
|
||||
calc(100% - var(--user-avatar-badge-size) / 2),
|
||||
transparent var(--cutout),
|
||||
#000 calc(var(--cutout) + 0.5px)
|
||||
);
|
||||
mask-image: radial-gradient(
|
||||
circle at calc(100% - var(--user-avatar-badge-size) / 2)
|
||||
calc(100% - var(--user-avatar-badge-size) / 2),
|
||||
transparent var(--cutout),
|
||||
#000 calc(var(--cutout) + 0.5px)
|
||||
);
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
-webkit-mask-size: 100% 100%;
|
||||
mask-size: 100% 100%;
|
||||
}
|
||||
|
||||
.user-avatar-badge {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
width: var(--user-avatar-badge-size);
|
||||
height: var(--user-avatar-badge-size);
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -16,7 +16,7 @@
|
||||
:icon="BadgeCheckIcon"
|
||||
:icon-props="{ fill: 'var(--color-brand-highlight)' }"
|
||||
:tooltip="formatMessage(messages.officialAccount)"
|
||||
class="border-brand-highlight bg-brand-highlight text-brand"
|
||||
class="border-brand-highlight bg-brand-highlight !text-brand"
|
||||
>
|
||||
{{ formatMessage(messages.officialAccount) }}
|
||||
</PageHeaderBadgeItem>
|
||||
@@ -104,7 +104,7 @@ import {
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Avatar from '#ui/components/base/Avatar.vue'
|
||||
import type { OverflowMenuOption } from '#ui/components/base/buttons'
|
||||
import type { ButtonMenuOption } from '#ui/components/base/buttons'
|
||||
import { Button, ButtonLink, TeleportOverflowMenu } from '#ui/components/base/buttons'
|
||||
import PageHeader from '#ui/components/base/page-header/index.vue'
|
||||
import PageHeaderMetadata from '#ui/components/base/page-header/metadata/index.vue'
|
||||
@@ -234,7 +234,7 @@ const formatDateTime = useFormatDateTime({
|
||||
const downloadsTooltip = computed(() => formatNumber(props.downloads))
|
||||
const joinedTooltip = computed(() => formatDateTime(props.user.created))
|
||||
|
||||
const moreActions = computed<OverflowMenuOption[]>(() => [
|
||||
const moreActions = computed<ButtonMenuOption[]>(() => [
|
||||
{
|
||||
id: 'manage-projects',
|
||||
label: formatMessage(messages.profileManageProjectsButton),
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<ModrinthIcon
|
||||
v-if="role === 'admin'"
|
||||
v-tooltip="formatMessage(messages.modrinthTeamLabel)"
|
||||
role="img"
|
||||
:aria-label="formatMessage(messages.modrinthTeamLabel)"
|
||||
class="size-4 shrink-0 !text-green"
|
||||
/>
|
||||
<ScaleIcon
|
||||
v-else-if="role === 'moderator'"
|
||||
v-tooltip="formatMessage(messages.moderatorLabel)"
|
||||
role="img"
|
||||
:aria-label="formatMessage(messages.moderatorLabel)"
|
||||
class="size-4 shrink-0 !text-orange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { ModrinthIcon, ScaleIcon } from '@modrinth/assets'
|
||||
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
|
||||
defineProps<{
|
||||
role?: Labrinth.Users.v2.Role | null
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
modrinthTeamLabel: {
|
||||
id: 'user.role.modrinth-team',
|
||||
defaultMessage: 'Modrinth Team',
|
||||
},
|
||||
moderatorLabel: {
|
||||
id: 'user.role.moderator',
|
||||
defaultMessage: 'Content Moderator',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -1,2 +1,7 @@
|
||||
export type { AccountChoice } from './AccountChoiceList.vue'
|
||||
export { default as AccountChoiceList } from './AccountChoiceList.vue'
|
||||
export { default as AccountSwitchOverlay } from './AccountSwitchOverlay.vue'
|
||||
export { default as UserAvatar } from './UserAvatar.vue'
|
||||
export { default as UserBadges } from './UserBadges.vue'
|
||||
export { default as UserPageHeader } from './UserPageHeader.vue'
|
||||
export { default as UserRoleIcon } from './UserRoleIcon.vue'
|
||||
|
||||
@@ -1,134 +1,99 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<MultiSelect
|
||||
v-if="filterOptions.platform.length > 1"
|
||||
:model-value="selectedPlatforms"
|
||||
:options="platformOptions"
|
||||
fit-content
|
||||
:dropdown-min-width="180"
|
||||
trigger-type="base"
|
||||
@update:model-value="updateSelectedPlatforms"
|
||||
>
|
||||
<template #input-content="{ isOpen, openDirection }">
|
||||
<div class="flex items-center gap-2">
|
||||
<FilterIcon class="h-5 w-5 text-secondary" />
|
||||
<span class="font-semibold text-inherit">Platforms</span>
|
||||
<div
|
||||
v-if="visibleDropdowns.length > 0"
|
||||
class="flex min-w-0 flex-1 flex-wrap items-center gap-1.5"
|
||||
>
|
||||
<FilterIcon class="size-5 shrink-0 text-secondary" aria-hidden="true" />
|
||||
<MultiSelect
|
||||
v-for="dropdown in visibleDropdowns"
|
||||
:key="dropdown.key"
|
||||
class="min-w-0 max-w-full"
|
||||
:model-value="selectedFilters[dropdown.key]"
|
||||
:options="dropdown.options"
|
||||
:searchable="dropdown.searchable"
|
||||
:search-placeholder="dropdown.searchPlaceholder"
|
||||
:max-height="500"
|
||||
:clearable="false"
|
||||
:show-chevron="false"
|
||||
fit-content
|
||||
trigger-type="base"
|
||||
trigger-size="lg"
|
||||
:trigger-class="getDropdownTriggerClass(dropdown.key)"
|
||||
:dropdown-min-width="dropdown.dropdownMinWidth"
|
||||
checkbox-position="right"
|
||||
show-selection-actions
|
||||
@update:model-value="(values) => updateSelected(dropdown.key, values)"
|
||||
>
|
||||
<template #input-content="{ isOpen, openDirection }">
|
||||
<div class="flex min-h-8 min-w-0 max-w-full items-center gap-2 sm:max-w-80">
|
||||
<span class="min-w-0 flex-1 truncate">
|
||||
<template v-if="selectedFilters[dropdown.key].length > 0">
|
||||
<span class="font-medium">{{ dropdown.label }}:</span>
|
||||
<span class="ml-1 font-semibold text-contrast">{{
|
||||
getDropdownSummary(dropdown)
|
||||
}}</span>
|
||||
</template>
|
||||
<span v-else class="font-semibold text-inherit">{{ dropdown.label }}</span>
|
||||
</span>
|
||||
<div class="flex shrink-0 items-center gap-1.5">
|
||||
<button
|
||||
v-if="selectedFilters[dropdown.key].length > 0"
|
||||
type="button"
|
||||
class="flex cursor-pointer items-center justify-center rounded border-none bg-transparent p-0.5 text-secondary transition-colors hover:text-contrast"
|
||||
:aria-label="formatMessage(messages.clearNamedFilter, { name: dropdown.label })"
|
||||
@click.stop="clearCategory(dropdown.key)"
|
||||
>
|
||||
<XIcon class="size-4 text-primary" />
|
||||
</button>
|
||||
<div
|
||||
v-if="selectedFilters[dropdown.key].length > 0"
|
||||
class="h-5 w-[1px] shrink-0 bg-surface-5"
|
||||
></div>
|
||||
<ChevronLeftIcon
|
||||
class="h-5 w-5 text-secondary transition-transform duration-150"
|
||||
class="size-5 shrink-0 text-secondary transition-transform duration-150"
|
||||
:class="
|
||||
isOpen ? (openDirection === 'down' ? 'rotate-90' : '-rotate-90') : '-rotate-90'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</MultiSelect>
|
||||
<MultiSelect
|
||||
v-if="availableGameVersions.length > 1"
|
||||
:model-value="selectedGameVersions"
|
||||
:options="gameVersionOptions"
|
||||
searchable
|
||||
search-placeholder="Search..."
|
||||
fit-content
|
||||
:dropdown-min-width="240"
|
||||
trigger-type="base"
|
||||
@update:model-value="updateSelectedGameVersions"
|
||||
>
|
||||
<template #input-content="{ isOpen, openDirection }">
|
||||
<div class="flex items-center gap-2">
|
||||
<FilterIcon class="h-5 w-5 text-secondary" />
|
||||
<span class="font-semibold text-inherit">Game versions</span>
|
||||
<ChevronLeftIcon
|
||||
class="h-5 w-5 text-secondary transition-transform duration-150"
|
||||
:class="
|
||||
isOpen ? (openDirection === 'down' ? 'rotate-90' : '-rotate-90') : '-rotate-90'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="hasAnyNonReleaseGameVersions" #bottom>
|
||||
<div class="border-0 border-t border-solid border-t-surface-5 px-3 py-3">
|
||||
<Checkbox
|
||||
:model-value="showSnapshots"
|
||||
class="mx-1"
|
||||
:label="`Show all versions`"
|
||||
@update:model-value="updateShowSnapshots"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</MultiSelect>
|
||||
<MultiSelect
|
||||
v-if="filterOptions.channel.length > 1"
|
||||
:model-value="selectedChannels"
|
||||
:options="channelOptions"
|
||||
fit-content
|
||||
:dropdown-min-width="180"
|
||||
trigger-type="base"
|
||||
@update:model-value="updateSelectedChannels"
|
||||
>
|
||||
<template #input-content="{ isOpen, openDirection }">
|
||||
<div class="flex items-center gap-2">
|
||||
<FilterIcon class="h-5 w-5 text-secondary" />
|
||||
<span class="font-semibold text-inherit">Channels</span>
|
||||
<ChevronLeftIcon
|
||||
class="h-5 w-5 text-secondary transition-transform duration-150"
|
||||
:class="
|
||||
isOpen ? (openDirection === 'down' ? 'rotate-90' : '-rotate-90') : '-rotate-90'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</MultiSelect>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-1 empty:hidden">
|
||||
<TagItem
|
||||
v-if="selectedChannels.length + selectedGameVersions.length + selectedPlatforms.length > 1"
|
||||
class="transition-transform active:scale-[0.95]"
|
||||
:action="clearFilters"
|
||||
>
|
||||
<XCircleIcon />
|
||||
Clear all filters
|
||||
</TagItem>
|
||||
<TagItem
|
||||
v-for="channel in selectedChannels"
|
||||
:key="`remove-filter-${channel}`"
|
||||
:style="`--_color: var(--color-${channel === 'alpha' ? 'red' : channel === 'beta' ? 'orange' : 'green'});--_bg-color: var(--color-${channel === 'alpha' ? 'red' : channel === 'beta' ? 'orange' : 'green'}-highlight)`"
|
||||
:action="() => toggleFilter('channel', channel)"
|
||||
>
|
||||
<XIcon />
|
||||
{{ channel.slice(0, 1).toUpperCase() + channel.slice(1) }}
|
||||
</TagItem>
|
||||
<TagItem
|
||||
v-for="version in selectedGameVersions"
|
||||
:key="`remove-filter-${version}`"
|
||||
:action="() => toggleFilter('gameVersion', version)"
|
||||
>
|
||||
<XIcon />
|
||||
{{ version }}
|
||||
</TagItem>
|
||||
<TagItem
|
||||
v-for="platform in selectedPlatforms"
|
||||
:key="`remove-filter-${platform}`"
|
||||
:style="`--_color: var(--color-platform-${platform})`"
|
||||
:action="() => toggleFilter('platform', platform)"
|
||||
>
|
||||
<XIcon />
|
||||
<FormattedTag :tag="platform" enforce-type="loader" />
|
||||
</TagItem>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="dropdown.key === 'gameVersion' && hasAnyNonReleaseGameVersions" #bottom>
|
||||
<div class="border-0 border-t border-solid border-t-surface-5 px-3 py-3">
|
||||
<Checkbox
|
||||
:model-value="showSnapshots"
|
||||
:label="formatMessage(commonMessages.showAllVersionsButton)"
|
||||
@update:model-value="updateShowSnapshots"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</MultiSelect>
|
||||
<Button v-if="hasSelectedFilters" type="quiet" native-type="button" @click="clearAllFilters">
|
||||
{{ formatMessage(commonMessages.clearButton) }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { ChevronLeftIcon, FilterIcon, XCircleIcon, XIcon } from '@modrinth/assets'
|
||||
import type { MultiSelectOption } from '@modrinth/ui'
|
||||
import { Checkbox, formatLoader, FormattedTag, MultiSelect, TagItem, useVIntl } from '@modrinth/ui'
|
||||
import { ChevronLeftIcon, FilterIcon, XIcon } from '@modrinth/assets'
|
||||
import type { GameVersionTag } from '@modrinth/utils'
|
||||
import { computed, ref } from 'vue'
|
||||
import type { LocationQueryValue } from 'vue-router'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { Button } from '#ui/components/base/buttons'
|
||||
import Checkbox from '#ui/components/base/Checkbox.vue'
|
||||
import MultiSelect, { type MultiSelectOption } from '#ui/components/base/MultiSelect.vue'
|
||||
import {
|
||||
ENVIRONMENT_FILTER_VALUES,
|
||||
type EnvironmentFilterValue,
|
||||
getEnvironmentFilterValue,
|
||||
} from '#ui/components/project/settings/environment/environments'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages, commonProjectSettingsMessages } from '#ui/utils/common-messages'
|
||||
import { formatLoader } from '#ui/utils/tag-messages'
|
||||
|
||||
const props = defineProps<{
|
||||
versions: Labrinth.Versions.v3.Version[]
|
||||
gameVersions: GameVersionTag[]
|
||||
@@ -138,15 +103,75 @@ const props = defineProps<{
|
||||
const emit = defineEmits(['update:query'])
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const allChannels = ref(['release', 'beta', 'alpha'])
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const CHANNEL_ORDER = ['release', 'beta', 'alpha'] as const
|
||||
|
||||
const messages = defineMessages({
|
||||
channel: {
|
||||
id: 'project.versions.filter.channel',
|
||||
defaultMessage: 'Channel',
|
||||
},
|
||||
clientSideOnly: {
|
||||
id: 'project.settings.environment.client_only.title',
|
||||
defaultMessage: 'Client-side only',
|
||||
},
|
||||
serverSideOnly: {
|
||||
id: 'project.settings.environment.server_only.title',
|
||||
defaultMessage: 'Server-side only',
|
||||
},
|
||||
clientAndServer: {
|
||||
id: 'project.settings.environment.client_and_server.title',
|
||||
defaultMessage: 'Client and server',
|
||||
},
|
||||
singleplayerOnly: {
|
||||
id: 'project.settings.environment.singleplayer.title',
|
||||
defaultMessage: 'Singleplayer only',
|
||||
},
|
||||
clearNamedFilter: {
|
||||
id: 'filter-bar.clear-named-filter',
|
||||
defaultMessage: 'Clear {name} filter',
|
||||
},
|
||||
selectedCount: {
|
||||
id: 'project.versions.filter.selected-count',
|
||||
defaultMessage: '{count, number} selected',
|
||||
},
|
||||
})
|
||||
|
||||
const filterTriggerClass =
|
||||
'!h-[34px] !rounded-xl !border !border-solid !border-surface-5 !bg-transparent !px-3 !text-sm !font-medium !text-primary !shadow-[0_1px_1.5px_rgba(0,0,0,0.15)] transition-all duration-100 active:scale-[0.97] hover:!bg-surface-3 focus-visible:!outline-none focus-visible:!ring-4 focus-visible:!ring-brand-shadow [&>svg]:!size-5'
|
||||
const filterPreviewTriggerClass =
|
||||
'!h-[34px] !rounded-xl !border !border-solid !border-brand !bg-brand-highlight !px-3 !text-sm !font-medium !text-brand !shadow-[0_1px_1.5px_rgba(0,0,0,0.15)] transition-all duration-100 active:scale-[0.97] hover:!bg-brand-highlight focus-visible:!outline-none focus-visible:!ring-4 focus-visible:!ring-brand-shadow [&>svg]:!size-5 [&>svg]:!text-brand'
|
||||
|
||||
type FilterType = 'channel' | 'gameVersion' | 'platform' | 'environment'
|
||||
type Filter = string
|
||||
|
||||
type FilterDropdown = {
|
||||
key: FilterType
|
||||
label: string
|
||||
options: MultiSelectOption<string>[]
|
||||
searchable?: boolean
|
||||
searchPlaceholder?: string
|
||||
dropdownMinWidth: number
|
||||
}
|
||||
|
||||
const showSnapshots = ref(false)
|
||||
|
||||
type FilterType = 'channel' | 'gameVersion' | 'platform'
|
||||
type Filter = string
|
||||
const selectedFilters = ref<Record<FilterType, string[]>>({
|
||||
channel: route.query.c ? getArrayOrString(route.query.c) : [],
|
||||
gameVersion: route.query.g ? getArrayOrString(route.query.g) : [],
|
||||
platform: route.query.l ? getArrayOrString(route.query.l) : [],
|
||||
environment: route.query.e ? getArrayOrString(route.query.e) : [],
|
||||
})
|
||||
|
||||
const selectedChannels = computed(() => selectedFilters.value.channel)
|
||||
const selectedGameVersions = computed(() => selectedFilters.value.gameVersion)
|
||||
const selectedPlatforms = computed(() => selectedFilters.value.platform)
|
||||
const selectedEnvironments = computed(() => selectedFilters.value.environment)
|
||||
|
||||
const hasSelectedFilters = computed(() =>
|
||||
Object.values(selectedFilters.value).some((values) => values.length > 0),
|
||||
)
|
||||
|
||||
const gameVersionTags = computed(() => new Map(props.gameVersions.map((x) => [x.version, x])))
|
||||
|
||||
@@ -176,91 +201,162 @@ const hasAnyNonReleaseGameVersions = computed(() =>
|
||||
availableGameVersions.value.some((version) => !isReleaseGameVersion(version)),
|
||||
)
|
||||
|
||||
const filterOptions = computed(() => {
|
||||
const filters: Record<FilterType, Filter[]> = {
|
||||
channel: [],
|
||||
gameVersion: [],
|
||||
platform: [],
|
||||
const availableChannels = computed(() => {
|
||||
const channelSet = new Set<Filter>()
|
||||
for (const version of props.versions) {
|
||||
channelSet.add(version.version_type)
|
||||
}
|
||||
|
||||
const platformSet = new Set<Filter>()
|
||||
const channelSet = new Set<Filter>()
|
||||
const knownChannels = CHANNEL_ORDER.filter((channel) => channelSet.has(channel))
|
||||
const unknownChannels = [...channelSet].filter(
|
||||
(channel) => !(CHANNEL_ORDER as readonly string[]).includes(channel),
|
||||
)
|
||||
return [...knownChannels, ...unknownChannels]
|
||||
})
|
||||
|
||||
const availablePlatforms = computed(() => {
|
||||
const platformSet = new Set<Filter>()
|
||||
for (const version of props.versions) {
|
||||
for (const loader of Array.isArray(version.loaders) ? version.loaders : []) {
|
||||
platformSet.add(loader)
|
||||
}
|
||||
channelSet.add(version.version_type)
|
||||
}
|
||||
|
||||
if (channelSet.size > 0) {
|
||||
filters.channel = Array.from(channelSet) as Filter[]
|
||||
filters.channel.sort((a, b) => allChannels.value.indexOf(a) - allChannels.value.indexOf(b))
|
||||
}
|
||||
if (availableGameVersions.value.length > 0) {
|
||||
filters.gameVersion = availableGameVersions.value.filter((version) =>
|
||||
showSnapshots.value || !hasAnyReleaseGameVersions.value
|
||||
? true
|
||||
: isReleaseGameVersion(version),
|
||||
)
|
||||
}
|
||||
if (platformSet.size > 0) {
|
||||
filters.platform = Array.from(platformSet) as Filter[]
|
||||
}
|
||||
|
||||
return filters
|
||||
return Array.from(platformSet)
|
||||
})
|
||||
|
||||
const gameVersionOptions = computed<MultiSelectOption<string>[]>(() =>
|
||||
filterOptions.value.gameVersion.map((version) => ({
|
||||
value: version,
|
||||
label: version,
|
||||
})),
|
||||
const availableEnvironments = computed(() => {
|
||||
const environmentSet = new Set<EnvironmentFilterValue>()
|
||||
for (const version of props.versions) {
|
||||
const environment = getEnvironmentFilterValue(version.environment)
|
||||
if (environment) {
|
||||
environmentSet.add(environment)
|
||||
}
|
||||
}
|
||||
return ENVIRONMENT_FILTER_VALUES.filter((environment) => environmentSet.has(environment))
|
||||
})
|
||||
|
||||
const visibleGameVersions = computed(() =>
|
||||
availableGameVersions.value.filter(
|
||||
(version) =>
|
||||
showSnapshots.value || !hasAnyReleaseGameVersions.value || isReleaseGameVersion(version),
|
||||
),
|
||||
)
|
||||
|
||||
const channelOptions = computed<MultiSelectOption<string>[]>(() =>
|
||||
filterOptions.value.channel.map((channel) => ({
|
||||
value: channel,
|
||||
label: getChannelLabel(channel),
|
||||
})),
|
||||
)
|
||||
|
||||
const platformOptions = computed<MultiSelectOption<string>[]>(() =>
|
||||
filterOptions.value.platform.map((platform) => ({
|
||||
value: platform,
|
||||
label: formatLoader(formatMessage, platform),
|
||||
})),
|
||||
)
|
||||
|
||||
const selectedChannels = ref<string[]>([])
|
||||
const selectedGameVersions = ref<string[]>([])
|
||||
const selectedPlatforms = ref<string[]>([])
|
||||
|
||||
selectedChannels.value = route.query.c ? getArrayOrString(route.query.c) : []
|
||||
selectedGameVersions.value = route.query.g ? getArrayOrString(route.query.g) : []
|
||||
selectedPlatforms.value = route.query.l ? getArrayOrString(route.query.l) : []
|
||||
|
||||
if (selectedGameVersions.value.some((version) => !isReleaseGameVersion(version))) {
|
||||
showSnapshots.value = true
|
||||
}
|
||||
|
||||
function selectedFiltersOfType(type: FilterType) {
|
||||
if (type === 'channel') {
|
||||
return selectedChannels
|
||||
} else if (type === 'gameVersion') {
|
||||
return selectedGameVersions
|
||||
} else {
|
||||
return selectedPlatforms
|
||||
const visibleDropdowns = computed<FilterDropdown[]>(() => {
|
||||
const dropdowns: FilterDropdown[] = []
|
||||
|
||||
if (shouldShowCategory('channel', availableChannels.value)) {
|
||||
dropdowns.push({
|
||||
key: 'channel',
|
||||
label: formatMessage(messages.channel),
|
||||
dropdownMinWidth: 180,
|
||||
options: availableChannels.value.map((channel) => ({
|
||||
value: channel,
|
||||
label: getChannelLabel(channel),
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
if (shouldShowCategory('gameVersion', availableGameVersions.value)) {
|
||||
dropdowns.push({
|
||||
key: 'gameVersion',
|
||||
label: formatMessage(commonMessages.gameVersionLabel),
|
||||
searchable: true,
|
||||
searchPlaceholder: formatMessage(commonMessages.searchVersionPlaceholder),
|
||||
dropdownMinWidth: 240,
|
||||
options: visibleGameVersions.value.map((version) => ({
|
||||
value: version,
|
||||
label: version,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
if (shouldShowCategory('platform', availablePlatforms.value)) {
|
||||
dropdowns.push({
|
||||
key: 'platform',
|
||||
label: formatMessage(commonMessages.platformLabel),
|
||||
dropdownMinWidth: 180,
|
||||
options: availablePlatforms.value.map((platform) => ({
|
||||
value: platform,
|
||||
label: formatLoader(formatMessage, platform),
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
if (shouldShowCategory('environment', availableEnvironments.value)) {
|
||||
dropdowns.push({
|
||||
key: 'environment',
|
||||
label: formatMessage(commonProjectSettingsMessages.environment),
|
||||
dropdownMinWidth: 220,
|
||||
options: availableEnvironments.value.map((environment) => ({
|
||||
value: environment,
|
||||
label: getEnvironmentFilterLabel(environment),
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
return dropdowns
|
||||
})
|
||||
|
||||
function shouldShowCategory(type: FilterType, options: string[]) {
|
||||
return options.length > 1 || (selectedFilters.value[type]?.length ?? 0) > 0
|
||||
}
|
||||
|
||||
function getDropdownTriggerClass(type: FilterType) {
|
||||
return selectedFilters.value[type].length > 0 ? filterPreviewTriggerClass : filterTriggerClass
|
||||
}
|
||||
|
||||
function getDropdownSummary(dropdown: FilterDropdown) {
|
||||
const selected = selectedFilters.value[dropdown.key]
|
||||
if (selected.length === 0) {
|
||||
return ''
|
||||
}
|
||||
if (selected.length === 1) {
|
||||
return dropdown.options.find((option) => option.value === selected[0])?.label ?? selected[0]
|
||||
}
|
||||
return formatMessage(messages.selectedCount, { count: selected.length })
|
||||
}
|
||||
|
||||
function updateSelected(type: FilterType, values: string[]) {
|
||||
selectedFilters.value = {
|
||||
...selectedFilters.value,
|
||||
[type]: values,
|
||||
}
|
||||
updateFilters()
|
||||
}
|
||||
|
||||
function clearCategory(type: FilterType) {
|
||||
updateSelected(type, [])
|
||||
}
|
||||
|
||||
function clearAllFilters() {
|
||||
selectedFilters.value = {
|
||||
channel: [],
|
||||
gameVersion: [],
|
||||
platform: [],
|
||||
environment: [],
|
||||
}
|
||||
updateFilters()
|
||||
}
|
||||
|
||||
function selectedFiltersOfType(type: FilterType) {
|
||||
return selectedFilters.value[type] ?? []
|
||||
}
|
||||
|
||||
function toggleFilters(type: FilterType, filters: Filter[]) {
|
||||
const selected = selectedFiltersOfType(type)
|
||||
const allSelected = filters.every((filter) => selected.value.includes(filter))
|
||||
const allSelected = filters.every((filter) => selected.includes(filter))
|
||||
|
||||
selected.value = allSelected
|
||||
? selected.value.filter((x) => !filters.includes(x))
|
||||
: [...selected.value, ...filters.filter((filter) => !selected.value.includes(filter))]
|
||||
selectedFilters.value = {
|
||||
...selectedFilters.value,
|
||||
[type]: allSelected
|
||||
? selected.filter((x) => !filters.includes(x))
|
||||
: [...selected, ...filters.filter((filter) => !selected.includes(filter))],
|
||||
}
|
||||
|
||||
updateFilters()
|
||||
}
|
||||
@@ -268,29 +364,17 @@ function toggleFilters(type: FilterType, filters: Filter[]) {
|
||||
function toggleFilter(type: FilterType, filter: Filter) {
|
||||
const selected = selectedFiltersOfType(type)
|
||||
|
||||
selected.value = selected.value.includes(filter)
|
||||
? selected.value.filter((x) => x !== filter)
|
||||
: [...selected.value, filter]
|
||||
selectedFilters.value = {
|
||||
...selectedFilters.value,
|
||||
[type]: selected.includes(filter)
|
||||
? selected.filter((x) => x !== filter)
|
||||
: [...selected, filter],
|
||||
}
|
||||
|
||||
updateFilters()
|
||||
}
|
||||
|
||||
function updateSelectedGameVersions(versions: string[]) {
|
||||
selectedGameVersions.value = versions
|
||||
updateFilters()
|
||||
}
|
||||
|
||||
function updateSelectedChannels(channels: string[]) {
|
||||
selectedChannels.value = channels
|
||||
updateFilters()
|
||||
}
|
||||
|
||||
function updateSelectedPlatforms(platforms: string[]) {
|
||||
selectedPlatforms.value = platforms
|
||||
updateFilters()
|
||||
}
|
||||
|
||||
function updateShowSnapshots(value: boolean, _event?: MouseEvent) {
|
||||
function updateShowSnapshots(value: boolean) {
|
||||
showSnapshots.value = value
|
||||
|
||||
if (value || !hasAnyReleaseGameVersions.value) {
|
||||
@@ -302,24 +386,20 @@ function updateShowSnapshots(value: boolean, _event?: MouseEvent) {
|
||||
)
|
||||
|
||||
if (selectedReleaseGameVersions.length !== selectedGameVersions.value.length) {
|
||||
selectedGameVersions.value = selectedReleaseGameVersions
|
||||
selectedFilters.value = {
|
||||
...selectedFilters.value,
|
||||
gameVersion: selectedReleaseGameVersions,
|
||||
}
|
||||
updateFilters()
|
||||
}
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
selectedChannels.value = []
|
||||
selectedGameVersions.value = []
|
||||
selectedPlatforms.value = []
|
||||
|
||||
updateFilters()
|
||||
}
|
||||
|
||||
function updateFilters() {
|
||||
emit('update:query', {
|
||||
c: selectedChannels.value,
|
||||
g: selectedGameVersions.value,
|
||||
l: selectedPlatforms.value,
|
||||
e: selectedEnvironments.value,
|
||||
page: undefined,
|
||||
})
|
||||
}
|
||||
@@ -330,6 +410,7 @@ defineExpose({
|
||||
selectedChannels,
|
||||
selectedGameVersions,
|
||||
selectedPlatforms,
|
||||
selectedEnvironments,
|
||||
})
|
||||
|
||||
function getArrayOrString(x: LocationQueryValue | LocationQueryValue[]): string[] {
|
||||
@@ -344,7 +425,29 @@ function getArrayOrString(x: LocationQueryValue | LocationQueryValue[]): string[
|
||||
}
|
||||
|
||||
function getChannelLabel(channel: string) {
|
||||
return channel === 'release' ? 'Release' : channel === 'beta' ? 'Beta' : 'Alpha'
|
||||
if (channel === 'release') {
|
||||
return formatMessage(commonMessages.release)
|
||||
}
|
||||
if (channel === 'beta') {
|
||||
return formatMessage(commonMessages.beta)
|
||||
}
|
||||
if (channel === 'alpha') {
|
||||
return formatMessage(commonMessages.alpha)
|
||||
}
|
||||
return channel.slice(0, 1).toUpperCase() + channel.slice(1)
|
||||
}
|
||||
|
||||
function getEnvironmentFilterLabel(environment: EnvironmentFilterValue) {
|
||||
switch (environment) {
|
||||
case 'client':
|
||||
return formatMessage(messages.clientSideOnly)
|
||||
case 'server':
|
||||
return formatMessage(messages.serverSideOnly)
|
||||
case 'client_and_server':
|
||||
return formatMessage(messages.clientAndServer)
|
||||
case 'singleplayer':
|
||||
return formatMessage(messages.singleplayerOnly)
|
||||
}
|
||||
}
|
||||
|
||||
function isReleaseGameVersion(version: string) {
|
||||
|
||||
+1
@@ -25,6 +25,7 @@ export const Interactive: StoryObj = {
|
||||
v-model="currentTheme"
|
||||
:theme-options="themeOptions"
|
||||
system-theme-color="dark"
|
||||
preferred-dark-theme="oled"
|
||||
/>
|
||||
`,
|
||||
}),
|
||||
|
||||
+3
-2
@@ -5,11 +5,12 @@ import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const { ariaLabel, modelValue, themeOptions, systemThemeColor } = defineProps<{
|
||||
const { ariaLabel, modelValue, themeOptions, systemThemeColor, preferredDarkTheme } = defineProps<{
|
||||
ariaLabel: string
|
||||
modelValue: T
|
||||
themeOptions: readonly T[]
|
||||
systemThemeColor: T
|
||||
preferredDarkTheme: T
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -94,7 +95,7 @@ function getPreviewClass(option: T): string {
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<MoonIcon
|
||||
v-else-if="'dark' === option"
|
||||
v-else-if="option === preferredDarkTheme"
|
||||
v-tooltip="formatMessage(themeTooltips.preferredDark)"
|
||||
class="theme-icon shrink-0"
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -15,6 +15,7 @@ const theme = appearance.theme
|
||||
const currentTheme = theme.current
|
||||
const themeOptions = theme.options
|
||||
const systemTheme = theme.system
|
||||
const preferredDarkTheme = theme.preferredDark
|
||||
const syncAcrossDevices = theme.syncAcrossDevices.value
|
||||
const syncDisabled = theme.syncAcrossDevices.disabled
|
||||
const advancedRendering = appearance.advancedRendering.value
|
||||
@@ -46,6 +47,7 @@ const sidebarPreferenceValues = sidebarPreferences?.value
|
||||
:model-value="currentTheme"
|
||||
:theme-options="themeOptions"
|
||||
:system-theme-color="systemTheme"
|
||||
:preferred-dark-theme="preferredDarkTheme"
|
||||
@update:model-value="theme.update"
|
||||
/>
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ interface ThemeSettings {
|
||||
current: AppearanceRef<AppearanceThemeSelection>
|
||||
options: AppearanceRef<readonly AppearanceThemeSelection[]>
|
||||
system: AppearanceRef<AppearanceTheme>
|
||||
preferredDark: AppearanceRef<AppearanceTheme>
|
||||
update: AppearanceSetter<AppearanceThemeSelection>
|
||||
syncAcrossDevices: AppearanceSetting<boolean>
|
||||
}
|
||||
@@ -54,6 +55,7 @@ export interface AppearanceSettingsProviderOptions {
|
||||
current: AppearanceRef<AppearanceThemeSelection>
|
||||
options: AppearanceRef<readonly AppearanceThemeSelection[]>
|
||||
system: AppearanceRef<AppearanceTheme>
|
||||
preferredDark: AppearanceRef<AppearanceTheme>
|
||||
set: AppearanceSetter<AppearanceThemeSelection>
|
||||
syncAcrossDevices: WritableAppearanceSetting<boolean>
|
||||
syncDisabled: AppearanceRef<boolean>
|
||||
@@ -197,6 +199,7 @@ export function provideAppearanceSettings(
|
||||
current: options.theme.current,
|
||||
options: options.theme.options,
|
||||
system: options.theme.system,
|
||||
preferredDark: options.theme.preferredDark,
|
||||
update: updateTheme,
|
||||
syncAcrossDevices: {
|
||||
value: options.theme.syncAcrossDevices.value,
|
||||
|
||||
@@ -51,6 +51,8 @@ const iconSrc = computed(() => {
|
||||
return fetchedIcon.value ?? installContext.value?.iconSrc ?? null
|
||||
})
|
||||
|
||||
const isInstanceIcon = computed(() => !installContext.value?.serverId)
|
||||
|
||||
const metadataItems = computed(() => {
|
||||
const context = installContext.value
|
||||
if (!context) return []
|
||||
@@ -161,6 +163,7 @@ async function handleSelectedProjectsLeaveResult(
|
||||
:alt="installContext.name"
|
||||
size="48px"
|
||||
class="shrink-0"
|
||||
:pad-transparent-corners="isInstanceIcon"
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import type { RouteLocationRaw } from 'vue-router'
|
||||
import AutoLink from '#ui/components/base/AutoLink.vue'
|
||||
import Avatar from '#ui/components/base/Avatar.vue'
|
||||
import BulletDivider from '#ui/components/base/BulletDivider.vue'
|
||||
import type { OverflowMenuOption } from '#ui/components/base/buttons'
|
||||
import type { ButtonMenuOption } from '#ui/components/base/buttons'
|
||||
import { IconButton, TeleportOverflowMenu } from '#ui/components/base/buttons'
|
||||
import Checkbox from '#ui/components/base/Checkbox.vue'
|
||||
import ProgressSpinner from '#ui/components/base/ProgressSpinner.vue'
|
||||
@@ -67,7 +67,7 @@ interface Props {
|
||||
isClientOnly?: boolean
|
||||
clientWarning?: ClientWarningType | null
|
||||
hideSwitchVersion?: boolean
|
||||
overflowOptions?: OverflowMenuOption[]
|
||||
overflowOptions?: ButtonMenuOption[]
|
||||
disabled?: boolean
|
||||
disabledTooltip?: string | null
|
||||
toggleDisabled?: boolean
|
||||
|
||||
+2
-2
@@ -14,7 +14,7 @@ import { computed, nextTick, ref, watchSyncEffect } from 'vue'
|
||||
|
||||
import Avatar from '#ui/components/base/Avatar.vue'
|
||||
import BulletDivider from '#ui/components/base/BulletDivider.vue'
|
||||
import type { OverflowMenuOption } from '#ui/components/base/buttons'
|
||||
import type { ButtonMenuOption } from '#ui/components/base/buttons'
|
||||
import { ButtonLink } from '#ui/components/base/buttons'
|
||||
import Checkbox from '#ui/components/base/Checkbox.vue'
|
||||
import FilterPills from '#ui/components/base/FilterPills.vue'
|
||||
@@ -44,7 +44,7 @@ interface Props {
|
||||
enableToggle?: boolean
|
||||
actionDisabled?: boolean
|
||||
actionDisabledTooltip?: string | null
|
||||
getOverflowOptions?: (item: ContentItem) => OverflowMenuOption[]
|
||||
getOverflowOptions?: (item: ContentItem) => ButtonMenuOption[]
|
||||
switchVersion?: (item: ContentItem) => void
|
||||
showEnvironmentWarnings?: boolean
|
||||
}
|
||||
|
||||
+12
-2
@@ -109,7 +109,12 @@
|
||||
class="flex min-w-0 cursor-pointer items-center gap-2.5 overflow-hidden border-0 bg-transparent p-0 text-left"
|
||||
@click="emit('navigate', inst)"
|
||||
>
|
||||
<Avatar :src="inst.iconUrl ?? undefined" size="2rem" rounded="md" />
|
||||
<Avatar
|
||||
:src="inst.iconUrl ?? undefined"
|
||||
size="2rem"
|
||||
rounded="md"
|
||||
pad-transparent-corners
|
||||
/>
|
||||
<span class="truncate font-semibold text-contrast hover:underline">{{
|
||||
inst.name
|
||||
}}</span>
|
||||
@@ -145,7 +150,12 @@
|
||||
<div v-else class="flex flex-col gap-6 p-6">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="group relative size-[7.75rem] shrink-0">
|
||||
<Avatar :src="iconPreviewUrl ?? undefined" size="100%" no-shadow />
|
||||
<Avatar
|
||||
:src="iconPreviewUrl ?? undefined"
|
||||
size="100%"
|
||||
no-shadow
|
||||
pad-transparent-corners
|
||||
/>
|
||||
<div
|
||||
v-if="iconPreviewUrl"
|
||||
class="pointer-events-none absolute right-1.5 top-1.5 opacity-0 transition-opacity group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100"
|
||||
|
||||
+4
-24
@@ -6,6 +6,10 @@ import type {
|
||||
DropdownFilterBarCategory,
|
||||
DropdownFilterBarOption,
|
||||
} from '#ui/components/base/DropdownFilterBar.vue'
|
||||
import {
|
||||
type EnvironmentFilterValue,
|
||||
getEnvironmentFilterValue,
|
||||
} from '#ui/components/project/settings/environment/environments'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
|
||||
import type { ContentItem } from '../types'
|
||||
@@ -58,30 +62,6 @@ const openSourceLicenseIds = new Set([
|
||||
'Zlib',
|
||||
])
|
||||
|
||||
type EnvironmentFilterValue = 'client' | 'server' | 'client_and_server' | 'singleplayer'
|
||||
|
||||
function getEnvironmentFilterValue(
|
||||
environment?: ContentItem['environment'],
|
||||
): EnvironmentFilterValue | undefined {
|
||||
switch (environment) {
|
||||
case 'client_only':
|
||||
return 'client'
|
||||
case 'server_only':
|
||||
case 'dedicated_server_only':
|
||||
return 'server'
|
||||
case 'client_and_server':
|
||||
case 'client_only_server_optional':
|
||||
case 'server_only_client_optional':
|
||||
case 'client_or_server':
|
||||
case 'client_or_server_prefers_both':
|
||||
return 'client_and_server'
|
||||
case 'singleplayer_only':
|
||||
return 'singleplayer'
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
author: {
|
||||
id: 'content.metadata-filter.author',
|
||||
|
||||
@@ -23,7 +23,7 @@ import { useSessionStorage } from '@vueuse/core'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import Avatar from '#ui/components/base/Avatar.vue'
|
||||
import { Button, type OverflowMenuOption, TeleportOverflowMenu } from '#ui/components/base/buttons'
|
||||
import { Button, type ButtonMenuOption, TeleportOverflowMenu } from '#ui/components/base/buttons'
|
||||
import DropdownFilterBar from '#ui/components/base/DropdownFilterBar.vue'
|
||||
import EmptyState from '#ui/components/base/EmptyState.vue'
|
||||
import FilterPills from '#ui/components/base/FilterPills.vue'
|
||||
@@ -191,7 +191,7 @@ const sortLabels: Record<SortMode, () => string> = {
|
||||
'date-added-oldest': () => formatMessage(messages.sortDateAddedOldest),
|
||||
}
|
||||
|
||||
const sortOptions = computed<OverflowMenuOption[]>(() => [
|
||||
const sortOptions = computed<ButtonMenuOption[]>(() => [
|
||||
{
|
||||
id: 'alphabetical-asc',
|
||||
label: formatMessage(messages.sortAlphabeticalAscending),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
|
||||
import type { OverflowMenuOption } from '#ui/components/base/buttons'
|
||||
import type { ButtonMenuOption } from '#ui/components/base/buttons'
|
||||
import { createContext } from '#ui/providers/create-context'
|
||||
|
||||
import type {
|
||||
@@ -81,7 +81,7 @@ export interface ContentManagerContext {
|
||||
switchVersion?: (item: ContentItem) => void
|
||||
|
||||
// Per-item overflow menu (optional)
|
||||
getOverflowOptions?: (item: ContentItem) => OverflowMenuOption[]
|
||||
getOverflowOptions?: (item: ContentItem) => ButtonMenuOption[]
|
||||
|
||||
// Share support (optional — when undefined, share button becomes hidden entirely)
|
||||
shareItems?: (items: ContentItem[], format: 'names' | 'file-names' | 'urls' | 'markdown') => void
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import type { OverflowMenuOption } from '#ui/components/base/buttons'
|
||||
import type { ButtonMenuOption } from '#ui/components/base/buttons'
|
||||
|
||||
export type ContentCardProject = Pick<
|
||||
Labrinth.Projects.v2.Project,
|
||||
@@ -73,7 +73,7 @@ export interface ContentCardTableItem {
|
||||
clientWarning?: ClientWarningType | null
|
||||
hideDelete?: boolean
|
||||
hideSwitchVersion?: boolean
|
||||
overflowOptions?: OverflowMenuOption[]
|
||||
overflowOptions?: ButtonMenuOption[]
|
||||
}
|
||||
|
||||
export type ContentCardTableSortColumn = 'project' | 'version'
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
<template>
|
||||
<Teleport to="#teleports">
|
||||
<Transition name="floating-expand">
|
||||
<div
|
||||
v-if="visible"
|
||||
ref="menuRef"
|
||||
class="fixed isolate z-[9999] flex w-fit min-w-[180px] flex-col gap-2 overflow-hidden rounded-2xl border border-solid border-surface-5 bg-bg-raised p-2 shadow-lg"
|
||||
:style="{ left: `${position.x}px`, top: `${position.y}px`, transformOrigin: 'top left' }"
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
@mousedown.stop
|
||||
>
|
||||
<Button
|
||||
type="quiet"
|
||||
class="w-full !justify-start !whitespace-nowrap"
|
||||
role="menuitem"
|
||||
@click="handleCopyFilename"
|
||||
>
|
||||
<ClipboardCopyIcon class="size-5" />
|
||||
{{ formatMessage(commonMessages.copyFilenameButton) }}
|
||||
</Button>
|
||||
<Button
|
||||
type="quiet"
|
||||
class="w-full !justify-start !whitespace-nowrap"
|
||||
role="menuitem"
|
||||
@click="handleCopyPath"
|
||||
>
|
||||
<ClipboardCopyIcon class="size-5" />
|
||||
{{ formatMessage(commonMessages.copyFullPathButton) }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="ctx.openInFolder"
|
||||
type="quiet"
|
||||
class="w-full !justify-start !whitespace-nowrap"
|
||||
role="menuitem"
|
||||
@click="handleOpenInFolder"
|
||||
>
|
||||
<FolderOpenIcon class="size-5" />
|
||||
{{ formatMessage(commonMessages.openInFolderButton) }}
|
||||
</Button>
|
||||
<div class="h-px w-full bg-surface-5" />
|
||||
<template v-for="(option, index) in menuOptions" :key="index">
|
||||
<div
|
||||
v-if="'divider' in option && option.divider && option.shown !== false"
|
||||
class="h-px w-full bg-surface-5"
|
||||
/>
|
||||
<Button
|
||||
v-else-if="'id' in option && option.shown !== false"
|
||||
v-tooltip="option.tooltip"
|
||||
type="quiet"
|
||||
:color="
|
||||
option.color && option.color !== 'standard'
|
||||
? option.color === 'medal-promo'
|
||||
? 'medal_promotion'
|
||||
: option.color
|
||||
: undefined
|
||||
"
|
||||
:disabled="option.disabled"
|
||||
class="w-full !justify-start !whitespace-nowrap"
|
||||
role="menuitem"
|
||||
@click="handleOptionClick(option)"
|
||||
>
|
||||
<slot :name="option.id" />
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ClipboardCopyIcon, FolderOpenIcon } from '@modrinth/assets'
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { Button } from '#ui/components/base/buttons'
|
||||
import { useVIntl } from '#ui/composables/i18n'
|
||||
import { injectNotificationManager } from '#ui/providers/web-notifications'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import { injectFileManager } from '../providers/file-manager'
|
||||
import type { FileContextMenuOption, FileItem } from '../types'
|
||||
import { joinDisplayPath } from '../utils'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const ctx = injectFileManager()
|
||||
|
||||
const visible = ref(false)
|
||||
const menuRef = ref<HTMLElement>()
|
||||
const position = ref({ x: 0, y: 0 })
|
||||
const currentItem = ref<FileItem | null>(null)
|
||||
const menuOptions = ref<FileContextMenuOption[]>([])
|
||||
|
||||
function show(item: FileItem, x: number, y: number, options: typeof menuOptions.value) {
|
||||
currentItem.value = item
|
||||
menuOptions.value = options
|
||||
position.value = { x, y }
|
||||
visible.value = true
|
||||
|
||||
nextTick(() => {
|
||||
if (!menuRef.value) return
|
||||
const rect = menuRef.value.getBoundingClientRect()
|
||||
const padding = 10
|
||||
if (rect.right > window.innerWidth - padding) {
|
||||
position.value.x = Math.max(padding, x - rect.width)
|
||||
}
|
||||
if (rect.bottom > window.innerHeight - padding) {
|
||||
position.value.y = Math.max(padding, y - rect.height)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function hide() {
|
||||
visible.value = false
|
||||
currentItem.value = null
|
||||
}
|
||||
|
||||
function handleCopyFilename() {
|
||||
if (!currentItem.value) return
|
||||
navigator.clipboard.writeText(currentItem.value.name)
|
||||
addNotification({ title: formatMessage(commonMessages.copiedFilenameLabel), type: 'success' })
|
||||
hide()
|
||||
}
|
||||
|
||||
function getFullPath() {
|
||||
if (!currentItem.value) return ''
|
||||
return joinDisplayPath(ctx.basePath?.value, currentItem.value.path)
|
||||
}
|
||||
|
||||
function handleCopyPath() {
|
||||
if (!currentItem.value) return
|
||||
navigator.clipboard.writeText(getFullPath())
|
||||
addNotification({ title: formatMessage(commonMessages.copiedPathLabel), type: 'success' })
|
||||
hide()
|
||||
}
|
||||
|
||||
function handleOpenInFolder() {
|
||||
if (!currentItem.value) return
|
||||
ctx.openInFolder?.(getFullPath())
|
||||
hide()
|
||||
}
|
||||
|
||||
function handleOptionClick(option: { action?: () => void }) {
|
||||
option.action?.()
|
||||
hide()
|
||||
}
|
||||
|
||||
function onClickOutside(event: MouseEvent) {
|
||||
if (menuRef.value && !menuRef.value.contains(event.target as Node)) {
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
function onEscape(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('mousedown', onClickOutside)
|
||||
document.addEventListener('keydown', onEscape)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('mousedown', onClickOutside)
|
||||
document.removeEventListener('keydown', onEscape)
|
||||
})
|
||||
|
||||
watch(visible, (v) => {
|
||||
if (!v) currentItem.value = null
|
||||
})
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -62,35 +62,6 @@
|
||||
:options="menuOptions"
|
||||
>
|
||||
<MoreHorizontalIcon class="h-5 w-5 bg-transparent" />
|
||||
<template #copy-filename
|
||||
><ClipboardCopyIcon />
|
||||
{{ formatMessage(commonMessages.copyFilenameButton) }}</template
|
||||
>
|
||||
<template #copy-full-path
|
||||
><ClipboardCopyIcon />
|
||||
{{ formatMessage(commonMessages.copyFullPathButton) }}</template
|
||||
>
|
||||
<template #open-in-folder
|
||||
><FolderOpenIcon /> {{ formatMessage(commonMessages.openInFolderButton) }}</template
|
||||
>
|
||||
<template #extract
|
||||
><PackageOpenIcon /> {{ formatMessage(commonMessages.extractButton) }}</template
|
||||
>
|
||||
<template #rename
|
||||
><EditIcon /> {{ formatMessage(commonMessages.renameButton) }}</template
|
||||
>
|
||||
<template #move
|
||||
><RightArrowIcon /> {{ formatMessage(commonMessages.moveButton) }}</template
|
||||
>
|
||||
<template #download
|
||||
><DownloadIcon />
|
||||
{{
|
||||
ctx.downloadButtonLabel ?? formatMessage(commonMessages.downloadButton)
|
||||
}}</template
|
||||
>
|
||||
<template #delete
|
||||
><TrashIcon /> {{ formatMessage(commonMessages.deleteLabel) }}</template
|
||||
>
|
||||
</TeleportOverflowMenu>
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,6 +88,7 @@ import {
|
||||
} from '@modrinth/assets'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { ButtonMenuOption } from '#ui/components/base/buttons'
|
||||
import { TeleportOverflowMenu } from '#ui/components/base/buttons'
|
||||
import Checkbox from '#ui/components/base/Checkbox.vue'
|
||||
import { useFormatBytes } from '#ui/composables'
|
||||
@@ -168,7 +140,7 @@ const emit = defineEmits<{
|
||||
e: 'moveDirectTo',
|
||||
item: Pick<FileItem, 'name' | 'type' | 'path'> & { destination: string },
|
||||
): void
|
||||
(e: 'contextmenu', x: number, y: number): void
|
||||
(e: 'contextmenu', event: MouseEvent, options: ButtonMenuOption[]): void
|
||||
(e: 'toggle-select'): void
|
||||
}>()
|
||||
|
||||
@@ -205,13 +177,13 @@ const containerClasses = computed(() => {
|
||||
|
||||
const fileExtension = computed(() => getFileExtension(props.name))
|
||||
|
||||
const isZip = computed(() => fileExtension.value === 'zip')
|
||||
const canExtract = computed(() => fileExtension.value === 'zip' && !!ctx.extractFile)
|
||||
|
||||
function getFullPath() {
|
||||
return joinDisplayPath(ctx.basePath?.value, props.path)
|
||||
}
|
||||
|
||||
const menuOptions = computed(() => {
|
||||
const menuOptions = computed<ButtonMenuOption[]>(() => {
|
||||
const item = { name: props.name, type: props.type, path: props.path }
|
||||
const wd = props.writeDisabled
|
||||
const wdTooltip = props.writeDisabledTooltip
|
||||
@@ -248,15 +220,17 @@ const menuOptions = computed(() => {
|
||||
{
|
||||
id: 'extract',
|
||||
label: formatMessage(commonMessages.extractButton),
|
||||
shown: isZip.value,
|
||||
icon: PackageOpenIcon,
|
||||
shown: canExtract.value,
|
||||
disabled: wd,
|
||||
tooltip: wd ? wdTooltip : undefined,
|
||||
action: () => emit('extract', item),
|
||||
},
|
||||
{ type: 'divider', shown: isZip.value },
|
||||
{ type: 'divider', shown: canExtract.value },
|
||||
{
|
||||
id: 'rename',
|
||||
label: formatMessage(commonMessages.renameButton),
|
||||
icon: EditIcon,
|
||||
disabled: wd,
|
||||
tooltip: wd ? wdTooltip : undefined,
|
||||
action: () => emit('rename', item),
|
||||
@@ -264,6 +238,7 @@ const menuOptions = computed(() => {
|
||||
{
|
||||
id: 'move',
|
||||
label: formatMessage(commonMessages.moveButton),
|
||||
icon: RightArrowIcon,
|
||||
disabled: wd,
|
||||
tooltip: wd ? wdTooltip : undefined,
|
||||
action: () => emit('move', item),
|
||||
@@ -271,12 +246,14 @@ const menuOptions = computed(() => {
|
||||
{
|
||||
id: 'download',
|
||||
label: ctx.downloadButtonLabel ?? formatMessage(commonMessages.downloadButton),
|
||||
icon: DownloadIcon,
|
||||
action: () => emit('download', item),
|
||||
shown: props.type !== 'directory',
|
||||
},
|
||||
{
|
||||
id: 'delete',
|
||||
label: formatMessage(commonMessages.deleteLabel),
|
||||
icon: TrashIcon,
|
||||
disabled: wd,
|
||||
tooltip: wd ? wdTooltip : undefined,
|
||||
action: () => emit('delete', item),
|
||||
@@ -327,7 +304,7 @@ const formattedSize = computed(() => {
|
||||
|
||||
function openContextMenu(event: MouseEvent) {
|
||||
event.preventDefault()
|
||||
emit('contextmenu', event.clientX, event.clientY)
|
||||
emit('contextmenu', event, menuOptions.value)
|
||||
}
|
||||
|
||||
function handleMouseEnter() {
|
||||
|
||||
@@ -17,25 +17,7 @@
|
||||
@move="handleMoveItem"
|
||||
/>
|
||||
<FileDeleteItemModal ref="deleteItemModal" :item="selectedItem" @delete="handleDeleteItem" />
|
||||
<FileContextMenu ref="contextMenuRef">
|
||||
<template #extract
|
||||
><PackageOpenIcon class="size-5" />
|
||||
{{ formatMessage(commonMessages.extractButton) }}</template
|
||||
>
|
||||
<template #rename
|
||||
><EditIcon class="size-5" /> {{ formatMessage(commonMessages.renameButton) }}</template
|
||||
>
|
||||
<template #move
|
||||
><RightArrowIcon class="size-5" /> {{ formatMessage(commonMessages.moveButton) }}</template
|
||||
>
|
||||
<template #download
|
||||
><DownloadIcon class="size-5" />
|
||||
{{ ctx.downloadButtonLabel ?? formatMessage(commonMessages.downloadButton) }}</template
|
||||
>
|
||||
<template #delete
|
||||
><TrashIcon class="size-5" /> {{ formatMessage(commonMessages.deleteLabel) }}</template
|
||||
>
|
||||
</FileContextMenu>
|
||||
<ContextMenu ref="contextMenuRef" :label="formatMessage(commonMessages.actionsLabel)" />
|
||||
<div v-if="!(ctx.loading.value && items.length === 0)" class="contents">
|
||||
<div class="relative flex w-full flex-col">
|
||||
<div class="relative isolate flex w-full flex-col gap-4">
|
||||
@@ -113,7 +95,9 @@
|
||||
@edit="() => handleEditFile(item)"
|
||||
@navigate="() => handleNavigateToFolder(item)"
|
||||
@hover="() => handleItemHover(item)"
|
||||
@contextmenu="(x, y) => handleContextMenu(item, x, y)"
|
||||
@contextmenu="
|
||||
(event, options) => contextMenuRef?.open(event as MouseEvent, options)
|
||||
"
|
||||
@toggle-select="() => toggleItemSelection(item.path)"
|
||||
/>
|
||||
</div>
|
||||
@@ -200,20 +184,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DownloadIcon,
|
||||
EditIcon,
|
||||
FolderOpenIcon,
|
||||
HistoryIcon,
|
||||
PackageOpenIcon,
|
||||
RightArrowIcon,
|
||||
SaveIcon,
|
||||
TrashIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { FolderOpenIcon, HistoryIcon, SaveIcon, TrashIcon } from '@modrinth/assets'
|
||||
import type { Component } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
import { Button } from '#ui/components/base/buttons'
|
||||
import { Button, ContextMenu } from '#ui/components/base/buttons'
|
||||
import FloatingActionBar from '#ui/components/base/FloatingActionBar.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { useStickyObserver } from '#ui/composables/sticky-observer'
|
||||
@@ -221,10 +196,9 @@ import { useVirtualScroll } from '#ui/composables/virtual-scroll'
|
||||
import { injectFilePicker } from '#ui/providers/file-picker'
|
||||
import { injectNotificationManager } from '#ui/providers/web-notifications'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
import { canOpenInFileEditor, getFileExtension } from '#ui/utils/file-extensions'
|
||||
import { canOpenInFileEditor } from '#ui/utils/file-extensions'
|
||||
|
||||
import FileEditor from './components/editor/FileEditor.vue'
|
||||
import FileContextMenu from './components/FileContextMenu.vue'
|
||||
import FileManagerError from './components/FileManagerError.vue'
|
||||
import FileNavbar from './components/FileNavbar.vue'
|
||||
import FileTableHeader from './components/FileTableHeader.vue'
|
||||
@@ -242,7 +216,7 @@ import { useFileSelection } from './composables/file-selection'
|
||||
import { useFileSorting } from './composables/file-sorting'
|
||||
import { useFileUndoRedo } from './composables/file-undo-redo'
|
||||
import { injectFileManager } from './providers/file-manager'
|
||||
import type { FileContextMenuOption, FileItem } from './types'
|
||||
import type { FileItem } from './types'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
@@ -366,7 +340,7 @@ const moveItemModal = ref<InstanceType<typeof FileMoveItemModal>>()
|
||||
const deleteItemModal = ref<InstanceType<typeof FileDeleteItemModal>>()
|
||||
const uploadConflictModal = ref<InstanceType<typeof FileUploadConflictModal>>()
|
||||
const uploadZipUrlModal = ref<InstanceType<typeof FileUploadZipUrlModal>>()
|
||||
const contextMenuRef = ref<InstanceType<typeof FileContextMenu>>()
|
||||
const contextMenuRef = ref<InstanceType<typeof ContextMenu>>()
|
||||
|
||||
const newItemType = ref<'file' | 'directory'>('file')
|
||||
const selectedItem = ref<FileItem | null>(null)
|
||||
@@ -663,50 +637,6 @@ function handlePrefetchHome() {
|
||||
}, 150)
|
||||
}
|
||||
|
||||
// Context menu
|
||||
function handleContextMenu(item: FileItem, x: number, y: number) {
|
||||
const wd = isBusy.value
|
||||
const wdTooltip = busyTooltip.value
|
||||
const isZip = getFileExtension(item.name) === 'zip'
|
||||
|
||||
const options: FileContextMenuOption[] = [
|
||||
{
|
||||
id: 'extract',
|
||||
shown: isZip && !!ctx.extractFile,
|
||||
disabled: wd,
|
||||
tooltip: wd ? wdTooltip : undefined,
|
||||
action: () => handleExtractItem(item),
|
||||
},
|
||||
{ divider: true, shown: isZip && !!ctx.extractFile },
|
||||
{
|
||||
id: 'rename',
|
||||
disabled: wd,
|
||||
tooltip: wd ? wdTooltip : undefined,
|
||||
action: () => showRenameModal(item),
|
||||
},
|
||||
{
|
||||
id: 'move',
|
||||
disabled: wd,
|
||||
tooltip: wd ? wdTooltip : undefined,
|
||||
action: () => showMoveModal(item),
|
||||
},
|
||||
{
|
||||
id: 'download',
|
||||
action: () => handleDownload(item),
|
||||
shown: item.type !== 'directory',
|
||||
},
|
||||
{
|
||||
id: 'delete',
|
||||
disabled: wd,
|
||||
tooltip: wd ? wdTooltip : undefined,
|
||||
action: () => showDeleteModal(item),
|
||||
color: 'red',
|
||||
},
|
||||
]
|
||||
|
||||
contextMenuRef.value?.show(item, x, y, options)
|
||||
}
|
||||
|
||||
// Reset search/sort/selection on path change
|
||||
watch(
|
||||
() => ctx.currentPath.value,
|
||||
|
||||
@@ -18,17 +18,6 @@ export type FileSortField = 'name' | 'size' | 'created' | 'modified'
|
||||
|
||||
export type FileViewFilter = 'all' | 'filesOnly' | 'foldersOnly'
|
||||
|
||||
export type FileContextMenuOption =
|
||||
| {
|
||||
id: string
|
||||
action?: () => void
|
||||
disabled?: boolean
|
||||
tooltip?: string
|
||||
color?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple' | 'medal-promo'
|
||||
shown?: boolean
|
||||
}
|
||||
| { divider: true; shown?: boolean }
|
||||
|
||||
export interface FileOperation {
|
||||
id?: string
|
||||
op: string
|
||||
|
||||
@@ -230,7 +230,7 @@
|
||||
:projects="filteredProjects"
|
||||
:layout="displayMode"
|
||||
:link-mode="projectLinkMode"
|
||||
show-status
|
||||
:show-status="canSeeProjectStatus"
|
||||
>
|
||||
<template v-if="$slots['project-actions']" #actions="{ project }">
|
||||
<slot name="project-actions" :project="project" />
|
||||
@@ -296,7 +296,7 @@
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div v-if="canSeeCollectionStatus" class="flex items-center gap-1">
|
||||
<template v-if="collection.status === 'listed'">
|
||||
<GlobeIcon />
|
||||
{{ formatMessage(commonMessages.publicLabel) }}
|
||||
@@ -703,6 +703,19 @@ const blockedUsersQuery = useQuery({
|
||||
enabled: computed(() => Boolean(auth.user.value)),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
const viewerProjectsQuery = useQuery({
|
||||
queryKey: computed(() => ['user', auth.user.value?.id, 'projects']),
|
||||
queryFn: () => userProfile.getProjects(auth.user.value!.id),
|
||||
enabled: computed(
|
||||
() =>
|
||||
Boolean(auth.user.value?.id) &&
|
||||
Boolean(userQuery.data.value?.id) &&
|
||||
auth.user.value?.id !== userQuery.data.value?.id &&
|
||||
auth.user.value?.role !== 'admin' &&
|
||||
auth.user.value?.role !== 'moderator',
|
||||
),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const user = computed(() => userQuery.data.value)
|
||||
const projects = computed(() => projectsQuery.data.value ?? [])
|
||||
@@ -797,6 +810,16 @@ const isAdminViewing = computed(() => auth.user.value?.role === 'admin')
|
||||
const isStaffViewing = computed(
|
||||
() => auth.user.value?.role === 'admin' || auth.user.value?.role === 'moderator',
|
||||
)
|
||||
const viewerMemberProjectIds = computed(
|
||||
() => new Set((viewerProjectsQuery.data.value ?? []).map((project) => project.id)),
|
||||
)
|
||||
|
||||
function canSeeProjectStatus(project: Labrinth.Projects.v3.Project) {
|
||||
if (isSelf.value || isStaffViewing.value) return true
|
||||
return viewerMemberProjectIds.value.has(project.id)
|
||||
}
|
||||
|
||||
const canSeeCollectionStatus = computed(() => isSelf.value || isStaffViewing.value)
|
||||
const isAffiliate = computed(() => Boolean((user.value?.badges ?? 0) & UserBadge.AFFILIATE))
|
||||
const hasMidas = computed(
|
||||
() => Boolean((user.value?.badges ?? 0) & UserBadge.MIDAS) || hasActivePride26Midas(user.value),
|
||||
|
||||
@@ -2609,6 +2609,9 @@
|
||||
"label.yes": {
|
||||
"defaultMessage": "Yes"
|
||||
},
|
||||
"layout.account-switcher.switching": {
|
||||
"defaultMessage": "Switching accounts..."
|
||||
},
|
||||
"locale.cs-CZ": {
|
||||
"defaultMessage": "Czech"
|
||||
},
|
||||
@@ -4019,6 +4022,12 @@
|
||||
"project.versions.channel.release.symbol": {
|
||||
"defaultMessage": "R"
|
||||
},
|
||||
"project.versions.filter.channel": {
|
||||
"defaultMessage": "Channel"
|
||||
},
|
||||
"project.versions.filter.selected-count": {
|
||||
"defaultMessage": "{count, number} selected"
|
||||
},
|
||||
"project.versions.filter.toggle-tooltip": {
|
||||
"defaultMessage": "Toggle filter for {filter}"
|
||||
},
|
||||
@@ -6671,6 +6680,12 @@
|
||||
"user.profile.badge.staff.name": {
|
||||
"defaultMessage": "Modrinth Team"
|
||||
},
|
||||
"user.role.moderator": {
|
||||
"defaultMessage": "Content Moderator"
|
||||
},
|
||||
"user.role.modrinth-team": {
|
||||
"defaultMessage": "Modrinth Team"
|
||||
},
|
||||
"version.content.name": {
|
||||
"defaultMessage": "Name"
|
||||
},
|
||||
@@ -6681,7 +6696,7 @@
|
||||
"defaultMessage": "Dev jar"
|
||||
},
|
||||
"version.file-type.javadoc-jar": {
|
||||
"defaultMessage": "Javadoc jar"
|
||||
"defaultMessage": "Javadocs jar"
|
||||
},
|
||||
"version.file-type.optional-resource-pack": {
|
||||
"defaultMessage": "Optional resource pack"
|
||||
@@ -6696,7 +6711,7 @@
|
||||
"defaultMessage": "Signature file"
|
||||
},
|
||||
"version.file-type.sources-jar": {
|
||||
"defaultMessage": "Source jar"
|
||||
"defaultMessage": "Sources jar"
|
||||
},
|
||||
"version.file-type.unknown": {
|
||||
"defaultMessage": "Other"
|
||||
|
||||
@@ -4,9 +4,9 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import Button from '../../components/base/buttons/Button.vue'
|
||||
import ButtonGroup from '../../components/base/buttons/ButtonGroup.vue'
|
||||
import SplitButton from '../../components/base/buttons/SplitButton.vue'
|
||||
import type { OverflowMenuOption } from '../../components/base/buttons/types'
|
||||
import type { ButtonMenuOption } from '../../components/base/buttons/types'
|
||||
|
||||
const splitOptions: OverflowMenuOption[] = [
|
||||
const splitOptions: ButtonMenuOption[] = [
|
||||
{
|
||||
id: 'settings',
|
||||
label: 'Server settings',
|
||||
|
||||
+41
-34
@@ -3,47 +3,61 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { fn } from 'storybook/test'
|
||||
import { nextTick, onMounted, ref } from 'vue'
|
||||
|
||||
import ContextMenu from '../../../../../apps/app-frontend/src/components/ui/context-menu/index.vue'
|
||||
import type { ContextMenuOption } from '../../../../../apps/app-frontend/src/components/ui/context-menu/types'
|
||||
import ContextMenu from '../../components/base/buttons/ContextMenu.vue'
|
||||
import type { ButtonMenuOption } from '../../components/base/buttons/types'
|
||||
|
||||
const options: ContextMenuOption[] = [
|
||||
{ name: 'play', color: 'primary' },
|
||||
const options: ButtonMenuOption[] = [
|
||||
{
|
||||
name: 'copy',
|
||||
children: [{ name: 'copy_name' }, { name: 'copy_path' }, { name: 'copy_id' }],
|
||||
id: 'play',
|
||||
label: 'Play',
|
||||
icon: PlayIcon,
|
||||
tone: 'brand',
|
||||
action: () => undefined,
|
||||
},
|
||||
{ name: 'open_folder' },
|
||||
{
|
||||
id: 'copy',
|
||||
label: 'Copy',
|
||||
icon: CopyIcon,
|
||||
type: 'submenu',
|
||||
options: [
|
||||
{ id: 'copy_name', label: 'Copy name', icon: CopyIcon, action: () => undefined },
|
||||
{ id: 'copy_path', label: 'Copy path', icon: CopyIcon, action: () => undefined },
|
||||
{ id: 'copy_id', label: 'Copy ID', icon: CopyIcon, action: () => undefined },
|
||||
],
|
||||
},
|
||||
{ id: 'open_folder', label: 'Open folder', icon: FolderOpenIcon, action: () => undefined },
|
||||
{ type: 'divider' },
|
||||
{ name: 'settings' },
|
||||
{ name: 'delete', color: 'danger' },
|
||||
{ id: 'settings', label: 'Settings', icon: SettingsIcon, action: () => undefined },
|
||||
{
|
||||
id: 'delete',
|
||||
label: 'Delete',
|
||||
icon: TrashIcon,
|
||||
tone: 'red',
|
||||
hoverFilledOnly: true,
|
||||
action: () => undefined,
|
||||
},
|
||||
]
|
||||
|
||||
const meta = {
|
||||
title: 'App/Context Menu',
|
||||
title: 'Buttons/Context Menu',
|
||||
component: ContextMenu,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
args: {
|
||||
onMenuClosed: fn(),
|
||||
onOptionClicked: fn(),
|
||||
label: 'Instance actions',
|
||||
onSelect: fn(),
|
||||
onOpen: fn(),
|
||||
onClose: fn(),
|
||||
},
|
||||
render: (args) => ({
|
||||
components: {
|
||||
ContextMenu,
|
||||
CopyIcon,
|
||||
FolderOpenIcon,
|
||||
PlayIcon,
|
||||
SettingsIcon,
|
||||
TrashIcon,
|
||||
},
|
||||
components: { ContextMenu },
|
||||
setup() {
|
||||
const contextMenu = ref<InstanceType<typeof ContextMenu>>()
|
||||
const target = ref<HTMLElement>()
|
||||
const item = { id: 'storybook-instance', name: 'Storybook Instance' }
|
||||
|
||||
function openMenu(event: MouseEvent) {
|
||||
contextMenu.value?.showMenu(event, item, options)
|
||||
contextMenu.value?.open(event, options)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -74,18 +88,11 @@ const meta = {
|
||||
|
||||
<ContextMenu
|
||||
ref="contextMenu"
|
||||
@menu-closed="args.onMenuClosed"
|
||||
@option-clicked="args.onOptionClicked"
|
||||
>
|
||||
<template #play><PlayIcon /> Play</template>
|
||||
<template #copy><CopyIcon /> Copy</template>
|
||||
<template #copy_name><CopyIcon /> Copy name</template>
|
||||
<template #copy_path><CopyIcon /> Copy path</template>
|
||||
<template #copy_id> <CopyIcon /> Copy ID</template>
|
||||
<template #open_folder><FolderOpenIcon /> Open folder</template>
|
||||
<template #settings><SettingsIcon /> Settings</template>
|
||||
<template #delete><TrashIcon /> Delete</template>
|
||||
</ContextMenu>
|
||||
:label="args.label"
|
||||
@select="args.onSelect"
|
||||
@open="args.onOpen"
|
||||
@close="args.onClose"
|
||||
/>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
@@ -1,16 +1,19 @@
|
||||
import {
|
||||
ArrowLeftRightIcon,
|
||||
DownloadIcon,
|
||||
ExternalIcon,
|
||||
MoreVerticalIcon,
|
||||
PlusIcon,
|
||||
SettingsIcon,
|
||||
TrashIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import TeleportOverflowMenu from '../../components/base/buttons/TeleportOverflowMenu.vue'
|
||||
import type { OverflowMenuOption } from '../../components/base/buttons/types'
|
||||
import type { ButtonMenuOption } from '../../components/base/buttons/types'
|
||||
|
||||
const options: OverflowMenuOption[] = [
|
||||
const options: ButtonMenuOption[] = [
|
||||
{
|
||||
id: 'download',
|
||||
label: 'Download',
|
||||
@@ -102,3 +105,48 @@ export const Hoverable: Story = {
|
||||
hoverable: true,
|
||||
},
|
||||
}
|
||||
|
||||
const optionsWithSubmenu: ButtonMenuOption[] = [
|
||||
...options,
|
||||
{ type: 'divider' },
|
||||
{
|
||||
id: 'switch-account',
|
||||
label: 'Switch account',
|
||||
icon: ArrowLeftRightIcon,
|
||||
type: 'submenu',
|
||||
options: [
|
||||
{
|
||||
id: 'account-jai',
|
||||
label: 'Jai',
|
||||
selected: true,
|
||||
action: () => undefined,
|
||||
trailingAction: {
|
||||
label: 'Remove Jai',
|
||||
icon: XIcon,
|
||||
color: 'red',
|
||||
action: () => undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'account-prospector',
|
||||
label: 'Prospector',
|
||||
selected: false,
|
||||
action: () => undefined,
|
||||
trailingAction: {
|
||||
label: 'Remove Prospector',
|
||||
icon: XIcon,
|
||||
color: 'red',
|
||||
action: () => undefined,
|
||||
},
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{ id: 'add-account', label: 'Add account', icon: PlusIcon, action: () => undefined },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const WithSubmenu: Story = {
|
||||
args: {
|
||||
options: optionsWithSubmenu,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import UserAvatar from '../../components/user/UserAvatar.vue'
|
||||
|
||||
const src = 'https://cdn.modrinth.com/data/AANobbMI/icon.png'
|
||||
|
||||
const meta = {
|
||||
title: 'User/User Avatar',
|
||||
component: UserAvatar,
|
||||
args: {
|
||||
src,
|
||||
size: '32px',
|
||||
},
|
||||
} satisfies Meta<typeof UserAvatar>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
|
||||
export const Online: Story = {
|
||||
args: {
|
||||
badge: true,
|
||||
},
|
||||
}
|
||||
|
||||
export const Offline: Story = {
|
||||
args: {
|
||||
grayscale: true,
|
||||
},
|
||||
}
|
||||
|
||||
export const OnSurface: Story = {
|
||||
render: (args) => ({
|
||||
components: { UserAvatar },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: /*html*/ `
|
||||
<div style="display: flex; gap: 1rem; padding: 1rem; background: var(--color-button-bg); border-radius: 1rem;">
|
||||
<UserAvatar v-bind="args" />
|
||||
<UserAvatar v-bind="args" badge />
|
||||
<UserAvatar v-bind="args" grayscale />
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
@@ -2,15 +2,67 @@
|
||||
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
html.theme-color-transitioning *,
|
||||
html.theme-color-transitioning *::before,
|
||||
html.theme-color-transitioning *::after,
|
||||
html.theme-color-transitioning *::backdrop,
|
||||
html.theme-color-transitioning *::placeholder {
|
||||
animation: none !important;
|
||||
animation-delay: 0s !important;
|
||||
transition:
|
||||
color,
|
||||
background-color,
|
||||
border-color,
|
||||
outline-color,
|
||||
fill,
|
||||
stroke,
|
||||
box-shadow,
|
||||
text-decoration-color,
|
||||
caret-color,
|
||||
column-rule-color 125ms ease-in-out !important;
|
||||
transition-delay: 0s !important;
|
||||
}
|
||||
|
||||
/* save bars and stuff should still animate in/out */
|
||||
html.theme-color-transitioning .floating-action-bar {
|
||||
animation: revert !important;
|
||||
animation-delay: revert !important;
|
||||
transition:
|
||||
color,
|
||||
background-color,
|
||||
border-color,
|
||||
outline-color,
|
||||
fill,
|
||||
stroke,
|
||||
box-shadow,
|
||||
text-decoration-color,
|
||||
caret-color,
|
||||
column-rule-color 125ms ease-in-out,
|
||||
transform 0.25s cubic-bezier(0.15, 1.4, 0.64, 0.96),
|
||||
opacity 0.25s cubic-bezier(0.15, 1.4, 0.64, 0.96),
|
||||
bottom 0.25s ease-in-out !important;
|
||||
transition-delay: 0s !important;
|
||||
}
|
||||
|
||||
html.theme-color-transitioning .floating-action-bar *,
|
||||
html.theme-color-transitioning .floating-action-bar *::before,
|
||||
html.theme-color-transitioning .floating-action-bar *::after {
|
||||
animation: revert !important;
|
||||
animation-delay: revert !important;
|
||||
}
|
||||
}
|
||||
|
||||
.floating-expand-enter-from,
|
||||
.floating-expand-leave-to {
|
||||
opacity: 0;
|
||||
scale: 0.9 0.3;
|
||||
scale: var(--floating-expand-x, 0.9) var(--floating-expand-y, 0.3);
|
||||
}
|
||||
|
||||
.floating-expand-enter-to,
|
||||
.floating-expand-leave-from {
|
||||
opacity: 1;
|
||||
scale: 1 1;
|
||||
}
|
||||
|
||||
.floating-expand-enter-active,
|
||||
@@ -18,7 +70,37 @@
|
||||
transition:
|
||||
opacity 0.125s var(--ease-out-expo),
|
||||
scale 0.125s var(--ease-out-expo);
|
||||
transform-origin: top;
|
||||
transform-origin: var(--floating-expand-origin, top);
|
||||
}
|
||||
|
||||
.button-menu-item[data-tone]:not([data-hover-filled-only]),
|
||||
.button-menu-item[data-tone]:not([data-hover-filled-only]) svg {
|
||||
color: var(--button-menu-item-tone);
|
||||
}
|
||||
|
||||
.button-menu-item[data-tone][data-hover-filled]:hover,
|
||||
.button-menu-item[data-tone][data-hover-filled]:focus-visible {
|
||||
color: var(--color-accent-contrast);
|
||||
background-color: var(--button-menu-item-tone);
|
||||
}
|
||||
|
||||
.button-menu-item[data-tone][data-hover-filled]:hover svg,
|
||||
.button-menu-item[data-tone][data-hover-filled]:focus-visible svg {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.button-menu-trailing {
|
||||
color: var(--button-menu-trailing-color, var(--color-secondary));
|
||||
}
|
||||
|
||||
.button-menu-trailing:hover,
|
||||
.button-menu-trailing:focus-visible {
|
||||
color: var(--color-accent-contrast);
|
||||
}
|
||||
|
||||
.button-menu-trailing:hover::before,
|
||||
.button-menu-trailing:focus-visible::before {
|
||||
background-color: var(--button-menu-trailing-color, var(--color-secondary));
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
|
||||
@@ -1466,7 +1466,7 @@ export const fileTypeMessages: Record<
|
||||
}),
|
||||
'sources-jar': defineMessage({
|
||||
id: 'version.file-type.sources-jar',
|
||||
defaultMessage: 'Source jar',
|
||||
defaultMessage: 'Sources jar',
|
||||
}),
|
||||
'dev-jar': defineMessage({
|
||||
id: 'version.file-type.dev-jar',
|
||||
@@ -1474,7 +1474,7 @@ export const fileTypeMessages: Record<
|
||||
}),
|
||||
'javadoc-jar': defineMessage({
|
||||
id: 'version.file-type.javadoc-jar',
|
||||
defaultMessage: 'Javadoc jar',
|
||||
defaultMessage: 'Javadocs jar',
|
||||
}),
|
||||
signature: defineMessage({
|
||||
id: 'version.file-type.signature',
|
||||
|
||||
@@ -13,6 +13,7 @@ export * from './savable'
|
||||
export * from './search'
|
||||
export * from './server-search'
|
||||
export * from './tag-messages'
|
||||
export * from './theme-color-transition'
|
||||
export * from './truncate'
|
||||
export * from './use-anchored-teleport'
|
||||
export * from './v3-projects'
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
const DURATION_MS = 125
|
||||
const CLASS = 'theme-color-transitioning'
|
||||
|
||||
let removeTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
export function prepareThemeColorTransition() {
|
||||
if (typeof document === 'undefined') return
|
||||
|
||||
const root = document.documentElement
|
||||
root.classList.remove(CLASS)
|
||||
void root.offsetWidth
|
||||
root.classList.add(CLASS)
|
||||
|
||||
clearTimeout(removeTimeout)
|
||||
removeTimeout = setTimeout(() => {
|
||||
root.classList.remove(CLASS)
|
||||
removeTimeout = undefined
|
||||
}, DURATION_MS)
|
||||
}
|
||||
@@ -14,15 +14,26 @@ export type AnchoredTeleportPlacement =
|
||||
| 'left-end'
|
||||
export type AnchoredTeleportSide = 'top' | 'right' | 'bottom' | 'left'
|
||||
|
||||
export interface AnchoredTeleportAnchor {
|
||||
getBoundingClientRect(): DOMRect
|
||||
}
|
||||
|
||||
const viewportPadding = 8
|
||||
const anchorPadding = 18
|
||||
const defaultDistance = ref(8)
|
||||
const defaultAlignOffset = ref(0)
|
||||
|
||||
export function pointAnchor(x: number, y: number): AnchoredTeleportAnchor {
|
||||
const rect = new DOMRect(x, y, 0, 0)
|
||||
return { getBoundingClientRect: () => rect }
|
||||
}
|
||||
|
||||
export function useAnchoredTeleport(
|
||||
trigger: Readonly<Ref<HTMLElement | null>>,
|
||||
trigger: Readonly<Ref<AnchoredTeleportAnchor | null>>,
|
||||
panel: Readonly<Ref<HTMLElement | null>>,
|
||||
placement: Readonly<Ref<AnchoredTeleportPlacement>>,
|
||||
distance: Readonly<Ref<number>> = defaultDistance,
|
||||
alignOffset: Readonly<Ref<number>> = defaultAlignOffset,
|
||||
) {
|
||||
const isOpen = ref(false)
|
||||
const panelStyle = ref<CSSProperties>({
|
||||
@@ -32,6 +43,7 @@ export function useAnchoredTeleport(
|
||||
})
|
||||
const anchorStyle = ref<CSSProperties>({})
|
||||
const resolvedSide = ref<AnchoredTeleportSide>('bottom')
|
||||
const expandOrigin = ref('top center')
|
||||
|
||||
let resizeObserver: ResizeObserver | undefined
|
||||
|
||||
@@ -59,7 +71,8 @@ export function useAnchoredTeleport(
|
||||
: panelRect.width + offset > spaceLeft && spaceRight > spaceLeft
|
||||
|
||||
resolvedSide.value = opensRight ? 'right' : 'left'
|
||||
idealTop = alignsEnd ? triggerRect.bottom - panelRect.height : triggerRect.top
|
||||
idealTop =
|
||||
(alignsEnd ? triggerRect.bottom - panelRect.height : triggerRect.top) + alignOffset.value
|
||||
idealLeft = opensRight
|
||||
? triggerRect.right + offset
|
||||
: triggerRect.left - panelRect.width - offset
|
||||
@@ -89,7 +102,8 @@ export function useAnchoredTeleport(
|
||||
idealLeft = centered
|
||||
}
|
||||
} else {
|
||||
idealLeft = alignsEnd ? triggerRect.right - panelRect.width : triggerRect.left
|
||||
idealLeft =
|
||||
(alignsEnd ? triggerRect.right - panelRect.width : triggerRect.left) + alignOffset.value
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +112,11 @@ export function useAnchoredTeleport(
|
||||
const maxLeft = Math.max(viewportPadding, window.innerWidth - panelRect.width - viewportPadding)
|
||||
const panelTop = Math.min(Math.max(idealTop, viewportPadding), maxTop)
|
||||
const panelLeft = Math.min(Math.max(idealLeft, viewportPadding), maxLeft)
|
||||
const originY = triggerRect.top + triggerRect.height / 2 - panelTop
|
||||
const originX = isHorizontal
|
||||
? (resolvedSide.value === 'right' ? triggerRect.right : triggerRect.left) - panelLeft
|
||||
: triggerRect.left + triggerRect.width / 2 - panelLeft
|
||||
expandOrigin.value = `${originX}px ${originY}px`
|
||||
|
||||
panelStyle.value = {
|
||||
top: `${panelTop}px`,
|
||||
@@ -120,9 +139,13 @@ export function useAnchoredTeleport(
|
||||
}
|
||||
}
|
||||
|
||||
function triggerElement() {
|
||||
return trigger.value instanceof HTMLElement ? trigger.value : null
|
||||
}
|
||||
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
const target = event.target as Node | null
|
||||
if (!target || trigger.value?.contains(target) || panel.value?.contains(target)) return
|
||||
if (!target || triggerElement()?.contains(target) || panel.value?.contains(target)) return
|
||||
close()
|
||||
}
|
||||
|
||||
@@ -132,7 +155,8 @@ export function useAnchoredTeleport(
|
||||
window.addEventListener('scroll', updatePosition, true)
|
||||
|
||||
resizeObserver = new ResizeObserver(updatePosition)
|
||||
if (trigger.value) resizeObserver.observe(trigger.value)
|
||||
const element = triggerElement()
|
||||
if (element) resizeObserver.observe(element)
|
||||
if (panel.value) resizeObserver.observe(panel.value)
|
||||
}
|
||||
|
||||
@@ -157,14 +181,15 @@ export function useAnchoredTeleport(
|
||||
if (!isOpen.value) return
|
||||
isOpen.value = false
|
||||
removeListeners()
|
||||
if (restoreFocus) nextTick(() => trigger.value?.focus())
|
||||
if (restoreFocus) nextTick(() => triggerElement()?.focus())
|
||||
}
|
||||
|
||||
watch([placement, distance], updatePosition)
|
||||
watch([placement, distance, alignOffset, trigger], updatePosition)
|
||||
watch(panel, () => {
|
||||
if (!isOpen.value) return
|
||||
resizeObserver?.disconnect()
|
||||
if (trigger.value) resizeObserver?.observe(trigger.value)
|
||||
const element = triggerElement()
|
||||
if (element) resizeObserver?.observe(element)
|
||||
if (panel.value) resizeObserver?.observe(panel.value)
|
||||
updatePosition()
|
||||
})
|
||||
@@ -176,7 +201,9 @@ export function useAnchoredTeleport(
|
||||
panelStyle,
|
||||
anchorStyle,
|
||||
resolvedSide,
|
||||
expandOrigin,
|
||||
open,
|
||||
close,
|
||||
updatePosition,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user