mirror of https://github.com/VERT-sh/VERT.git
188 lines
4.8 KiB
Svelte
188 lines
4.8 KiB
Svelte
<script lang="ts">
|
|
import { duration, fade, transition } from "$lib/util/animation";
|
|
import { ChevronDown } from "lucide-svelte";
|
|
import { quintOut } from "svelte/easing";
|
|
|
|
type Props = {
|
|
options: string[] | { value: string; label: string }[];
|
|
selected?: string;
|
|
onselect?: (option: string) => void;
|
|
disabled?: boolean;
|
|
settingsStyle?: boolean;
|
|
};
|
|
|
|
let {
|
|
options,
|
|
selected = $bindable(
|
|
typeof options[0] === "string" ? options[0] : options[0].value,
|
|
),
|
|
onselect,
|
|
disabled,
|
|
settingsStyle,
|
|
}: Props = $props();
|
|
|
|
let open = $state(false);
|
|
let dropdown = $state<HTMLDivElement>();
|
|
let menuElement = $state<HTMLDivElement>();
|
|
let button = $state<HTMLButtonElement>();
|
|
let clickHandler: ((e: MouseEvent) => void) | null = null;
|
|
let resizeHandler: (() => void) | null = null;
|
|
let scrollHandler: (() => void) | null = null;
|
|
|
|
const getValue = (option: string | { value: string; label: string }) =>
|
|
typeof option === "string" ? option : option.value;
|
|
|
|
const getLabel = (option: string | { value: string; label: string }) =>
|
|
typeof option === "string" ? option : option.label;
|
|
|
|
const select = (option: string | { value: string; label: string }) => {
|
|
const selectedValue = getValue(option);
|
|
selected = selectedValue;
|
|
onselect?.(selectedValue);
|
|
toggle();
|
|
};
|
|
|
|
const toggle = () => {
|
|
open = !open;
|
|
};
|
|
|
|
const updateMenuPosition = () => {
|
|
if (open && menuElement && button) {
|
|
const rect = button.getBoundingClientRect();
|
|
menuElement.style.top = `${rect.bottom + 4}px`;
|
|
menuElement.style.left = `${rect.left}px`;
|
|
menuElement.style.width = `${rect.width}px`;
|
|
}
|
|
};
|
|
|
|
const scrollView = () => {
|
|
if (!menuElement) return;
|
|
const selectedButton = menuElement.querySelector(
|
|
"[data-selected='true']",
|
|
) as HTMLButtonElement | null;
|
|
if (!selectedButton) return;
|
|
selectedButton.scrollIntoView({ block: "start" });
|
|
};
|
|
|
|
// outside clicks
|
|
$effect(() => {
|
|
clickHandler = (e: MouseEvent) => {
|
|
if (dropdown && !dropdown.contains(e.target as Node)) open = false;
|
|
};
|
|
|
|
window.addEventListener("click", clickHandler);
|
|
|
|
return () => {
|
|
if (clickHandler) window.removeEventListener("click", clickHandler);
|
|
};
|
|
});
|
|
|
|
// dropdown menu positioning
|
|
$effect(() => {
|
|
if (open && menuElement && button) {
|
|
resizeHandler = updateMenuPosition;
|
|
scrollHandler = updateMenuPosition;
|
|
window.addEventListener("resize", resizeHandler);
|
|
window.addEventListener("scroll", scrollHandler, {
|
|
capture: true,
|
|
passive: true,
|
|
});
|
|
document.body.appendChild(menuElement);
|
|
menuElement.style.position = "fixed";
|
|
updateMenuPosition();
|
|
requestAnimationFrame(scrollView);
|
|
|
|
return () => {
|
|
if (resizeHandler)
|
|
window.removeEventListener("resize", resizeHandler);
|
|
if (scrollHandler)
|
|
window.removeEventListener("scroll", scrollHandler, true);
|
|
if (menuElement?.parentNode === document.body)
|
|
document.body.removeChild(menuElement);
|
|
};
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<div
|
|
class="relative w-full min-w-fit {settingsStyle
|
|
? 'font-normal'
|
|
: 'text-xl font-medium'} text-center"
|
|
bind:this={dropdown}
|
|
>
|
|
<button
|
|
bind:this={button}
|
|
class="font-display w-full {settingsStyle
|
|
? 'justify-between'
|
|
: 'justify-center'} overflow-hidden relative cursor-pointer {settingsStyle
|
|
? 'px-4'
|
|
: 'px-3'} py-3.5 bg-button {disabled
|
|
? 'opacity-50 cursor-auto'
|
|
: 'cursor-pointer'} flex items-center {settingsStyle
|
|
? 'rounded-xl'
|
|
: 'rounded-full'} focus:!outline-none"
|
|
onclick={toggle}
|
|
{disabled}
|
|
>
|
|
<div class="grid grid-cols-1 grid-rows-1 w-fit flex-grow-0">
|
|
{#key selected}
|
|
<p
|
|
in:fade={{
|
|
duration,
|
|
easing: quintOut,
|
|
}}
|
|
out:fade={{
|
|
duration,
|
|
easing: quintOut,
|
|
}}
|
|
class="col-start-1 row-start-1 {settingsStyle
|
|
? 'text-left'
|
|
: 'text-center'} font-body {settingsStyle
|
|
? 'font-normal'
|
|
: 'font-medium'}"
|
|
>
|
|
{getLabel(
|
|
options.find((opt) => getValue(opt) === selected) ||
|
|
selected,
|
|
)}
|
|
</p>
|
|
{/key}
|
|
{#each options as option}
|
|
<p
|
|
class="col-start-1 row-start-1 invisible pointer-events-none"
|
|
>
|
|
{getLabel(option)}
|
|
</p>
|
|
{/each}
|
|
</div>
|
|
<ChevronDown
|
|
class="w-4 h-4 ml-3 mt-0.5 flex-shrink-0"
|
|
style="transform: rotate({open
|
|
? 180
|
|
: 0}deg); transition: transform {duration}ms {transition};"
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
{#if open}
|
|
<div
|
|
bind:this={menuElement}
|
|
transition:fade={{
|
|
duration,
|
|
easing: quintOut,
|
|
}}
|
|
class="shadow-xl bg-panel-alt shadow-black/25 overflow-hidden z-[9999] bg-background rounded-xl max-h-[23.5vh] overflow-y-auto"
|
|
>
|
|
{#each options as option}
|
|
<button
|
|
data-selected={getValue(option) === selected}
|
|
class={`w-full p-2 px-4 text-left hover:bg-panel font-normal text-sm text-muted
|
|
${getValue(option) === selected ? "bg-separator" : ""}`}
|
|
onclick={() => select(option)}
|
|
>
|
|
{getLabel(option)}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|