mirror of
https://codeberg.org/PLG-Development/PLG-MuDiCS
synced 2026-07-06 17:07:08 +00:00
189 lines
4.8 KiB
Svelte
189 lines
4.8 KiB
Svelte
<script lang="ts">
|
|
import { get_shifted_color } from '$lib/ts/stores/ui_behavior';
|
|
import type { MenuOption } from '$lib/ts/types';
|
|
import { fade } from 'svelte/transition';
|
|
|
|
let {
|
|
className = '',
|
|
bg = 'bg-stone-700',
|
|
hover_bg = get_shifted_color(bg, 100),
|
|
active_bg = get_shifted_color(bg, 200),
|
|
disabled = false,
|
|
title = '',
|
|
click_function = () => {},
|
|
menu_options = [],
|
|
menu_class = 'right-0',
|
|
div_class = '',
|
|
children
|
|
}: {
|
|
className?: string;
|
|
bg?: string;
|
|
hover_bg?: string;
|
|
active_bg?: string;
|
|
disabled?: boolean;
|
|
title?: string;
|
|
click_function?: (e: MouseEvent) => void;
|
|
menu_options?: MenuOption[];
|
|
menu_class?: string;
|
|
div_class?: string;
|
|
children?: any;
|
|
} = $props();
|
|
|
|
let menu_shown = $state(false);
|
|
let button_element: HTMLButtonElement;
|
|
let menu_element: HTMLDivElement | null = $state(null);
|
|
let position_bottom = $state(true);
|
|
|
|
function onclick(e: MouseEvent) {
|
|
if (menu_options.length !== 0) {
|
|
if (menu_shown) {
|
|
close_menu();
|
|
} else {
|
|
open_menu();
|
|
}
|
|
}
|
|
click_function(e);
|
|
}
|
|
|
|
function getPolygon(): [number, number][] | null {
|
|
if (!button_element || !menu_element) return null;
|
|
const b = button_element.getBoundingClientRect();
|
|
const m = menu_element.getBoundingClientRect();
|
|
|
|
// get polygon coords
|
|
if (position_bottom) {
|
|
return [
|
|
[b.left, b.top],
|
|
[b.right, b.top],
|
|
[m.right, m.top],
|
|
[m.right, m.bottom],
|
|
[m.left, m.bottom],
|
|
[m.left, m.top],
|
|
[b.left, b.top]
|
|
];
|
|
} else {
|
|
return [
|
|
[b.left, b.bottom],
|
|
[b.right, b.bottom],
|
|
[m.right, m.bottom],
|
|
[m.right, m.top],
|
|
[m.left, m.top],
|
|
[m.left, m.bottom],
|
|
[b.left, b.bottom]
|
|
];
|
|
}
|
|
}
|
|
|
|
function handleMouseMove(e: MouseEvent) {
|
|
if (!menu_shown) return;
|
|
const polygon = getPolygon();
|
|
if (!polygon) return;
|
|
|
|
const inside = pointInPolygon([e.clientX, e.clientY], polygon);
|
|
if (!inside) {
|
|
close_menu();
|
|
}
|
|
}
|
|
|
|
function pointInPolygon(point: [number, number], polygon: [number, number][]) {
|
|
let [x, y] = point;
|
|
let inside = false;
|
|
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
|
let [xi, yi] = polygon[i];
|
|
let [xj, yj] = polygon[j];
|
|
let intersect = yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi;
|
|
if (intersect) inside = !inside;
|
|
}
|
|
return inside;
|
|
}
|
|
|
|
function is_enough_space_below(): boolean {
|
|
if (!button_element || !menu_element) return true;
|
|
const button_rectangle = button_element.getBoundingClientRect();
|
|
const menu_height = menu_element.offsetHeight;
|
|
const space_below_available = window.innerHeight - button_rectangle.bottom;
|
|
|
|
return space_below_available > menu_height;
|
|
}
|
|
|
|
function open_menu() {
|
|
menu_shown = true;
|
|
window.addEventListener('mousemove', handleMouseMove);
|
|
setTimeout(() => {
|
|
position_bottom = is_enough_space_below();
|
|
}, 0);
|
|
}
|
|
|
|
function close_menu() {
|
|
window.removeEventListener('mousemove', handleMouseMove);
|
|
menu_shown = false;
|
|
}
|
|
|
|
function no_option_has_icon(): boolean {
|
|
for (const option of menu_options) {
|
|
if (option.icon) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
</script>
|
|
|
|
<div class="relative min-w-0 flex {div_class}" {title}>
|
|
<button
|
|
bind:this={button_element}
|
|
class="{className} {menu_shown ? hover_bg : bg} {disabled
|
|
? 'text-stone-500 cursor-not-allowed'
|
|
: 'hover:' +
|
|
hover_bg +
|
|
' active:' +
|
|
active_bg +
|
|
' cursor-pointer'} p-2 rounded-xl flex justify-center items-center transition-colors duration-200"
|
|
{disabled}
|
|
{onclick}
|
|
>
|
|
{@render children()}
|
|
</button>
|
|
|
|
{#if menu_shown}
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
<div
|
|
bind:this={menu_element}
|
|
transition:fade={{ duration: 50 }}
|
|
class="absolute {position_bottom
|
|
? 'top-full'
|
|
: 'bottom-full'} {menu_class} z-100 my-1.5 min-w-64 rounded-xl backdrop-blur bg-stone-800/45 border border-stone-400/10 shadow-xl/20 p-2 flex flex-col gap-2 text-stone-200 cursor-auto"
|
|
onclick={(e) => {
|
|
e.stopPropagation();
|
|
}}
|
|
>
|
|
{#each menu_options as option}
|
|
<button
|
|
disabled={option.disabled ?? false}
|
|
class="bg-white/15 {option.disabled
|
|
? 'text-stone-500 cursor-not-allowed'
|
|
: 'hover:bg-white/35 active:bg-white/60 cursor-pointer ' +
|
|
option.class} rounded-lg p-2 transition-colors duration-200 select-none flex flex-row gap-2 items-center"
|
|
onclick={async (e) => {
|
|
if (option.on_select) await option.on_select();
|
|
close_menu();
|
|
}}
|
|
>
|
|
{#if !no_option_has_icon()}
|
|
<div class="aspect-square h-[1.2rem]">
|
|
{#if option.icon}
|
|
{@const Icon = option.icon}
|
|
<Icon class="size-full" />
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
<div class="truncate min-w-0" title={option.name}>
|
|
{option.name}
|
|
</div>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|