Modrinth/apps/frontend/src/components/ui/servers/TeleportOverflowMenu.vue
Prospector c39bb78e38
App redesign (#2946)
* Start of app redesign

* format

* continue progress

* Content page nearly done

* Fix recursion issues with content page

* Fix update all alignment

* Discover page progress

* Settings progress

* Removed unlocked-size hack that breaks web

* Revamp project page, refactor web project page to share code with app, fixed loading bar, misc UI/UX enhancements, update ko-fi logo, update arrow icons, fix web issues caused by floating-vue migration, fix tooltip issues, update web tooltips, clean up web hydration issues

* Ads + run prettier

* Begin auth refactor, move common messages to ui lib, add i18n extraction to all apps, begin Library refactor

* fix ads not hiding when plus log in

* rev lockfile changes/conflicts

* Fix sign in page

* Add generated

* (mostly) Data driven search

* Fix search mobile issue

* profile fixes

* Project versions page, fix typescript on UI lib and misc fixes

* Remove unused gallery component

* Fix linkfunction err

* Search filter controls at top, localization for locked filters

* Fix provided filter names

* Fix navigating from instance browse to main browse

* Friends frontend (#2995)

* Friends system frontend

* (almost) finish frontend

* finish friends, fix lint

* Fix lint

---------

Signed-off-by: Geometrically <18202329+Geometrically@users.noreply.github.com>

* Refresh macOS app icon

* Update web search UI more

* Fix link opens

* Fix frontend build

---------

Signed-off-by: Geometrically <18202329+Geometrically@users.noreply.github.com>
Co-authored-by: Jai A <jaiagr+gpg@pm.me>
Co-authored-by: Geometrically <18202329+Geometrically@users.noreply.github.com>
2024-12-11 19:54:18 -08:00

434 lines
13 KiB
Vue

<template>
<div data-pyro-telepopover-wrapper class="relative">
<button
ref="triggerRef"
class="teleport-overflow-menu-trigger"
:aria-expanded="isOpen"
:aria-haspopup="true"
@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="experimental-styles-within fixed isolate z-[9999] flex w-fit flex-col gap-2 overflow-hidden rounded-2xl border-[1px] border-solid border-divider bg-bg-raised p-2 shadow-lg"
:style="menuStyle"
role="menu"
tabindex="-1"
@mousedown.stop
@mouseleave="handleMouseLeave"
>
<ButtonStyled
v-for="(option, index) in filteredOptions"
:key="option.id"
type="transparent"
role="menuitem"
:color="option.color"
>
<button
v-if="typeof option.action === 'function'"
:ref="
(el) => {
if (el) menuItemsRef[index] = el as HTMLElement;
}
"
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
:aria-selected="index === selectedIndex"
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
@click="handleItemClick(option, index)"
@focus="selectedIndex = index"
@mouseover="handleMouseOver(index)"
>
<slot :name="option.id">{{ option.id }}</slot>
</button>
<nuxt-link
v-else-if="typeof option.action === 'string' && option.action.startsWith('/')"
:ref="
(el) => {
if (el) menuItemsRef[index] = el as HTMLElement;
}
"
:to="option.action"
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
:aria-selected="index === selectedIndex"
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
@click="handleItemClick(option, index)"
@focus="selectedIndex = index"
@mouseover="handleMouseOver(index)"
>
<slot :name="option.id">{{ option.id }}</slot>
</nuxt-link>
<a
v-else-if="typeof option.action === 'string' && !option.action.startsWith('http')"
:ref="
(el) => {
if (el) menuItemsRef[index] = el as HTMLElement;
}
"
:href="option.action"
target="_blank"
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
:aria-selected="index === selectedIndex"
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
@click="handleItemClick(option, index)"
@focus="selectedIndex = index"
@mouseover="handleMouseOver(index)"
>
<slot :name="option.id">{{ option.id }}</slot>
</a>
<span v-else>
<slot :name="option.id">{{ option.id }}</slot>
</span>
</ButtonStyled>
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ButtonStyled } from "@modrinth/ui";
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from "vue";
import { onClickOutside, useElementHover } from "@vueuse/core";
interface Option {
id: string;
action?: (() => void) | string;
shown?: boolean;
color?: "standard" | "brand" | "red" | "orange" | "green" | "blue" | "purple";
}
const props = withDefaults(
defineProps<{
options: Option[];
hoverable?: boolean;
}>(),
{
hoverable: false,
},
);
const emit = defineEmits<{
(e: "select", option: Option): void;
}>();
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({
top: "0px",
left: "0px",
});
const filteredOptions = computed(() => props.options.filter((option) => option.shown !== false));
const calculateMenuPosition = () => {
if (!triggerRef.value || !menuRef.value) return { top: "0px", left: "0px" };
const triggerRect = triggerRef.value.getBoundingClientRect();
const menuRect = menuRef.value.getBoundingClientRect();
const menuWidth = menuRect.width;
const menuHeight = menuRect.height;
const margin = 8;
let top: number;
let left: number;
// okay gang lets calculate this shit
// from the top now yall
// y
if (triggerRect.bottom + menuHeight + margin <= window.innerHeight) {
top = triggerRect.bottom + margin;
} else if (triggerRect.top - menuHeight - margin >= 0) {
top = triggerRect.top - menuHeight - margin;
} else {
top = Math.max(margin, window.innerHeight - menuHeight - margin);
}
// x
if (triggerRect.left + menuWidth + margin <= window.innerWidth) {
left = triggerRect.left;
} else if (triggerRect.right - menuWidth - margin >= 0) {
left = triggerRect.right - menuWidth;
} else {
left = Math.max(margin, window.innerWidth - menuWidth - margin);
}
return {
top: `${top}px`,
left: `${left}px`,
};
};
const toggleMenu = (event: MouseEvent) => {
event.stopPropagation();
if (!props.hoverable) {
if (isOpen.value) {
closeMenu();
} else {
openMenu();
}
}
};
const openMenu = () => {
isOpen.value = true;
disableBodyScroll();
nextTick(() => {
menuStyle.value = calculateMenuPosition();
document.addEventListener("mousemove", handleMouseMove);
focusFirstMenuItem();
});
};
const closeMenu = () => {
isOpen.value = false;
selectedIndex.value = -1;
enableBodyScroll();
document.removeEventListener("mousemove", handleMouseMove);
};
const selectOption = (option: Option) => {
emit("select", option);
if (typeof option.action === "function") {
option.action();
}
closeMenu();
};
const handleMouseDown = (event: MouseEvent) => {
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) => {
selectedIndex.value = index;
selectOption(option);
};
const handleMouseOver = (index: number) => {
selectedIndex.value = index;
menuItemsRef.value[selectedIndex.value].focus?.();
};
// Scrolling is disabled for keyboard navigation
const disableBodyScroll = () => {
// Make opening not shift page when there's a vertical scrollbar
const scrollBarWidth = window.innerWidth - document.documentElement.clientWidth;
if (scrollBarWidth > 0) {
document.body.style.paddingRight = `${scrollBarWidth}px`;
} else {
document.body.style.paddingRight = "";
}
document.body.style.overflow = "hidden";
};
const enableBodyScroll = () => {
document.body.style.paddingRight = "";
document.body.style.overflow = "";
};
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) {
selectOption(filteredOptions.value[selectedIndex.value]);
}
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) =>
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 handleResizeOrScroll = () => {
if (isOpen.value) {
menuStyle.value = calculateMenuPosition();
}
};
const throttle = (func: (...args: any[]) => void, limit: number): ((...args: any[]) => void) => {
let inThrottle: boolean;
return function (...args: any[]) {
if (!inThrottle) {
func(...args);
inThrottle = true;
setTimeout(() => (inThrottle = false), limit);
}
};
};
const throttledHandleResizeOrScroll = throttle(handleResizeOrScroll, 100);
onMounted(() => {
triggerRef.value?.addEventListener("keydown", handleKeydown);
window.addEventListener("resize", throttledHandleResizeOrScroll);
window.addEventListener("scroll", throttledHandleResizeOrScroll);
});
onUnmounted(() => {
triggerRef.value?.removeEventListener("keydown", handleKeydown);
window.removeEventListener("resize", throttledHandleResizeOrScroll);
window.removeEventListener("scroll", throttledHandleResizeOrScroll);
document.removeEventListener("mousemove", handleMouseMove);
if (typeAheadTimeout.value) {
clearTimeout(typeAheadTimeout.value);
}
enableBodyScroll();
});
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();
}
});
</script>