diff --git a/apps/app-frontend/src/components/ui/ContextMenu.vue b/apps/app-frontend/src/components/ui/ContextMenu.vue deleted file mode 100644 index 42d34cda4b..0000000000 --- a/apps/app-frontend/src/components/ui/ContextMenu.vue +++ /dev/null @@ -1,203 +0,0 @@ - - - - - diff --git a/apps/app-frontend/src/components/ui/context-menu/index.vue b/apps/app-frontend/src/components/ui/context-menu/index.vue new file mode 100644 index 0000000000..93732168bb --- /dev/null +++ b/apps/app-frontend/src/components/ui/context-menu/index.vue @@ -0,0 +1,236 @@ + + + + + diff --git a/apps/app-frontend/src/components/ui/context-menu/types.ts b/apps/app-frontend/src/components/ui/context-menu/types.ts new file mode 100644 index 0000000000..fe45d2b6ff --- /dev/null +++ b/apps/app-frontend/src/components/ui/context-menu/types.ts @@ -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 diff --git a/apps/app-frontend/src/components/ui/context-menu/use-context-menu-position.ts b/apps/app-frontend/src/components/ui/context-menu/use-context-menu-position.ts new file mode 100644 index 0000000000..7ffa4fef4f --- /dev/null +++ b/apps/app-frontend/src/components/ui/context-menu/use-context-menu-position.ts @@ -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 + activeOption: Ref + activeOptionIndex: Ref + isMobileSubmenuLayout: Ref + contextMenu: Ref + submenu: Ref + optionButtonRefs: Map +} + +export function useContextMenuPosition({ + shown, + activeOption, + activeOptionIndex, + isMobileSubmenuLayout, + contextMenu, + submenu, + optionButtonRefs, +}: ContextMenuPositionOptions) { + const menuStyle = ref({ left: '0px', top: '0px' }) + const menuAnchor = ref({ x: 0, y: 0 }) + const submenuPosition = ref({ x: 0, y: 0 }) + const hasSubmenuPosition = ref(false) + let positionRafId: number | null = null + + const submenuStyle = computed(() => { + 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' +} diff --git a/apps/app-frontend/src/components/ui/context-menu/use-context-menu.ts b/apps/app-frontend/src/components/ui/context-menu/use-context-menu.ts new file mode 100644 index 0000000000..a74b5012e0 --- /dev/null +++ b/apps/app-frontend/src/components/ui/context-menu/use-context-menu.ts @@ -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(null) + const contextMenu = ref(null) + const submenu = ref(null) + const options = ref([]) + const shown = ref(false) + const activeOptionIndex = ref(null) + const pendingOptionIndex = ref(null) + const isCursorInsideSubmenu = ref(false) + const isMobileSubmenuLayout = ref(false) + const lastMousePosition = ref(null) + const contextMenuId = Symbol() + const optionButtonRefs = new Map() + const submenuButtonRefs = new Map() + let previousMousePosition: Point | null = null + let pendingOptionTimeout: ReturnType | null = null + let mobileSubmenuMediaQuery: MediaQueryList | null = null + + const activeOption = computed(() => { + 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, + index: number, + element: ButtonRefElement, +) { + if (element instanceof HTMLElement) { + buttonRefs.set(index, element) + } else { + buttonRefs.delete(index) + } +} diff --git a/apps/app-frontend/src/components/ui/context-menu/utils.ts b/apps/app-frontend/src/components/ui/context-menu/utils.ts new file mode 100644 index 0000000000..e7bd7c79d2 --- /dev/null +++ b/apps/app-frontend/src/components/ui/context-menu/utils.ts @@ -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) { + 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) +} diff --git a/apps/app-frontend/src/components/ui/friends/FriendsSection.vue b/apps/app-frontend/src/components/ui/friends/FriendsSection.vue index e53e7cc0f6..e9678ec7b7 100644 --- a/apps/app-frontend/src/components/ui/friends/FriendsSection.vue +++ b/apps/app-frontend/src/components/ui/friends/FriendsSection.vue @@ -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() diff --git a/apps/app-frontend/src/components/ui/library/index.vue b/apps/app-frontend/src/components/ui/library/index.vue index fe9d64f433..b3e6ad4cb3 100644 --- a/apps/app-frontend/src/components/ui/library/index.vue +++ b/apps/app-frontend/src/components/ui/library/index.vue @@ -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' diff --git a/apps/app-frontend/src/components/ui/library/instance-group/index.vue b/apps/app-frontend/src/components/ui/library/instance-group/index.vue index 11bc5397bc..4a179654b8 100644 --- a/apps/app-frontend/src/components/ui/library/instance-group/index.vue +++ b/apps/app-frontend/src/components/ui/library/instance-group/index.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 { diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json index e366aa6558..863a4436fd 100644 --- a/apps/app-frontend/src/locales/en-US/index.json +++ b/apps/app-frontend/src/locales/en-US/index.json @@ -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" }, diff --git a/apps/app-frontend/src/pages/Browse.vue b/apps/app-frontend/src/pages/Browse.vue index 0a7a18263d..d4f5a895dd 100644 --- a/apps/app-frontend/src/pages/Browse.vue +++ b/apps/app-frontend/src/pages/Browse.vue @@ -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' diff --git a/apps/app-frontend/src/pages/Index.vue b/apps/app-frontend/src/pages/Index.vue index 93fc59a663..89b0661d9d 100644 --- a/apps/app-frontend/src/pages/Index.vue +++ b/apps/app-frontend/src/pages/Index.vue @@ -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' diff --git a/apps/app-frontend/src/pages/instance/layout.vue b/apps/app-frontend/src/pages/instance/layout.vue index 6a9f27d4f1..664bcc322a 100644 --- a/apps/app-frontend/src/pages/instance/layout.vue +++ b/apps/app-frontend/src/pages/instance/layout.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' diff --git a/apps/app-frontend/src/pages/project/Index.vue b/apps/app-frontend/src/pages/project/Index.vue index 798a9e97e6..d5e75cb711 100644 --- a/apps/app-frontend/src/pages/project/Index.vue +++ b/apps/app-frontend/src/pages/project/Index.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, diff --git a/packages/ui/src/stories/app/ContextMenu.stories.ts b/packages/ui/src/stories/app/ContextMenu.stories.ts new file mode 100644 index 0000000000..a521f89caf --- /dev/null +++ b/packages/ui/src/stories/app/ContextMenu.stories.ts @@ -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>() + const target = ref() + 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*/ ` +
+
+

+ Right-click anywhere in this panel to reopen the menu. +

+
+ + + + + + + + + + + +
+ `, + }), +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = {}