mirror of
https://github.com/modrinth/code.git
synced 2026-08-28 10:34:53 +00:00
* Rule 1.x, 3.2 and 3.3x support * reorganize * Kinda Jank dynamic list stuff that should probably be implemented better but its 1:30 am * Checklist Stage Title * Make Stage Title Good * Rule 1.x, 3.2 and 3.3x support * reorganize * Kinda Jank dynamic list stuff that should probably be implemented better but its 1:30 am * Checklist Stage Title * Make Stage Title Good * oops i forgot 3.1 * Insufficient description improvement test * "i forgor" checklist tooltips tooltips (kinda jank but they work) update to latest * only use indexedDb for moderation storage, localStorage forces a bunch of cringe * store moderation stuff separately + compact checklist stuff * give stages actual titles + rename what was previously called titles to hints * Title & Slug, Summary Description, Links, License, Gallery, Versions and Reupload migrated to new system™️ * put stageoptions before nodes * do state and variablse in tooltips * checklist fully migrated to new system, not much new has been added (yet) * forgot to only do modpack permissions for modpacks * Idk why I wasn't just mutating the builder * actual node type hierarchy + action abstraction + improve message/text handling * dropdown + "functional" quick fixes * dropdown + "functional" quick fixes * Allow moderators to suggest correct environment. * remove vestigial function * Prioritization™️ * quickest fixes in the west * start of metadata stage + optimize checklist parsing + a couple of qol features * futureproof™️ environment metadata + just give me project context everywhere * tidy up after today's refactoring * tidy up after tidying up after today's refactoring * move loaders to metadata (from versions) * better required display + vueify + reorganize messages + tag quick fixes + todo * fix log spam + concat md * more bugs * nested state access * update description stage, add showcase clarity * Fix links stage mostly, and tweak metadata stage. * fix links lag + actions on stages * setup links bonus buttons * Change stage order * Update rules stage * Move conditional stages to the top where they belong. * fix some metadata stuff * fix stage selector * don't show non-english buttons on servers without english support. * prepr * stop linter from trying to break my markdown * fix: some issues * fix: lint * not sure why but link's links weren't links * fix quick fixes (again) (idk how they keep breaking) * make messages only actually count as messages if they actually have content * rename horror button to spoilers button * Update links name to match the links settings * re-review stage * unbreak stage selector * passing off version stage fixing. * actually fix the stage selector this time * fix showcase clarity msg * redirect when checklist updates slug fix initial stage not being first if first stage is only shown under certain conditions move to a consistent naming scheme * redirect when checklist updates slug fix initial stage not being first if first stage is only shown under certain conditions move to a consistent naming scheme * ok maybe lets use kebab * move custom description advice stage to first button. * temp priority on corrections applied * make metadata page go to non setting versions page * links stage bonus buttons, src required flag, show link value in msgs, fix msg groupings. * update re-review button * reupload stage navigates to project Description. * fix: link stage using page context too late * chore: run prepr * fix(ci): tell typo check to ignore moderation utils * fix: pr comment if storybook fails * make complex nodes less complex (for the most part) fix re-review stage shown condition new fancy message collection system all buttons that contribute to messages now have relevant tooltips don't store default values in checklist db * make complex nodes less complex (for the most part) fix re-review stage shown condition new fancy message collection system all buttons that contribute to messages now have relevant tooltips don't store default values in checklist db * maybe fixed markdown escaping? * tweak re-review wording. * Update license stage handling and info * add mixed option back for env info * fix: formatEnvironments fix: formatEnvironments * update metadata environment group shown * fix: status alert priority * fix: display suggested slug in slug/misused message * actually navigate when we change project slug * ok like redirect but like don't lose state when slug changes :smart: * make checklist reset button reset only current stage if there's modifications to it instead of full checklist * fix versions stage messages not working * fix donation links not being in a column * I think slug stuff is working? also like actually 100% garunteed markdown escaping works now * prepr * pseudo stages no longer interact with non-pseudo stages at all also query projects better I think? fix repetitive message spam in links stage * apply quick fixes before refreshing page * differentiate reset and return to start * remove random empty lines in some situations * better inaccurate slug message * slug correction input reset button resets to current slug again instead of auto * better keybind navigation * incorrect environment message tweak * use environment name(s) in checklist info * prepr --------- Signed-off-by: Prospector <6166773+Prospector@users.noreply.github.com> Co-authored-by: chyzman <chyzalt@gmail.com> Co-authored-by: Calum H. (IMB11) <contact@cal.engineer> Co-authored-by: Gravy Boat <gravy@thatgravyboat.tech> Co-authored-by: Michael H. <michael@iptables.sh> Co-authored-by: chyz <32403637+chyzman@users.noreply.github.com> Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
523 lines
14 KiB
Vue
523 lines
14 KiB
Vue
<template>
|
|
<div data-pyro-telepopover-wrapper class="relative">
|
|
<button
|
|
ref="triggerRef"
|
|
v-tooltip="tooltip"
|
|
class="teleport-overflow-menu-trigger"
|
|
:class="btnClass"
|
|
:aria-expanded="isOpen"
|
|
:aria-haspopup="true"
|
|
:aria-label="ariaLabel"
|
|
:disabled="disabled"
|
|
@mousedown="handleMouseDown"
|
|
@mouseenter="handleMouseEnter"
|
|
@mouseleave="handleMouseLeave"
|
|
@click="toggleMenu"
|
|
>
|
|
<slot></slot>
|
|
</button>
|
|
<Teleport to="#teleports">
|
|
<Transition
|
|
enter-active-class="transition duration-125 ease-out"
|
|
enter-from-class="transform scale-75 opacity-0"
|
|
enter-to-class="transform scale-100 opacity-100"
|
|
leave-active-class="transition duration-125 ease-in"
|
|
leave-from-class="transform scale-100 opacity-100"
|
|
leave-to-class="transform scale-75 opacity-0"
|
|
>
|
|
<div
|
|
v-if="isOpen"
|
|
ref="menuRef"
|
|
data-pyro-telepopover-root
|
|
class="fixed isolate z-[9999] flex w-fit flex-col gap-2 overflow-x-hidden overflow-y-auto rounded-2xl border-[1px] border-solid border-surface-5 bg-bg-raised p-2 shadow-lg"
|
|
:style="menuStyle"
|
|
role="menu"
|
|
tabindex="-1"
|
|
@mousedown.stop
|
|
@wheel.stop
|
|
@mouseleave="handleMouseLeave"
|
|
>
|
|
<template
|
|
v-for="(option, index) in filteredOptions"
|
|
:key="isDivider(option) ? `divider-${index}` : option.id"
|
|
>
|
|
<div v-if="isDivider(option)" class="h-px w-full bg-surface-5"></div>
|
|
<ButtonStyled
|
|
v-else
|
|
type="transparent"
|
|
role="menuitem"
|
|
:color="optionButtonColor(option)"
|
|
>
|
|
<button
|
|
v-if="typeof option.action === 'function' || option.disabled"
|
|
:ref="
|
|
(el) => {
|
|
if (el) menuItemsRef[index] = el as HTMLElement
|
|
}
|
|
"
|
|
v-tooltip="option.tooltip"
|
|
:disabled="option.disabled"
|
|
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
|
|
:aria-label="option.ariaLabel ?? option.label ?? option.id"
|
|
:aria-selected="index === selectedIndex"
|
|
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
|
|
@click="(event) => handleItemClick(option, index, event)"
|
|
@focus="selectedIndex = index"
|
|
@mouseover="handleMouseOver(index)"
|
|
>
|
|
<slot :name="option.id">
|
|
<component :is="option.icon" v-if="option.icon" class="size-5" />
|
|
{{ option.label ?? option.id }}
|
|
</slot>
|
|
</button>
|
|
<AutoLink
|
|
v-else-if="optionLink(option)"
|
|
:ref="
|
|
(el) => {
|
|
if (el) menuItemsRef[index] = el as HTMLElement
|
|
}
|
|
"
|
|
:to="optionLink(option)"
|
|
:target="option.external ? '_blank' : undefined"
|
|
:rel="option.external ? 'noopener noreferrer' : undefined"
|
|
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
|
|
:aria-label="option.ariaLabel ?? option.label ?? option.id"
|
|
:aria-selected="index === selectedIndex"
|
|
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
|
|
@click="(event) => handleItemClick(option, index, event)"
|
|
@focus="selectedIndex = index"
|
|
@mouseover="handleMouseOver(index)"
|
|
>
|
|
<slot :name="option.id">
|
|
<component :is="option.icon" v-if="option.icon" class="size-5" />
|
|
{{ option.label ?? option.id }}
|
|
</slot>
|
|
</AutoLink>
|
|
<span v-else>
|
|
<slot :name="option.id">
|
|
<component :is="option.icon" v-if="option.icon" class="size-5" />
|
|
{{ option.label ?? option.id }}
|
|
</slot>
|
|
</span>
|
|
</ButtonStyled>
|
|
</template>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { onClickOutside, useElementHover } from '@vueuse/core'
|
|
import { type Component, computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
|
|
|
import AutoLink from './AutoLink.vue'
|
|
import ButtonStyled from './ButtonStyled.vue'
|
|
|
|
type OptionColor =
|
|
| 'standard'
|
|
| 'brand'
|
|
| 'primary'
|
|
| 'danger'
|
|
| 'secondary'
|
|
| 'highlight'
|
|
| 'red'
|
|
| 'orange'
|
|
| 'green'
|
|
| 'blue'
|
|
| 'purple'
|
|
|
|
export interface Option {
|
|
id: string
|
|
label?: string
|
|
icon?: Component
|
|
action?: ((event?: MouseEvent) => void) | string
|
|
link?: string
|
|
external?: boolean
|
|
shown?: boolean
|
|
color?: OptionColor
|
|
disabled?: boolean
|
|
tooltip?: string
|
|
ariaLabel?: string
|
|
remainOnClick?: boolean
|
|
}
|
|
|
|
export type Divider = {
|
|
divider?: boolean
|
|
shown?: boolean
|
|
}
|
|
|
|
export type Item = Option | Divider
|
|
|
|
function isDivider(item: Item): item is Divider {
|
|
return !!(item as Divider).divider
|
|
}
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
options: Item[]
|
|
hoverable?: boolean
|
|
btnClass?: string | string[] | Record<string, boolean>
|
|
disabled?: boolean
|
|
tooltip?: string
|
|
ariaLabel?: string
|
|
placement?: 'right' | 'center'
|
|
}>(),
|
|
{
|
|
hoverable: false,
|
|
btnClass: undefined,
|
|
disabled: false,
|
|
tooltip: undefined,
|
|
ariaLabel: undefined,
|
|
placement: 'right',
|
|
},
|
|
)
|
|
|
|
const emit = defineEmits<{
|
|
select: [option: Option]
|
|
open: []
|
|
}>()
|
|
|
|
const isOpen = ref(false)
|
|
const selectedIndex = ref(-1)
|
|
const menuRef = ref<HTMLElement | null>(null)
|
|
const triggerRef = ref<HTMLElement | null>(null)
|
|
const isMouseDown = ref(false)
|
|
const typeAheadBuffer = ref('')
|
|
const typeAheadTimeout = ref<number | null>(null)
|
|
const menuItemsRef = ref<HTMLElement[]>([])
|
|
|
|
const hoveringTrigger = useElementHover(triggerRef)
|
|
const hoveringMenu = useElementHover(menuRef)
|
|
|
|
const hovering = computed(() => hoveringTrigger.value || hoveringMenu.value)
|
|
|
|
const menuStyle = ref<Record<string, string>>({
|
|
top: '-9999px',
|
|
left: '-9999px',
|
|
})
|
|
|
|
const filteredOptions = computed(() => props.options.filter((option) => option.shown !== false))
|
|
|
|
const calculateMenuPosition = () => {
|
|
if (!triggerRef.value || !menuRef.value) return null
|
|
|
|
const triggerRect = triggerRef.value.getBoundingClientRect()
|
|
// offsetWidth is not affected by CSS transforms (unlike getBoundingClientRect)
|
|
// scrollHeight gives the full content height regardless of any maxHeight constraint,
|
|
// preventing a feedback loop where clamped offsetHeight makes it look like the menu fits
|
|
const menuWidth = menuRef.value.offsetWidth
|
|
const menuHeight = menuRef.value.scrollHeight
|
|
const margin = 8
|
|
|
|
let top: number
|
|
let maxHeight: number | null = null
|
|
|
|
const spaceBelow = window.innerHeight - triggerRect.bottom - margin
|
|
const spaceAbove = triggerRect.top - margin
|
|
|
|
if (menuHeight <= spaceBelow) {
|
|
top = triggerRect.bottom + margin
|
|
} else if (menuHeight <= spaceAbove) {
|
|
top = triggerRect.top - menuHeight - margin
|
|
} else if (spaceBelow >= spaceAbove) {
|
|
top = triggerRect.bottom + margin
|
|
maxHeight = spaceBelow
|
|
} else {
|
|
maxHeight = spaceAbove
|
|
top = triggerRect.top - maxHeight - margin
|
|
}
|
|
|
|
const preferredLeft =
|
|
props.placement === 'center'
|
|
? triggerRect.left + triggerRect.width / 2 - menuWidth / 2
|
|
: triggerRect.right - menuWidth
|
|
const left = Math.max(margin, Math.min(preferredLeft, window.innerWidth - menuWidth - margin))
|
|
|
|
return {
|
|
top: `${top}px`,
|
|
left: `${left}px`,
|
|
...(maxHeight !== null ? { maxHeight: `${maxHeight}px` } : {}),
|
|
}
|
|
}
|
|
|
|
const toggleMenu = (event: MouseEvent) => {
|
|
event.stopPropagation()
|
|
if (props.disabled) return
|
|
if (!props.hoverable) {
|
|
if (isOpen.value) {
|
|
closeMenu()
|
|
} else {
|
|
openMenu()
|
|
}
|
|
}
|
|
}
|
|
|
|
const openMenu = () => {
|
|
if (props.disabled) return
|
|
menuStyle.value = { top: '-9999px', left: '-9999px' }
|
|
isOpen.value = true
|
|
emit('open')
|
|
// nextTick lets Vue render the element, then requestAnimationFrame waits for the
|
|
// browser to complete layout so offsetWidth/offsetHeight are real values.
|
|
nextTick(() =>
|
|
requestAnimationFrame(() => {
|
|
const pos = calculateMenuPosition()
|
|
if (pos) menuStyle.value = pos
|
|
document.addEventListener('mousemove', handleMouseMove)
|
|
focusFirstMenuItem()
|
|
}),
|
|
)
|
|
}
|
|
|
|
const closeMenu = () => {
|
|
isOpen.value = false
|
|
selectedIndex.value = -1
|
|
document.removeEventListener('mousemove', handleMouseMove)
|
|
}
|
|
|
|
const selectOption = (option: Option, event?: MouseEvent) => {
|
|
emit('select', option)
|
|
if (typeof option.action === 'function') {
|
|
option.action(event)
|
|
}
|
|
if (!option.remainOnClick) {
|
|
closeMenu()
|
|
}
|
|
}
|
|
|
|
const handleMouseDown = (event: MouseEvent) => {
|
|
if (props.disabled) return
|
|
event.preventDefault()
|
|
isMouseDown.value = true
|
|
}
|
|
|
|
const handleMouseEnter = () => {
|
|
if (props.hoverable) {
|
|
openMenu()
|
|
}
|
|
}
|
|
|
|
const handleMouseLeave = () => {
|
|
if (props.hoverable) {
|
|
setTimeout(() => {
|
|
if (!hovering.value) {
|
|
closeMenu()
|
|
}
|
|
}, 250)
|
|
}
|
|
}
|
|
|
|
const handleMouseMove = (event: MouseEvent) => {
|
|
if (!isOpen.value || !isMouseDown.value) return
|
|
|
|
const menuRect = menuRef.value?.getBoundingClientRect()
|
|
if (!menuRect) return
|
|
|
|
const menuItems = menuRef.value?.querySelectorAll('[role="menuitem"]')
|
|
if (!menuItems) return
|
|
|
|
for (let i = 0; i < menuItems.length; i++) {
|
|
const itemRect = (menuItems[i] as HTMLElement).getBoundingClientRect()
|
|
if (
|
|
event.clientX >= itemRect.left &&
|
|
event.clientX <= itemRect.right &&
|
|
event.clientY >= itemRect.top &&
|
|
event.clientY <= itemRect.bottom
|
|
) {
|
|
selectedIndex.value = i
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
const handleItemClick = (option: Option, index: number, event?: MouseEvent) => {
|
|
if (option.disabled) return
|
|
selectedIndex.value = index
|
|
selectOption(option, event)
|
|
}
|
|
|
|
const handleMouseOver = (index: number) => {
|
|
selectedIndex.value = index
|
|
menuItemsRef.value[selectedIndex.value]?.focus?.()
|
|
}
|
|
|
|
const focusFirstMenuItem = () => {
|
|
if (menuItemsRef.value.length > 0) {
|
|
menuItemsRef.value[0]?.focus?.()
|
|
}
|
|
}
|
|
|
|
const handleKeydown = (event: KeyboardEvent) => {
|
|
if (!isOpen.value) {
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault()
|
|
openMenu()
|
|
}
|
|
return
|
|
}
|
|
|
|
switch (event.key) {
|
|
case 'ArrowDown':
|
|
event.preventDefault()
|
|
selectedIndex.value = (selectedIndex.value + 1) % filteredOptions.value.length
|
|
menuItemsRef.value[selectedIndex.value]?.focus?.()
|
|
break
|
|
case 'ArrowUp':
|
|
event.preventDefault()
|
|
selectedIndex.value =
|
|
(selectedIndex.value - 1 + filteredOptions.value.length) % filteredOptions.value.length
|
|
menuItemsRef.value[selectedIndex.value]?.focus?.()
|
|
break
|
|
case 'Home':
|
|
event.preventDefault()
|
|
if (menuItemsRef.value.length > 0) {
|
|
selectedIndex.value = 0
|
|
menuItemsRef.value[selectedIndex.value]?.focus?.()
|
|
}
|
|
break
|
|
case 'End':
|
|
event.preventDefault()
|
|
if (menuItemsRef.value.length > 0) {
|
|
selectedIndex.value = filteredOptions.value.length - 1
|
|
menuItemsRef.value[selectedIndex.value]?.focus?.()
|
|
}
|
|
break
|
|
case 'Enter':
|
|
case ' ':
|
|
event.preventDefault()
|
|
if (selectedIndex.value >= 0) {
|
|
const option = filteredOptions.value[selectedIndex.value]
|
|
if (isDivider(option)) break
|
|
selectOption(option)
|
|
}
|
|
break
|
|
case 'Escape':
|
|
event.preventDefault()
|
|
closeMenu()
|
|
triggerRef.value?.focus?.()
|
|
break
|
|
case 'Tab':
|
|
event.preventDefault()
|
|
if (menuItemsRef.value.length > 0) {
|
|
if (event.shiftKey) {
|
|
selectedIndex.value =
|
|
(selectedIndex.value - 1 + filteredOptions.value.length) % filteredOptions.value.length
|
|
} else {
|
|
selectedIndex.value = (selectedIndex.value + 1) % filteredOptions.value.length
|
|
}
|
|
menuItemsRef.value[selectedIndex.value]?.focus?.()
|
|
}
|
|
break
|
|
default:
|
|
if (event.key.length === 1) {
|
|
typeAheadBuffer.value += event.key.toLowerCase()
|
|
const matchIndex = filteredOptions.value.findIndex(
|
|
(option) =>
|
|
!isDivider(option) && option.id.toLowerCase().startsWith(typeAheadBuffer.value),
|
|
)
|
|
if (matchIndex !== -1) {
|
|
selectedIndex.value = matchIndex
|
|
menuItemsRef.value[selectedIndex.value]?.focus?.()
|
|
}
|
|
if (typeAheadTimeout.value) {
|
|
clearTimeout(typeAheadTimeout.value)
|
|
}
|
|
typeAheadTimeout.value = setTimeout(() => {
|
|
typeAheadBuffer.value = ''
|
|
}, 1000) as unknown as number
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
const handleResize = () => {
|
|
if (!isOpen.value) return
|
|
const pos = calculateMenuPosition()
|
|
if (pos) menuStyle.value = pos
|
|
}
|
|
|
|
const handleScroll = () => {
|
|
if (!isOpen.value) return
|
|
const pos = calculateMenuPosition()
|
|
if (!pos) return
|
|
// On scroll only the vertical position changes — don't update left, which would shift
|
|
// the menu horizontally due to scrollbar appearing/disappearing changing offsetWidth
|
|
const updated: Record<string, string> = { ...menuStyle.value, top: pos.top }
|
|
if (pos.maxHeight) {
|
|
updated.maxHeight = pos.maxHeight
|
|
} else {
|
|
delete updated.maxHeight
|
|
}
|
|
menuStyle.value = updated
|
|
}
|
|
|
|
const throttle = <T extends unknown[]>(
|
|
func: (...args: T) => void,
|
|
limit: number,
|
|
): ((...args: T) => void) => {
|
|
let inThrottle: boolean
|
|
return function (...args: T) {
|
|
if (!inThrottle) {
|
|
func(...args)
|
|
inThrottle = true
|
|
setTimeout(() => (inThrottle = false), limit)
|
|
}
|
|
}
|
|
}
|
|
|
|
const throttledHandleResize = throttle(handleResize, 100)
|
|
const throttledHandleScroll = throttle(handleScroll, 100)
|
|
|
|
onMounted(() => {
|
|
triggerRef.value?.addEventListener('keydown', handleKeydown)
|
|
window.addEventListener('resize', throttledHandleResize)
|
|
window.addEventListener('scroll', throttledHandleScroll)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
triggerRef.value?.removeEventListener('keydown', handleKeydown)
|
|
window.removeEventListener('resize', throttledHandleResize)
|
|
window.removeEventListener('scroll', throttledHandleScroll)
|
|
document.removeEventListener('mousemove', handleMouseMove)
|
|
if (typeAheadTimeout.value) {
|
|
clearTimeout(typeAheadTimeout.value)
|
|
}
|
|
})
|
|
|
|
watch(isOpen, (newValue) => {
|
|
if (newValue) {
|
|
nextTick(() => {
|
|
menuRef.value?.addEventListener('keydown', handleKeydown)
|
|
})
|
|
} else {
|
|
menuRef.value?.removeEventListener('keydown', handleKeydown)
|
|
}
|
|
})
|
|
|
|
onClickOutside(menuRef, (event) => {
|
|
if (!triggerRef.value?.contains(event.target as Node)) {
|
|
closeMenu()
|
|
}
|
|
})
|
|
|
|
function optionLink(option: Option) {
|
|
if (typeof option.action === 'string') return option.action
|
|
return option.link
|
|
}
|
|
|
|
function optionButtonColor(option: Option) {
|
|
switch (option.color) {
|
|
case 'primary':
|
|
return 'brand'
|
|
case 'danger':
|
|
return 'red'
|
|
case 'secondary':
|
|
case 'highlight':
|
|
return 'standard'
|
|
default:
|
|
return option.color ?? 'standard'
|
|
}
|
|
}
|
|
</script>
|