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:
Prospector
2026-08-27 00:35:34 +00:00
committed by GitHub
parent 3439a43880
commit b68a199814
226 changed files with 6585 additions and 4078 deletions
+11 -6
View File
@@ -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) {
+112 -9
View File
@@ -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()
})
+2 -2
View File
@@ -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
+8 -4
View File
@@ -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>
@@ -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>
+5
View File
@@ -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) {
@@ -25,6 +25,7 @@ export const Interactive: StoryObj = {
v-model="currentTheme"
:theme-options="themeOptions"
system-theme-color="dark"
preferred-dark-theme="oled"
/>
`,
}),
@@ -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
@@ -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
}
@@ -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"
@@ -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),
+17 -2
View File
@@ -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',
@@ -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>
`,
}),
}
+84 -2
View File
@@ -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 {
+2 -2
View File
@@ -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',
+1
View File
@@ -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)
}
+35 -8
View File
@@ -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,
}
}