|
| 1 | +<script lang="ts" generics="T = number"> |
| 2 | + import type { Component } from "svelte"; |
| 3 | + import type { HTMLAttributes } from "svelte/elements"; |
| 4 | + import Popover from "$lib/components/ui/Popover.svelte"; |
| 5 | + import { isNullish, nonNullish } from "@dfinity/utils"; |
| 6 | + import Button from "$lib/components/ui/Button.svelte"; |
| 7 | +
|
| 8 | + type Option<T> = { |
| 9 | + value?: T; |
| 10 | + label: string; |
| 11 | + icon?: Component; |
| 12 | + selected?: boolean; |
| 13 | + onClick?: () => void; |
| 14 | + }; |
| 15 | + type Direction = "up" | "right" | "down" | "left"; |
| 16 | + type Align = "start" | "center" | "end"; |
| 17 | +
|
| 18 | + type Props = HTMLAttributes<HTMLElement> & { |
| 19 | + options: Option<T>[]; |
| 20 | + onChange?: (value: T) => void; |
| 21 | + direction?: Direction; |
| 22 | + align?: Align; |
| 23 | + distance?: string; |
| 24 | + }; |
| 25 | + let { |
| 26 | + children, |
| 27 | + class: className, |
| 28 | + options, |
| 29 | + onChange, |
| 30 | + direction = "down", |
| 31 | + align = "start", |
| 32 | + distance = "0px", |
| 33 | + ...props |
| 34 | + }: Props = $props(); |
| 35 | +
|
| 36 | + let wrapperRef = $state<HTMLElement>(); |
| 37 | + let isOpen = $state(false); |
| 38 | +
|
| 39 | + const childrenRef = $derived( |
| 40 | + wrapperRef?.firstElementChild as HTMLElement | undefined, |
| 41 | + ); |
| 42 | +
|
| 43 | + const handleClick = (option: Option<T>, index: number) => { |
| 44 | + isOpen = false; |
| 45 | + option.onClick?.(); |
| 46 | + onChange?.((option.value ?? index) as T); |
| 47 | + }; |
| 48 | +
|
| 49 | + $effect(() => { |
| 50 | + if (isNullish(childrenRef)) { |
| 51 | + return; |
| 52 | + } |
| 53 | + const listener = () => (isOpen = true); |
| 54 | + childrenRef?.addEventListener("click", listener); |
| 55 | + return () => childrenRef.removeEventListener("click", listener); |
| 56 | + }); |
| 57 | +</script> |
| 58 | + |
| 59 | +<div bind:this={wrapperRef} class="contents"> |
| 60 | + {@render children?.()} |
| 61 | +</div> |
| 62 | + |
| 63 | +{#if isOpen && nonNullish(childrenRef)} |
| 64 | + <Popover |
| 65 | + {...props} |
| 66 | + anchor={childrenRef} |
| 67 | + {direction} |
| 68 | + {align} |
| 69 | + {distance} |
| 70 | + responsive={false} |
| 71 | + onClose={() => (isOpen = false)} |
| 72 | + class={["!w-max !p-1.5 !shadow-lg", className]} |
| 73 | + > |
| 74 | + <ul class="flex flex-col"> |
| 75 | + {#each options as option, index} |
| 76 | + <li class="contents"> |
| 77 | + <Button |
| 78 | + onclick={() => handleClick(option, index)} |
| 79 | + variant="tertiary" |
| 80 | + class={[ |
| 81 | + "justify-start gap-2.5 !px-3 text-start", |
| 82 | + option.selected && "[ul:not(:hover)_&]:bg-bg-primary_hover", |
| 83 | + ]} |
| 84 | + > |
| 85 | + {#if nonNullish(option.icon)} |
| 86 | + {@const Icon = option.icon} |
| 87 | + <div class="text-fg-quaternary [&_svg]:size-4"> |
| 88 | + <Icon /> |
| 89 | + </div> |
| 90 | + {/if} |
| 91 | + <span>{option.label}</span> |
| 92 | + </Button> |
| 93 | + </li> |
| 94 | + {/each} |
| 95 | + </ul> |
| 96 | + </Popover> |
| 97 | +{/if} |
0 commit comments