You've already forked AstralRinth
forked from didirus/AstralRinth
Pyro Integration (#2503)
* fix Signed-off-by: Evan Song <theevansong@gmail.com> * fix Signed-off-by: Evan Song <theevansong@gmail.com> * refactor(fileitem): optimize Signed-off-by: Evan Song <theevansong@gmail.com> * chore(fileitem): fixed width timestamp Signed-off-by: Evan Song <theevansong@gmail.com> * fix(fileitem): allow editing json5/jsonc Signed-off-by: Evan Song <theevansong@gmail.com> * feat: motd pt 1, auto backups scaffolding, editing navbar changes * feat: fancy sidebar animations * fix: files * fix: files pt2 * fix: faulty name validation disallowing spaces in file names Signed-off-by: Evan Song <theevansong@gmail.com> * refactor: fileitem props Signed-off-by: Evan Song <theevansong@gmail.com> * fix: upload files not refreshing files list Signed-off-by: Evan Song <theevansong@gmail.com> * fix(imgviewer): handle invalid/empty images Signed-off-by: Evan Song <theevansong@gmail.com> * fix: return of the sticky files header Signed-off-by: Evan Song <theevansong@gmail.com> * chore: prevent servericon from shrinking Signed-off-by: Evan Song <theevansong@gmail.com> * fix: wtf were we thinking with this anyway Signed-off-by: Evan Song <theevansong@gmail.com> * fix: further mobile optimization Signed-off-by: Evan Song <theevansong@gmail.com> * chore: propagate margin Signed-off-by: Evan Song <theevansong@gmail.com> * chore: truncation fixes Signed-off-by: Evan Song <theevansong@gmail.com> * fix: track navbar with sentinel Signed-off-by: Evan Song <theevansong@gmail.com> * chore: clean Signed-off-by: Evan Song <theevansong@gmail.com> * fix(files): a11y Signed-off-by: Evan Song <theevansong@gmail.com> * chore: improve inspector styles Signed-off-by: Evan Song <theevansong@gmail.com> * chore: clean Signed-off-by: Evan Song <theevansong@gmail.com> * feat: console preformance improvements, decrease blur * feat(mobile): new server header * fix: linting * fix: useless z indeces Signed-off-by: Evan Song <theevansong@gmail.com> * chore: adjust file filter names Signed-off-by: Evan Song <theevansong@gmail.com> * feat(files): true breadcrumbs Signed-off-by: Evan Song <theevansong@gmail.com> * fix(marketing): make custom responsive * fix(marketing): mobile file manager card * feat: trackable navtabs Signed-off-by: Evan Song <theevansong@gmail.com> * fix: oh no Signed-off-by: Evan Song <theevansong@gmail.com> * fix: smartly truncate Signed-off-by: Evan Song <theevansong@gmail.com> * fix(terminal): z-indexes * fix: autofocus more inputs Signed-off-by: Evan Song <theevansong@gmail.com> * fix: color Signed-off-by: Evan Song <theevansong@gmail.com> * chore: adjust copy Signed-off-by: Evan Song <theevansong@gmail.com> * chore: backup modal usability improvements Signed-off-by: Evan Song <theevansong@gmail.com> * fix: padding Signed-off-by: Evan Song <theevansong@gmail.com> * chore: title Signed-off-by: Evan Song <theevansong@gmail.com> * fix(content): update banner mobile support * fix: server listing icons Signed-off-by: Evan Song <theevansong@gmail.com> * fix: ignore clicks in server listing for labels Signed-off-by: Evan Song <theevansong@gmail.com> * feat(mobile): backup card * fix(backups): make plural conditional * fix: debounce file item selectitem Signed-off-by: Evan Song <theevansong@gmail.com> * fix: lint Signed-off-by: Evan Song <theevansong@gmail.com> * stuff Signed-off-by: Evan Song <theevansong@gmail.com> * fix: temp sidebar fix until i can be smart * chore: clean Signed-off-by: Evan Song <theevansong@gmail.com> * chore: explictly set button type in file modals Signed-off-by: Evan Song <theevansong@gmail.com> * fix: properly sort backups Signed-off-by: Evan Song <theevansong@gmail.com> * feat: add getautobackup method to pyroservers Signed-off-by: Evan Song <theevansong@gmail.com> * choer: update autobackup params Signed-off-by: Evan Song <theevansong@gmail.com> * chore: update autobackup methods (REALLY GUYS) Signed-off-by: Evan Song <theevansong@gmail.com> * feat: implement autobackups Signed-off-by: Evan Song <theevansong@gmail.com> * feat: implement backup-while-running preference Signed-off-by: Evan Song <theevansong@gmail.com> * feat: make server labels a component * feat: implement 'All details' modal * fix(mobile): server manage page * feat(files): mobile compatible * fix(info labels): wrap * chore(inspector): clean Signed-off-by: Evan Song <theevansong@gmail.com> * fix(backup settings): swap + and - * fix(manage): new -> plans instead of modal * feat: more small mobile fixes * fix(auto backup modal): manual input validation * fix(file browse navbar): home margin * feat(purchase modal): mobile support * fix(marketing): faded line alignments * feat: add servers to mobile nav * feat(network): dns record fixes * feat: make all settings work on mobile * fix(loader settings): modpack mobile * chore: clean Signed-off-by: Evan Song <theevansong@gmail.com> * feat(marketing): add 'Manage your servers' button * fix(marketing): only check servers if logged in * fix(network): allocation edit & delete button * fix(backups): use UiServersTeleportOverflowMenu * chore: linting * chore: but here comes the sentence case Signed-off-by: Evan Song <theevansong@gmail.com> * feat(marketing): make buttons consistent * lint Signed-off-by: Evan Song <theevansong@gmail.com> * fix(loader): prevent multiline version names in dropdown Signed-off-by: Evan Song <theevansong@gmail.com> * lint Signed-off-by: Evan Song <theevansong@gmail.com> * fix: copy Signed-off-by: Evan Song <theevansong@gmail.com> * fix: sentence case Signed-off-by: Evan Song <theevansong@gmail.com> * fix: linting * chore: rename dumbass preference key Signed-off-by: Evan Song <theevansong@gmail.com> * refactor: rewrite power action buttons Signed-off-by: Evan Song <theevansong@gmail.com> * fix: robust download logic Signed-off-by: Evan Song <theevansong@gmail.com> * fix(loader mobile): modpack dropdown width * fix: sentence case * fix(save & 'working on it'): look good on mobile * fix(TeleportDropdown): width * fix(inspecting error): mobile * fix: show action button dropdown when installing * fix(navtabs): temp fix for mobile scrolling issue * fix(install error): mobile compatible * chore: just remove tracking Signed-off-by: Evan Song <theevansong@gmail.com> * chore: clean Signed-off-by: Evan Song <theevansong@gmail.com> * chore: clean Signed-off-by: Evan Song <theevansong@gmail.com> * fix: cleanup * fix: broken svg clr in checkbox when using experimental styles Signed-off-by: Evan Song <theevansong@gmail.com> * chore: adjust vanilla icon Signed-off-by: Evan Song <theevansong@gmail.com> * chore: adjust loader props Signed-off-by: Evan Song <theevansong@gmail.com> * revert changes to serversidebar Signed-off-by: Evan Song <theevansong@gmail.com> * fix: server properties flicker Signed-off-by: Evan Song <theevansong@gmail.com> * fix(backups): plural * fix: cases where the telepoverflow would clash with viewport edge Signed-off-by: Evan Song <theevansong@gmail.com> * feat(backups): auto-backups label * fix(network): titlecase * feat(fileitem): new rename icon * fix(properties): wiki proper noun * fix: disable motd for the time being * chore: adjust wording for power conifmration Signed-off-by: Evan Song <theevansong@gmail.com> * feat: "external" to billing Signed-off-by: Evan Song <theevansong@gmail.com> * fix: icon Signed-off-by: Evan Song <theevansong@gmail.com> * fix: add EULA checkbox * chore: clean Signed-off-by: Evan Song <theevansong@gmail.com> * me and bro deciding which case rules to enforce Signed-off-by: Evan Song <theevansong@gmail.com> * feat(sftp): copy address & username, launch tooltip * feat(files): better move * chore: attempt to mitigate excessive stack depth type Signed-off-by: Evan Song <theevansong@gmail.com> * fix(loader): prevent versions 1.2.4 and below * feat(dns table): placeholder improvements * feat(pyroServer): error handling * fix: intrinsic size on loader icon Signed-off-by: Evan Song <theevansong@gmail.com> * chore: adjust wording Signed-off-by: Evan Song <theevansong@gmail.com> * fix: sentence case Signed-off-by: Evan Song <theevansong@gmail.com> * chore: adjust wording Signed-off-by: Evan Song <theevansong@gmail.com> * fix: types Signed-off-by: Evan Song <theevansong@gmail.com> * fix: "implemented" key in preference Signed-off-by: Evan Song <theevansong@gmail.com> * feat(connection lost): redesign * feat(connection error): make icon orange * fix: cleanup * chore(connection lost): redesign pt 2 Signed-off-by: Evan Song <theevansong@gmail.com> * fix: OOOOHHH MY GOD Signed-off-by: Evan Song <theevansong@gmail.com> * feat: implement capacity api on marketing Signed-off-by: Evan Song <theevansong@gmail.com> * chore: update createdat backup type Signed-off-by: Evan Song <theevansong@gmail.com> * refactor: all of backups Signed-off-by: Evan Song <theevansong@gmail.com> * chore: update backup types Signed-off-by: Evan Song <theevansong@gmail.com> * refactor: backups pt 2 Signed-off-by: Evan Song <theevansong@gmail.com> * fix: comically small icons Signed-off-by: Evan Song <theevansong@gmail.com> * chore: align designs Signed-off-by: Evan Song <theevansong@gmail.com> * chore: hide ram graph if ram as bytes enabled Signed-off-by: Evan Song <theevansong@gmail.com> * base add content page * Fix conflict * feat(content): mobile-compatible header, sticky * fix(marketing): md instead of sm for custom * fix: compiler macro warning Signed-off-by: Evan Song <theevansong@gmail.com> * again Signed-off-by: Evan Song <theevansong@gmail.com> * fix: loader type error Signed-off-by: Evan Song <theevansong@gmail.com> * fix: default uptime seconds prop Signed-off-by: Evan Song <theevansong@gmail.com> * fix: hydration errors on server listing Signed-off-by: Evan Song <theevansong@gmail.com> * feat: move custom URL to general Signed-off-by: Evan Song <theevansong@gmail.com> * feat: indiviudally checkj capacities Signed-off-by: Evan Song <theevansong@gmail.com> * fix: falsey Signed-off-by: Evan Song <theevansong@gmail.com> * fix: missing prop Signed-off-by: Evan Song <theevansong@gmail.com> * chore: Derive On That Thang Signed-off-by: Evan Song <theevansong@gmail.com> * chore: adjust gap Signed-off-by: Evan Song <theevansong@gmail.com> * fix: add default name for backups * fix: the backup number should PROBABLY be computed lol * fix(backups): truncate text, mobile fixes * fix(loader): modpack mobile fix * feat(plans): add vcpus * fix(backup modal): blank by default, maxlength * fix(subdomain): separate length & valid chars * feat: mrpack installs functionality (untested), forbidden handling, backups grammar * feat(content): make responsive on mobile * fix: disable plan buttons separately * fix(backup modal): update name max length * fix(purchase): wrapping on eula, eula link * fix: move skeleton * fix(server mobile header): truncation * fix(server header): proper alignment * Finish content page fixes * fix: who up rinthing Signed-off-by: Evan Song <theevansong@gmail.com> * wip Signed-off-by: Evan Song <theevansong@gmail.com> * fix(staging & email banner): z-index * feat: make eula tickbox more visible * fix: move "powered by pyro" below buttons on hero * fix: oops sorry ellie, also updated the main screenshot * feat: update content screenshot * fix: content page card should hide image on lg * feat: hide total storage for now * fix: terminal card now uses terminal icon * fix(marketing): make medium plan card border solid * feat: modloader card, move pyro BACK below buttons, beta release pill * fix: spinning logo should be behind hero * feat: surgically remove the hero's massive forehead * feat(marketing): mobile UI screenshot * fix(hero): z-index goes over mobile nav * fix: consistent borders, files breakpoints * chore: update turbo * chore: adjust hero sizing Signed-off-by: Evan Song <theevansong@gmail.com> * chore: mention region restrictions * chore: double check if we are at capcity Signed-off-by: Evan Song <theevansong@gmail.com> * fix: measure twice cut once Signed-off-by: Evan Song <theevansong@gmail.com> * chore: bro cut twice and measured once 💀 Signed-off-by: Evan Song <theevansong@gmail.com> * fix(marketing): login first * fix: out of capacity text when logged out * fix(slider): reset some values for frontend * feat: wip hero section Signed-off-by: Evan Song <theevansong@gmail.com> * New navigation to support the new products (#2879) * Nav * oops extra file * feat: mrpack uploading with existing modpack, fix: choose modpack duplicate * chore: clean Signed-off-by: Evan Song <theevansong@gmail.com> * feat: update features section Signed-off-by: Evan Song <theevansong@gmail.com> * Nav adjustments * fix: server manager empty state clashing with loading state Signed-off-by: Evan Song <theevansong@gmail.com> * chore: query param hard Signed-off-by: Evan Song <theevansong@gmail.com> * fix: do not count uptime if crashed Signed-off-by: Evan Song <theevansong@gmail.com> * fix: grammar Signed-off-by: Evan Song <theevansong@gmail.com> * hide hero img on lg breakpoints * Make plugins a plug * chore: prep for buffered text selection terminal Signed-off-by: Evan Song <theevansong@gmail.com> * fix: marketing responsive stuff, n fixes * fix hoverable prop * fix: edit mod spacing * fix: type error for display name in dropdown Signed-off-by: Evan Song <theevansong@gmail.com> * feat: custom plans * fix: no more console.log * fix: properly linked prop label Signed-off-by: Evan Song <theevansong@gmail.com> * fix(install hero mobile): padding * fix: prevent x overflow on servers page Signed-off-by: Evan Song <theevansong@gmail.com> * fix lint oh ym fucking god yal Signed-off-by: Evan Song <theevansong@gmail.com> * Migrate modpack install to search * fix(custom plan): warning icon variable * fix: loading probally and modal loader things * fix(marketing): login icon colours * fix(marketing): responsiveness * fix(marketing): responsiveness v2 * fix: sync button for icon tm * fix(marketing): responsiveness v3 * fix: hero image Signed-off-by: Evan Song <theevansong@gmail.com> * chore: clean Signed-off-by: Evan Song <theevansong@gmail.com> * chore: switch to cdn links Signed-off-by: Evan Song <theevansong@gmail.com> * chore: switch to cdn links Signed-off-by: Evan Song <theevansong@gmail.com> * chore: switch to cdn links Signed-off-by: Evan Song <theevansong@gmail.com> * chore: switch to cdn links Signed-off-by: Evan Song <theevansong@gmail.com> * Remove prod override --------- Signed-off-by: Evan Song <theevansong@gmail.com> Co-authored-by: Evan Song <theevansong@gmail.com> Co-authored-by: TheWander02 <48934424+thewander02@users.noreply.github.com> Co-authored-by: he3als <65787561+he3als@users.noreply.github.com> Co-authored-by: Evan Song <52982404+ferothefox@users.noreply.github.com> Co-authored-by: Lio <git@lio.cat> Co-authored-by: Jai A <jaiagr+gpg@pm.me> Co-authored-by: not-nullptr <needhelpwithrift@gmail.com> Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com> Co-authored-by: Prospector <prospectordev@gmail.com> Co-authored-by: sticks <tanner@teamhydra.dev>
This commit is contained in:
433
apps/frontend/src/components/ui/servers/TeleportOverflowMenu.vue
Normal file
433
apps/frontend/src/components/ui/servers/TeleportOverflowMenu.vue
Normal file
@@ -0,0 +1,433 @@
|
||||
<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-button-bg 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>
|
||||
Reference in New Issue
Block a user