mirror of
https://github.com/modrinth/code.git
synced 2026-08-30 11:36:05 +00:00
feat: add context menu submenus
This commit is contained in:
@@ -1,203 +0,0 @@
|
||||
<template>
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-show="shown"
|
||||
ref="contextMenu"
|
||||
class="context-menu"
|
||||
:style="{
|
||||
left: left,
|
||||
top: top,
|
||||
}"
|
||||
>
|
||||
<div v-for="(option, index) in options" :key="index" @click.stop="optionClicked(option.name)">
|
||||
<hr v-if="option.type === 'divider'" class="divider" />
|
||||
<div
|
||||
v-else-if="!(isInstanceLink(item) && option.name === `add_content`)"
|
||||
class="item clickable"
|
||||
:class="[option.color ?? 'base']"
|
||||
>
|
||||
<slot :name="option.name" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
const emit = defineEmits(['menu-closed', 'option-clicked'])
|
||||
|
||||
const item = ref(null)
|
||||
const contextMenu = ref(null)
|
||||
const options = ref([])
|
||||
const left = ref('0px')
|
||||
const top = ref('0px')
|
||||
const shown = ref(false)
|
||||
const contextMenuId = Symbol()
|
||||
const contextMenuOpenEvent = 'modrinth-context-menu-open'
|
||||
|
||||
const hideContextMenu = () => {
|
||||
shown.value = false
|
||||
emit('menu-closed')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
showMenu: (event, passedItem, passedOptions) => {
|
||||
window.dispatchEvent(new CustomEvent(contextMenuOpenEvent, { detail: contextMenuId }))
|
||||
|
||||
item.value = passedItem
|
||||
options.value = passedOptions
|
||||
|
||||
// show to get dimensions
|
||||
shown.value = true
|
||||
|
||||
// then, adjust position if overflowing
|
||||
nextTick(() => {
|
||||
const menuWidth = contextMenu.value?.clientWidth || 200
|
||||
const menuHeight = contextMenu.value?.clientHeight || 100
|
||||
const minFromEdge = 10
|
||||
|
||||
if (event.pageX + menuWidth + minFromEdge >= window.innerWidth) {
|
||||
left.value = Math.max(minFromEdge, event.pageX - menuWidth - minFromEdge) + 'px'
|
||||
} else {
|
||||
left.value = event.pageX + minFromEdge + 'px'
|
||||
}
|
||||
|
||||
if (event.pageY + menuHeight + minFromEdge >= window.innerHeight) {
|
||||
top.value = Math.max(minFromEdge, event.pageY - menuHeight - minFromEdge) + 'px'
|
||||
} else {
|
||||
top.value = event.pageY + minFromEdge + 'px'
|
||||
}
|
||||
})
|
||||
},
|
||||
hideMenu: hideContextMenu,
|
||||
})
|
||||
|
||||
const isInstanceLink = (item) => {
|
||||
if (item.instance != undefined && item.instance.link) {
|
||||
return true
|
||||
} else if (item != undefined && item.link) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const optionClicked = (option) => {
|
||||
emit('option-clicked', {
|
||||
item: item.value,
|
||||
option: option,
|
||||
})
|
||||
hideContextMenu()
|
||||
}
|
||||
|
||||
const onEscKeyRelease = (event) => {
|
||||
if (event.keyCode === 27) {
|
||||
hideContextMenu()
|
||||
}
|
||||
}
|
||||
|
||||
const handleContextMenuOpen = (event) => {
|
||||
if (shown.value && event.detail !== contextMenuId) {
|
||||
hideContextMenu()
|
||||
}
|
||||
}
|
||||
|
||||
const handleClickOutside = (event) => {
|
||||
const elements = document.elementsFromPoint(event.clientX, event.clientY)
|
||||
if (
|
||||
contextMenu.value &&
|
||||
contextMenu.value.$el !== event.target &&
|
||||
!elements.includes(contextMenu.value.$el)
|
||||
) {
|
||||
hideContextMenu()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('click', handleClickOutside)
|
||||
window.addEventListener(contextMenuOpenEvent, handleContextMenuOpen)
|
||||
document.body.addEventListener('keyup', onEscKeyRelease)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('click', handleClickOutside)
|
||||
window.removeEventListener(contextMenuOpenEvent, handleContextMenuOpen)
|
||||
document.removeEventListener('keyup', onEscKeyRelease)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.context-menu {
|
||||
background-color: var(--color-raised-bg);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-floating);
|
||||
border: 1px solid var(--color-divider);
|
||||
margin: 0;
|
||||
position: fixed;
|
||||
z-index: 1000000;
|
||||
overflow: hidden;
|
||||
padding: var(--gap-sm);
|
||||
|
||||
.item {
|
||||
align-items: center;
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
gap: var(--gap-sm);
|
||||
padding: var(--gap-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
:deep(svg) {
|
||||
color: var(--color-base);
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
:deep(svg) {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
&.base {
|
||||
background-color: var(--color-button-bg);
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
|
||||
&.primary {
|
||||
background-color: var(--color-brand);
|
||||
color: var(--color-accent-contrast);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
background-color: var(--color-red);
|
||||
color: var(--color-accent-contrast);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&.contrast {
|
||||
background-color: var(--color-orange);
|
||||
color: var(--color-accent-contrast);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
border: 1px solid var(--color-divider);
|
||||
margin: var(--gap-sm);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,236 @@
|
||||
<template>
|
||||
<Teleport to="#teleports">
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-show="shown && !isMobileActiveSubmenu"
|
||||
ref="contextMenu"
|
||||
class="context-menu"
|
||||
:style="menuStyle"
|
||||
role="menu"
|
||||
@keydown="handleMenuKeydown"
|
||||
@mousemove="(event) => handleMenuMouseMove(event, 'menu')"
|
||||
>
|
||||
<template v-for="(option, index) in options" :key="index">
|
||||
<hr v-if="isDivider(option)" class="divider" />
|
||||
<button
|
||||
v-else-if="isOptionVisible(option)"
|
||||
:ref="(element) => setOptionButtonRef(index, element)"
|
||||
type="button"
|
||||
class="item clickable"
|
||||
:class="[
|
||||
option.color ?? 'base',
|
||||
{
|
||||
active: index === activeOptionIndex,
|
||||
'safe-triangle-hover': index === pendingOptionIndex,
|
||||
},
|
||||
]"
|
||||
role="menuitem"
|
||||
:aria-haspopup="hasChildren(option) ? 'menu' : undefined"
|
||||
:aria-expanded="hasChildren(option) ? index === activeOptionIndex : undefined"
|
||||
@click.stop="handleOptionClick(option, index)"
|
||||
@focus="handleOptionFocus(option, index)"
|
||||
@mouseenter="handleOptionMouseEnter(option, index)"
|
||||
>
|
||||
<span class="item-content"><slot :name="option.name" /></span>
|
||||
<ChevronRightIcon v-if="hasChildren(option)" class="submenu-chevron" />
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</transition>
|
||||
</Teleport>
|
||||
|
||||
<Teleport to="#teleports">
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="shown && activeOption && hasSubmenuPosition"
|
||||
ref="submenu"
|
||||
class="context-menu submenu"
|
||||
:style="submenuStyle"
|
||||
role="menu"
|
||||
@keydown="handleSubmenuKeydown"
|
||||
@mouseenter="handleSubmenuMouseEnter"
|
||||
@mouseleave="isCursorInsideSubmenu = false"
|
||||
@mousemove="(event) => handleMenuMouseMove(event, 'submenu')"
|
||||
>
|
||||
<button
|
||||
v-if="isMobileSubmenuLayout"
|
||||
type="button"
|
||||
class="item clickable base mobile-back"
|
||||
@click.stop="returnToMenu"
|
||||
>
|
||||
<ChevronLeftIcon class="submenu-chevron" />
|
||||
<span class="item-content"><slot :name="activeOption.name" /></span>
|
||||
</button>
|
||||
<template v-for="(option, index) in activeOption.children" :key="index">
|
||||
<hr v-if="isDivider(option)" class="divider" />
|
||||
<button
|
||||
v-else-if="isOptionVisible(option)"
|
||||
:ref="(element) => setSubmenuButtonRef(index, element)"
|
||||
type="button"
|
||||
class="item clickable"
|
||||
:class="option.color ?? 'base'"
|
||||
role="menuitem"
|
||||
@click.stop="optionClicked(option.name)"
|
||||
>
|
||||
<span class="item-content"><slot :name="option.name" /></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from '@modrinth/assets'
|
||||
|
||||
import type { ContextMenuEmit } from './types'
|
||||
import { useContextMenu } from './use-context-menu'
|
||||
import { hasChildren, isDivider } from './utils'
|
||||
|
||||
const emit = defineEmits<ContextMenuEmit>()
|
||||
|
||||
const {
|
||||
shown,
|
||||
contextMenu,
|
||||
submenu,
|
||||
options,
|
||||
menuStyle,
|
||||
submenuStyle,
|
||||
activeOption,
|
||||
activeOptionIndex,
|
||||
pendingOptionIndex,
|
||||
hasSubmenuPosition,
|
||||
isMobileSubmenuLayout,
|
||||
isMobileActiveSubmenu,
|
||||
isCursorInsideSubmenu,
|
||||
isOptionVisible,
|
||||
setOptionButtonRef,
|
||||
setSubmenuButtonRef,
|
||||
showMenu,
|
||||
hideMenu,
|
||||
handleOptionClick,
|
||||
optionClicked,
|
||||
handleOptionFocus,
|
||||
handleOptionMouseEnter,
|
||||
returnToMenu,
|
||||
handleSubmenuMouseEnter,
|
||||
handleMenuMouseMove,
|
||||
handleMenuKeydown,
|
||||
handleSubmenuKeydown,
|
||||
} = useContextMenu(emit)
|
||||
|
||||
defineExpose({ showMenu, hideMenu })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.context-menu {
|
||||
background-color: var(--color-raised-bg);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-floating);
|
||||
border: 1px solid var(--color-divider);
|
||||
margin: 0;
|
||||
position: fixed;
|
||||
z-index: 1000000;
|
||||
overflow: hidden;
|
||||
padding: var(--gap-sm);
|
||||
min-width: 12rem;
|
||||
max-width: calc(100vw - 1.25rem);
|
||||
|
||||
&.submenu {
|
||||
z-index: 1000001;
|
||||
}
|
||||
|
||||
.item {
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
color: var(--color-text-primary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
font-weight: 500;
|
||||
gap: var(--gap-sm);
|
||||
justify-content: space-between;
|
||||
padding: var(--gap-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
|
||||
.item-content {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--gap-sm);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.submenu-chevron {
|
||||
color: var(--color-text-secondary);
|
||||
flex-shrink: 0;
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
margin-right: -0.25rem;
|
||||
}
|
||||
|
||||
:deep(svg) {
|
||||
color: var(--color-base);
|
||||
}
|
||||
|
||||
&:hover:not(.safe-triangle-hover),
|
||||
&:active,
|
||||
&:focus-visible,
|
||||
&.active {
|
||||
:deep(svg) {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
&.base {
|
||||
background-color: var(--color-button-bg);
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
|
||||
&.primary {
|
||||
background-color: var(--color-brand);
|
||||
color: var(--color-accent-contrast);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
background-color: var(--color-red);
|
||||
color: var(--color-accent-contrast);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&.contrast {
|
||||
background-color: var(--color-orange);
|
||||
color: var(--color-accent-contrast);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-back {
|
||||
border-bottom: 1px solid var(--color-divider);
|
||||
border-radius: 0;
|
||||
margin: calc(var(--gap-sm) * -1) calc(var(--gap-sm) * -1) var(--gap-sm);
|
||||
width: calc(100% + var(--gap-sm) * 2);
|
||||
}
|
||||
|
||||
.divider {
|
||||
border: 1px solid var(--color-divider);
|
||||
margin: var(--gap-sm);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { ComponentPublicInstance } from 'vue'
|
||||
|
||||
export type ContextMenuDivider = {
|
||||
type: string
|
||||
}
|
||||
|
||||
export type ContextMenuAction = {
|
||||
name: string
|
||||
color?: string
|
||||
children?: ContextMenuOption[]
|
||||
}
|
||||
|
||||
export type ContextMenuParentAction = ContextMenuAction & {
|
||||
children: ContextMenuOption[]
|
||||
}
|
||||
|
||||
export type ContextMenuOption = ContextMenuDivider | ContextMenuAction
|
||||
|
||||
export type ContextMenuSelection = {
|
||||
item: unknown
|
||||
option: string
|
||||
}
|
||||
|
||||
export type ContextMenuEmit = {
|
||||
(event: 'menu-closed'): void
|
||||
(event: 'option-clicked', selection: ContextMenuSelection): void
|
||||
}
|
||||
|
||||
export type Point = {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export type ViewportRect = {
|
||||
width: number
|
||||
height: number
|
||||
offsetTop: number
|
||||
offsetLeft: number
|
||||
}
|
||||
|
||||
export type ButtonRefElement = Element | ComponentPublicInstance | null
|
||||
@@ -0,0 +1,170 @@
|
||||
import type { CSSProperties, Ref } from 'vue'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import type { ContextMenuParentAction, Point, ViewportRect } from './types'
|
||||
|
||||
const MENU_GAP = 8
|
||||
const VIEWPORT_MARGIN = 10
|
||||
|
||||
type ContextMenuPositionOptions = {
|
||||
shown: Ref<boolean>
|
||||
activeOption: Ref<ContextMenuParentAction | null>
|
||||
activeOptionIndex: Ref<number | null>
|
||||
isMobileSubmenuLayout: Ref<boolean>
|
||||
contextMenu: Ref<HTMLElement | null>
|
||||
submenu: Ref<HTMLElement | null>
|
||||
optionButtonRefs: Map<number, HTMLElement>
|
||||
}
|
||||
|
||||
export function useContextMenuPosition({
|
||||
shown,
|
||||
activeOption,
|
||||
activeOptionIndex,
|
||||
isMobileSubmenuLayout,
|
||||
contextMenu,
|
||||
submenu,
|
||||
optionButtonRefs,
|
||||
}: ContextMenuPositionOptions) {
|
||||
const menuStyle = ref<CSSProperties>({ left: '0px', top: '0px' })
|
||||
const menuAnchor = ref<Point>({ x: 0, y: 0 })
|
||||
const submenuPosition = ref<Point>({ x: 0, y: 0 })
|
||||
const hasSubmenuPosition = ref(false)
|
||||
let positionRafId: number | null = null
|
||||
|
||||
const submenuStyle = computed<CSSProperties>(() => {
|
||||
if (isMobileSubmenuLayout.value) return menuStyle.value
|
||||
|
||||
return {
|
||||
left: `${submenuPosition.value.x}px`,
|
||||
top: `${submenuPosition.value.y}px`,
|
||||
}
|
||||
})
|
||||
|
||||
function updateMenuPosition(x: number, y: number) {
|
||||
if (!contextMenu.value) return
|
||||
|
||||
const viewport = getViewportRect()
|
||||
const menuRect = contextMenu.value.getBoundingClientRect()
|
||||
const viewportLeft = viewport.offsetLeft
|
||||
const viewportTop = viewport.offsetTop
|
||||
const viewportRight = viewport.offsetLeft + viewport.width
|
||||
const viewportBottom = viewport.offsetTop + viewport.height
|
||||
const anchorX = x + viewport.offsetLeft
|
||||
const anchorY = y + viewport.offsetTop
|
||||
const left = Math.min(
|
||||
Math.max(viewportLeft + VIEWPORT_MARGIN, anchorX + MENU_GAP),
|
||||
Math.max(viewportLeft + VIEWPORT_MARGIN, viewportRight - menuRect.width - VIEWPORT_MARGIN),
|
||||
)
|
||||
const top = Math.min(
|
||||
Math.max(viewportTop + VIEWPORT_MARGIN, anchorY + MENU_GAP),
|
||||
Math.max(viewportTop + VIEWPORT_MARGIN, viewportBottom - menuRect.height - VIEWPORT_MARGIN),
|
||||
)
|
||||
|
||||
menuStyle.value = { left: `${left}px`, top: `${top}px` }
|
||||
if (activeOption.value) scheduleSubmenuPositionUpdate()
|
||||
}
|
||||
|
||||
function updateSubmenuPosition() {
|
||||
if (!activeOption.value || activeOptionIndex.value === null) return false
|
||||
|
||||
if (isMobileSubmenuLayout.value) {
|
||||
hasSubmenuPosition.value = true
|
||||
return true
|
||||
}
|
||||
|
||||
const optionButton = optionButtonRefs.get(activeOptionIndex.value)
|
||||
if (!optionButton || !contextMenu.value) return false
|
||||
|
||||
const viewport = getViewportRect()
|
||||
const buttonRect = optionButton.getBoundingClientRect()
|
||||
const menuRect = contextMenu.value.getBoundingClientRect()
|
||||
const submenuRect = submenu.value?.getBoundingClientRect()
|
||||
const submenuWidth = submenuRect?.width ?? menuRect.width
|
||||
const submenuHeight = submenuRect?.height ?? 100
|
||||
const direction = getSubmenuOpenDirection(menuRect, submenuWidth, viewport)
|
||||
const preferredLeft =
|
||||
direction === 'right'
|
||||
? buttonRect.right + MENU_GAP
|
||||
: buttonRect.left - submenuWidth - MENU_GAP
|
||||
const minLeft = viewport.offsetLeft + VIEWPORT_MARGIN
|
||||
const maxLeft = Math.max(
|
||||
minLeft,
|
||||
viewport.offsetLeft + viewport.width - submenuWidth - VIEWPORT_MARGIN,
|
||||
)
|
||||
const minTop = viewport.offsetTop + VIEWPORT_MARGIN
|
||||
const maxTop = Math.max(
|
||||
minTop,
|
||||
viewport.offsetTop + viewport.height - submenuHeight - VIEWPORT_MARGIN,
|
||||
)
|
||||
|
||||
submenuPosition.value = {
|
||||
x: Math.min(Math.max(minLeft, preferredLeft), maxLeft),
|
||||
y: Math.min(Math.max(minTop, buttonRect.top), maxTop),
|
||||
}
|
||||
hasSubmenuPosition.value = true
|
||||
return true
|
||||
}
|
||||
|
||||
function scheduleSubmenuPositionUpdate(retries = 8) {
|
||||
nextTick(() => {
|
||||
if (!shown.value || !activeOption.value) return
|
||||
|
||||
const hasRenderedSubmenu = submenu.value !== null
|
||||
if (updateSubmenuPosition()) {
|
||||
if (!hasRenderedSubmenu) nextTick(updateSubmenuPosition)
|
||||
return
|
||||
}
|
||||
|
||||
if (retries > 0) setTimeout(() => scheduleSubmenuPositionUpdate(retries - 1), 0)
|
||||
})
|
||||
}
|
||||
|
||||
function schedulePositionUpdate() {
|
||||
if (!shown.value || positionRafId !== null) return
|
||||
|
||||
positionRafId = window.requestAnimationFrame(() => {
|
||||
positionRafId = null
|
||||
updateMenuPosition(menuAnchor.value.x, menuAnchor.value.y)
|
||||
})
|
||||
}
|
||||
|
||||
function cancelPositionUpdate() {
|
||||
if (positionRafId !== null) window.cancelAnimationFrame(positionRafId)
|
||||
}
|
||||
|
||||
return {
|
||||
menuStyle,
|
||||
menuAnchor,
|
||||
submenuStyle,
|
||||
hasSubmenuPosition,
|
||||
updateMenuPosition,
|
||||
scheduleSubmenuPositionUpdate,
|
||||
schedulePositionUpdate,
|
||||
cancelPositionUpdate,
|
||||
}
|
||||
}
|
||||
|
||||
function getViewportRect(): ViewportRect {
|
||||
const visualViewport = window.visualViewport
|
||||
return {
|
||||
width: visualViewport?.width ?? window.innerWidth,
|
||||
height: visualViewport?.height ?? window.innerHeight,
|
||||
offsetTop: visualViewport?.offsetTop ?? 0,
|
||||
offsetLeft: visualViewport?.offsetLeft ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
function getSubmenuOpenDirection(
|
||||
menuRect: DOMRect,
|
||||
submenuWidth: number,
|
||||
viewport: ViewportRect,
|
||||
): 'left' | 'right' {
|
||||
const viewportLeft = viewport.offsetLeft
|
||||
const viewportRight = viewport.offsetLeft + viewport.width
|
||||
const rightSpace = viewportRight - menuRect.right - MENU_GAP - VIEWPORT_MARGIN
|
||||
const leftSpace = menuRect.left - viewportLeft - MENU_GAP - VIEWPORT_MARGIN
|
||||
|
||||
if (rightSpace >= submenuWidth) return 'right'
|
||||
if (leftSpace >= submenuWidth) return 'left'
|
||||
return rightSpace >= leftSpace ? 'right' : 'left'
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import type {
|
||||
ButtonRefElement,
|
||||
ContextMenuAction,
|
||||
ContextMenuEmit,
|
||||
ContextMenuOption,
|
||||
ContextMenuParentAction,
|
||||
Point,
|
||||
} from './types'
|
||||
import { useContextMenuPosition } from './use-context-menu-position'
|
||||
import {
|
||||
focusRelativeButton,
|
||||
getFocusableButtons,
|
||||
hasChildren,
|
||||
isAction,
|
||||
isInstanceLink,
|
||||
isPointInTriangle,
|
||||
} from './utils'
|
||||
|
||||
const MOBILE_SUBMENU_LAYOUT_QUERY = '(pointer: coarse), (max-width: 800px)'
|
||||
const CONTEXT_MENU_OPEN_EVENT = 'modrinth-context-menu-open'
|
||||
|
||||
export function useContextMenu(emit: ContextMenuEmit) {
|
||||
const item = ref<unknown>(null)
|
||||
const contextMenu = ref<HTMLElement | null>(null)
|
||||
const submenu = ref<HTMLElement | null>(null)
|
||||
const options = ref<ContextMenuOption[]>([])
|
||||
const shown = ref(false)
|
||||
const activeOptionIndex = ref<number | null>(null)
|
||||
const pendingOptionIndex = ref<number | null>(null)
|
||||
const isCursorInsideSubmenu = ref(false)
|
||||
const isMobileSubmenuLayout = ref(false)
|
||||
const lastMousePosition = ref<Point | null>(null)
|
||||
const contextMenuId = Symbol()
|
||||
const optionButtonRefs = new Map<number, HTMLElement>()
|
||||
const submenuButtonRefs = new Map<number, HTMLElement>()
|
||||
let previousMousePosition: Point | null = null
|
||||
let pendingOptionTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let mobileSubmenuMediaQuery: MediaQueryList | null = null
|
||||
|
||||
const activeOption = computed<ContextMenuParentAction | null>(() => {
|
||||
if (activeOptionIndex.value === null) return null
|
||||
const option = options.value[activeOptionIndex.value]
|
||||
return option && isAction(option) && hasChildren(option) ? option : null
|
||||
})
|
||||
|
||||
const {
|
||||
menuStyle,
|
||||
menuAnchor,
|
||||
submenuStyle,
|
||||
hasSubmenuPosition,
|
||||
updateMenuPosition,
|
||||
scheduleSubmenuPositionUpdate,
|
||||
schedulePositionUpdate,
|
||||
cancelPositionUpdate,
|
||||
} = useContextMenuPosition({
|
||||
shown,
|
||||
activeOption,
|
||||
activeOptionIndex,
|
||||
isMobileSubmenuLayout,
|
||||
contextMenu,
|
||||
submenu,
|
||||
optionButtonRefs,
|
||||
})
|
||||
|
||||
const isMobileActiveSubmenu = computed(
|
||||
() => isMobileSubmenuLayout.value && activeOption.value !== null && hasSubmenuPosition.value,
|
||||
)
|
||||
|
||||
function isOptionVisible(option: ContextMenuOption): option is ContextMenuAction {
|
||||
return isAction(option) && !(isInstanceLink(item.value) && option.name === 'add_content')
|
||||
}
|
||||
|
||||
function setOptionButtonRef(index: number, element: ButtonRefElement) {
|
||||
setButtonRef(optionButtonRefs, index, element)
|
||||
}
|
||||
|
||||
function setSubmenuButtonRef(index: number, element: ButtonRefElement) {
|
||||
setButtonRef(submenuButtonRefs, index, element)
|
||||
}
|
||||
|
||||
function hideMenu() {
|
||||
if (!shown.value) return
|
||||
|
||||
shown.value = false
|
||||
deactivateSubmenu()
|
||||
emit('menu-closed')
|
||||
}
|
||||
|
||||
function showMenu(event: MouseEvent, passedItem: unknown, passedOptions: ContextMenuOption[]) {
|
||||
window.dispatchEvent(new CustomEvent(CONTEXT_MENU_OPEN_EVENT, { detail: contextMenuId }))
|
||||
|
||||
item.value = passedItem
|
||||
options.value = passedOptions
|
||||
menuAnchor.value = { x: event.clientX, y: event.clientY }
|
||||
shown.value = true
|
||||
deactivateSubmenu()
|
||||
syncMobileSubmenuLayout()
|
||||
nextTick(() => updateMenuPosition(event.clientX, event.clientY))
|
||||
}
|
||||
|
||||
function handleOptionClick(option: ContextMenuAction, index: number) {
|
||||
if (hasChildren(option)) {
|
||||
activateSubmenu(index)
|
||||
return
|
||||
}
|
||||
|
||||
optionClicked(option.name)
|
||||
}
|
||||
|
||||
function optionClicked(option: string) {
|
||||
emit('option-clicked', { item: item.value, option })
|
||||
hideMenu()
|
||||
}
|
||||
|
||||
function handleOptionFocus(option: ContextMenuAction, index: number) {
|
||||
if (hasChildren(option) && !isMobileSubmenuLayout.value) {
|
||||
activateSubmenu(index)
|
||||
} else if (!hasChildren(option)) {
|
||||
deactivateSubmenu()
|
||||
}
|
||||
}
|
||||
|
||||
function handleOptionMouseEnter(option: ContextMenuAction, index: number) {
|
||||
if (isMobileSubmenuLayout.value) return
|
||||
|
||||
if (activeOptionIndex.value === null) {
|
||||
if (hasChildren(option)) activateSubmenu(index)
|
||||
return
|
||||
}
|
||||
|
||||
if (activeOptionIndex.value === index) return
|
||||
|
||||
if (!isCursorAimingAtSubmenu(lastMousePosition.value, previousMousePosition)) {
|
||||
commitHoveredOption(index)
|
||||
return
|
||||
}
|
||||
|
||||
pendingOptionIndex.value = index
|
||||
clearPendingOptionTimeout()
|
||||
pendingOptionTimeout = setTimeout(() => {
|
||||
if (pendingOptionIndex.value !== index) return
|
||||
if (isCursorInsideSubmenu.value) {
|
||||
pendingOptionIndex.value = null
|
||||
return
|
||||
}
|
||||
|
||||
commitHoveredOption(index)
|
||||
}, 180)
|
||||
}
|
||||
|
||||
function commitHoveredOption(index: number) {
|
||||
const option = options.value[index]
|
||||
if (option && hasChildren(option)) {
|
||||
activateSubmenu(index)
|
||||
} else {
|
||||
deactivateSubmenu()
|
||||
}
|
||||
}
|
||||
|
||||
function activateSubmenu(index: number) {
|
||||
clearPendingOptionTimeout()
|
||||
pendingOptionIndex.value = null
|
||||
activeOptionIndex.value = index
|
||||
hasSubmenuPosition.value = false
|
||||
scheduleSubmenuPositionUpdate()
|
||||
}
|
||||
|
||||
function deactivateSubmenu() {
|
||||
clearPendingOptionTimeout()
|
||||
activeOptionIndex.value = null
|
||||
pendingOptionIndex.value = null
|
||||
hasSubmenuPosition.value = false
|
||||
isCursorInsideSubmenu.value = false
|
||||
lastMousePosition.value = null
|
||||
previousMousePosition = null
|
||||
}
|
||||
|
||||
function returnToMenu() {
|
||||
const previousIndex = activeOptionIndex.value
|
||||
deactivateSubmenu()
|
||||
nextTick(() => {
|
||||
if (previousIndex !== null) optionButtonRefs.get(previousIndex)?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
function handleSubmenuMouseEnter() {
|
||||
isCursorInsideSubmenu.value = true
|
||||
clearPendingOptionTimeout()
|
||||
pendingOptionIndex.value = null
|
||||
}
|
||||
|
||||
function handleMenuMouseMove(event: MouseEvent, source: 'menu' | 'submenu') {
|
||||
previousMousePosition = lastMousePosition.value
|
||||
lastMousePosition.value = { x: event.clientX, y: event.clientY }
|
||||
|
||||
if (
|
||||
source === 'menu' &&
|
||||
pendingOptionIndex.value !== null &&
|
||||
!isCursorAimingAtSubmenu(lastMousePosition.value, previousMousePosition)
|
||||
) {
|
||||
commitHoveredOption(pendingOptionIndex.value)
|
||||
}
|
||||
}
|
||||
|
||||
function isCursorAimingAtSubmenu(cursor: Point | null, origin: Point | null) {
|
||||
const submenuRect = submenu.value?.getBoundingClientRect()
|
||||
if (!submenuRect || !cursor || !origin) return false
|
||||
|
||||
const submenuTargetX =
|
||||
origin.x <= submenuRect.left
|
||||
? submenuRect.left
|
||||
: origin.x >= submenuRect.right
|
||||
? submenuRect.right
|
||||
: cursor.x <= submenuRect.left
|
||||
? submenuRect.left
|
||||
: submenuRect.right
|
||||
const upperTarget = { x: submenuTargetX, y: submenuRect.top - 20 }
|
||||
const lowerTarget = { x: submenuTargetX, y: submenuRect.bottom + 20 }
|
||||
|
||||
return isPointInTriangle(cursor, origin, upperTarget, lowerTarget)
|
||||
}
|
||||
|
||||
function handleMenuKeydown(event: KeyboardEvent) {
|
||||
const buttons = getFocusableButtons(optionButtonRefs)
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
focusRelativeButton(buttons, event.key === 'ArrowDown' ? 1 : -1)
|
||||
} else if (event.key === 'Home' || event.key === 'End') {
|
||||
event.preventDefault()
|
||||
buttons[event.key === 'Home' ? 0 : buttons.length - 1]?.focus()
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
const focusedIndex = [...optionButtonRefs.entries()].find(
|
||||
([, button]) => button === document.activeElement,
|
||||
)?.[0]
|
||||
const focusedOption = focusedIndex === undefined ? undefined : options.value[focusedIndex]
|
||||
if (focusedIndex !== undefined && focusedOption && hasChildren(focusedOption)) {
|
||||
event.preventDefault()
|
||||
activateSubmenu(focusedIndex)
|
||||
nextTick(() => getFocusableButtons(submenuButtonRefs)[0]?.focus())
|
||||
}
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
hideMenu()
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmenuKeydown(event: KeyboardEvent) {
|
||||
const buttons = getFocusableButtons(submenuButtonRefs)
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
focusRelativeButton(buttons, event.key === 'ArrowDown' ? 1 : -1)
|
||||
} else if (event.key === 'Home' || event.key === 'End') {
|
||||
event.preventDefault()
|
||||
buttons[event.key === 'Home' ? 0 : buttons.length - 1]?.focus()
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault()
|
||||
returnToMenu()
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
hideMenu()
|
||||
}
|
||||
}
|
||||
|
||||
function syncMobileSubmenuLayout(event?: MediaQueryListEvent) {
|
||||
isMobileSubmenuLayout.value = event?.matches ?? mobileSubmenuMediaQuery?.matches ?? false
|
||||
}
|
||||
|
||||
function handleDocumentKeydown(event: KeyboardEvent) {
|
||||
if (shown.value && event.key === 'Escape') hideMenu()
|
||||
}
|
||||
|
||||
function handleContextMenuOpen(event: Event) {
|
||||
if (shown.value && event instanceof CustomEvent && event.detail !== contextMenuId) hideMenu()
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const target = event.target
|
||||
if (!(target instanceof Node)) return
|
||||
if (!contextMenu.value?.contains(target) && !submenu.value?.contains(target)) hideMenu()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
mobileSubmenuMediaQuery = window.matchMedia(MOBILE_SUBMENU_LAYOUT_QUERY)
|
||||
syncMobileSubmenuLayout()
|
||||
mobileSubmenuMediaQuery.addEventListener('change', syncMobileSubmenuLayout)
|
||||
window.addEventListener('click', handleClickOutside)
|
||||
window.addEventListener('resize', schedulePositionUpdate)
|
||||
window.addEventListener('scroll', schedulePositionUpdate, true)
|
||||
window.visualViewport?.addEventListener('scroll', schedulePositionUpdate)
|
||||
window.visualViewport?.addEventListener('resize', schedulePositionUpdate)
|
||||
window.addEventListener(CONTEXT_MENU_OPEN_EVENT, handleContextMenuOpen)
|
||||
document.addEventListener('keydown', handleDocumentKeydown)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearPendingOptionTimeout()
|
||||
cancelPositionUpdate()
|
||||
mobileSubmenuMediaQuery?.removeEventListener('change', syncMobileSubmenuLayout)
|
||||
window.removeEventListener('click', handleClickOutside)
|
||||
window.removeEventListener('resize', schedulePositionUpdate)
|
||||
window.removeEventListener('scroll', schedulePositionUpdate, true)
|
||||
window.visualViewport?.removeEventListener('scroll', schedulePositionUpdate)
|
||||
window.visualViewport?.removeEventListener('resize', schedulePositionUpdate)
|
||||
window.removeEventListener(CONTEXT_MENU_OPEN_EVENT, handleContextMenuOpen)
|
||||
document.removeEventListener('keydown', handleDocumentKeydown)
|
||||
})
|
||||
|
||||
function clearPendingOptionTimeout() {
|
||||
if (!pendingOptionTimeout) return
|
||||
clearTimeout(pendingOptionTimeout)
|
||||
pendingOptionTimeout = null
|
||||
}
|
||||
|
||||
return {
|
||||
shown,
|
||||
contextMenu,
|
||||
submenu,
|
||||
options,
|
||||
menuStyle,
|
||||
submenuStyle,
|
||||
activeOption,
|
||||
activeOptionIndex,
|
||||
pendingOptionIndex,
|
||||
hasSubmenuPosition,
|
||||
isMobileSubmenuLayout,
|
||||
isMobileActiveSubmenu,
|
||||
isCursorInsideSubmenu,
|
||||
isOptionVisible,
|
||||
setOptionButtonRef,
|
||||
setSubmenuButtonRef,
|
||||
showMenu,
|
||||
hideMenu,
|
||||
handleOptionClick,
|
||||
optionClicked,
|
||||
handleOptionFocus,
|
||||
handleOptionMouseEnter,
|
||||
returnToMenu,
|
||||
handleSubmenuMouseEnter,
|
||||
handleMenuMouseMove,
|
||||
handleMenuKeydown,
|
||||
handleSubmenuKeydown,
|
||||
}
|
||||
}
|
||||
|
||||
function setButtonRef(
|
||||
buttonRefs: Map<number, HTMLElement>,
|
||||
index: number,
|
||||
element: ButtonRefElement,
|
||||
) {
|
||||
if (element instanceof HTMLElement) {
|
||||
buttonRefs.set(index, element)
|
||||
} else {
|
||||
buttonRefs.delete(index)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type {
|
||||
ContextMenuAction,
|
||||
ContextMenuDivider,
|
||||
ContextMenuOption,
|
||||
ContextMenuParentAction,
|
||||
Point,
|
||||
} from './types'
|
||||
|
||||
export function isAction(option: ContextMenuOption): option is ContextMenuAction {
|
||||
return 'name' in option
|
||||
}
|
||||
|
||||
export function isDivider(option: ContextMenuOption): option is ContextMenuDivider {
|
||||
return 'type' in option && option.type === 'divider'
|
||||
}
|
||||
|
||||
export function hasChildren(option: ContextMenuOption): option is ContextMenuParentAction {
|
||||
return isAction(option) && Boolean(option.children?.length)
|
||||
}
|
||||
|
||||
export function isInstanceLink(value: unknown) {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
|
||||
if ('instance' in value) {
|
||||
const instance = value.instance
|
||||
return Boolean(instance && typeof instance === 'object' && 'link' in instance && instance.link)
|
||||
}
|
||||
|
||||
return 'link' in value && Boolean(value.link)
|
||||
}
|
||||
|
||||
export function getFocusableButtons(buttonRefs: Map<number, HTMLElement>) {
|
||||
return [...buttonRefs.entries()]
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([, button]) => button)
|
||||
.filter((button) => button.offsetParent !== null)
|
||||
}
|
||||
|
||||
export function focusRelativeButton(buttons: HTMLElement[], direction: 1 | -1) {
|
||||
if (!buttons.length) return
|
||||
|
||||
const currentIndex = buttons.indexOf(document.activeElement as HTMLElement)
|
||||
const nextIndex =
|
||||
currentIndex === -1 ? (direction === 1 ? 0 : buttons.length - 1) : currentIndex + direction
|
||||
buttons[(nextIndex + buttons.length) % buttons.length]?.focus()
|
||||
}
|
||||
|
||||
export function isPointInTriangle(point: Point, a: Point, b: Point, c: Point) {
|
||||
const area = triangleArea(a, b, c)
|
||||
const area1 = triangleArea(point, b, c)
|
||||
const area2 = triangleArea(a, point, c)
|
||||
const area3 = triangleArea(a, b, point)
|
||||
|
||||
return Math.abs(area - (area1 + area2 + area3)) < 0.5
|
||||
}
|
||||
|
||||
function triangleArea(a: Point, b: Point, c: Point) {
|
||||
return Math.abs((a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y)) / 2)
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { useTemplateRef } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import ContextMenu from '@/components/ui/context-menu/index.vue'
|
||||
import type { FriendWithUserData } from '@/helpers/friends.ts'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
@@ -14,7 +14,7 @@ import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { computed, nextTick, onDeactivated, onUnmounted, ref, toRef, watch } from 'vue'
|
||||
import Draggable from 'vuedraggable'
|
||||
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import ContextMenu from '@/components/ui/context-menu/index.vue'
|
||||
import GroupInstancesModal from '@/components/ui/library/group-instances-modal.vue'
|
||||
import InstanceGroup from '@/components/ui/library/instance-group/index.vue'
|
||||
import InstanceGroupDnd from '@/components/ui/library/instance-group/instance-group-dnd.vue'
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
} from '@modrinth/ui'
|
||||
import { computed, inject, nextTick, onActivated, onDeactivated, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import ContextMenu from '@/components/ui/context-menu/index.vue'
|
||||
import GroupActionButtons from '@/components/ui/library/instance-group/group-action-buttons.vue'
|
||||
import InstanceCard from '@/components/ui/library/instance-group/instance-card.vue'
|
||||
import type {
|
||||
|
||||
@@ -332,6 +332,9 @@
|
||||
"app.home.jump-back-in.new-instance": {
|
||||
"message": "New instance"
|
||||
},
|
||||
"app.home.jump-back-in.resize": {
|
||||
"message": "Drag to resize"
|
||||
},
|
||||
"app.home.jump-back-in.title": {
|
||||
"message": "Jump in"
|
||||
},
|
||||
|
||||
@@ -38,7 +38,7 @@ import { computed, ref, shallowRef, watch } from 'vue'
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import ContextMenu from '@/components/ui/context-menu/index.vue'
|
||||
import { useAppServerBrowse } from '@/composables/browse/use-app-server-browse'
|
||||
import { useAppEvent } from '@/composables/use-app-event'
|
||||
import { get_project, get_search_results_v3, get_version_many } from '@/helpers/cache.js'
|
||||
|
||||
@@ -4,7 +4,7 @@ import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/u
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, inject, onActivated, ref } from 'vue'
|
||||
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import ContextMenu from '@/components/ui/context-menu/index.vue'
|
||||
import LibrarySection from '@/components/ui/library/index.vue'
|
||||
import WelcomeScreen from '@/components/ui/WelcomeScreen.vue'
|
||||
import RecentWorldsList from '@/components/ui/world/RecentWorldsList.vue'
|
||||
|
||||
@@ -122,7 +122,7 @@ import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import { computed, type ComputedRef, onUnmounted, ref, shallowRef, watch } from 'vue'
|
||||
import { onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import ContextMenu from '@/components/ui/context-menu/index.vue'
|
||||
import ExportModal from '@/components/ui/ExportModal.vue'
|
||||
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
|
||||
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
|
||||
|
||||
@@ -287,7 +287,7 @@ import { computed, ref, shallowRef, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { SwapIcon } from '@/assets/icons/index.js'
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import ContextMenu from '@/components/ui/context-menu/index.vue'
|
||||
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
|
||||
import {
|
||||
fetchCachedServerStatus,
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { CopyIcon, FolderOpenIcon, PlayIcon, SettingsIcon, TrashIcon } from '@modrinth/assets'
|
||||
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'
|
||||
|
||||
const options: ContextMenuOption[] = [
|
||||
{ name: 'play', color: 'primary' },
|
||||
{
|
||||
name: 'copy',
|
||||
children: [{ name: 'copy_name' }, { name: 'copy_path' }, { name: 'copy_id' }],
|
||||
},
|
||||
{ name: 'open_folder' },
|
||||
{ type: 'divider' },
|
||||
{ name: 'settings' },
|
||||
{ name: 'delete', color: 'danger' },
|
||||
]
|
||||
|
||||
const meta = {
|
||||
title: 'App/Context Menu',
|
||||
component: ContextMenu,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
args: {
|
||||
onMenuClosed: fn(),
|
||||
onOptionClicked: fn(),
|
||||
},
|
||||
render: (args) => ({
|
||||
components: {
|
||||
ContextMenu,
|
||||
CopyIcon,
|
||||
FolderOpenIcon,
|
||||
PlayIcon,
|
||||
SettingsIcon,
|
||||
TrashIcon,
|
||||
},
|
||||
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)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
const rect = target.value?.getBoundingClientRect()
|
||||
openMenu(
|
||||
new MouseEvent('contextmenu', {
|
||||
clientX: (rect?.left ?? 80) + 80,
|
||||
clientY: (rect?.top ?? 80) + 80,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
return { args, contextMenu, openMenu, target }
|
||||
},
|
||||
template: /*html*/ `
|
||||
<div
|
||||
ref="target"
|
||||
style="box-sizing: border-box; min-height: 100vh; padding: 5rem; background: var(--color-bg); color: var(--color-text-primary);"
|
||||
@contextmenu.prevent.stop="openMenu"
|
||||
>
|
||||
<div style="max-width: 32rem; border: 1px dashed var(--color-divider); border-radius: var(--radius-lg); padding: 2rem;">
|
||||
<p style="margin: 0; color: var(--color-text-secondary);">
|
||||
Right-click anywhere in this panel to reopen the menu.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
} satisfies Meta<typeof ContextMenu>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
Reference in New Issue
Block a user