summaryrefslogtreecommitdiff
path: root/src/components/Menu.svelte
diff options
context:
space:
mode:
authorrxliuli <rxliuli@gmail.com>2025-11-04 05:03:50 +0800
committerrxliuli <rxliuli@gmail.com>2025-11-04 05:03:50 +0800
commitbce557cc2dc767628bed6aac87301a1be7c5431b (patch)
treeb51a051228d01fe3306cd7626d4a96768aadb944 /src/components/Menu.svelte
init commit
Diffstat (limited to 'src/components/Menu.svelte')
-rw-r--r--src/components/Menu.svelte218
1 files changed, 218 insertions, 0 deletions
diff --git a/src/components/Menu.svelte b/src/components/Menu.svelte
new file mode 100644
index 0000000..8221c79
--- /dev/null
+++ b/src/components/Menu.svelte
@@ -0,0 +1,218 @@
+<script lang="ts" generics="T">
+ import { tick } from 'svelte';
+ import type { Opt } from '@jet/environment/types/optional';
+ import type { MouseEventHandler } from 'svelte/elements';
+ import { onDestroy, onMount } from 'svelte';
+ import { generateUuid } from '@amp/web-apps-utils/src';
+ import {
+ computePosition,
+ autoUpdate,
+ offset,
+ flip,
+ shift,
+ } from '@floating-ui/dom';
+
+ export let options: T[];
+ // Allows the developer the override the floating-ui calculated offset to a fixed number
+ export let forcedXPosition: number | null = null;
+
+ export let handleShowMenu: () => void = () => {};
+
+ let isMenuOpen = false;
+
+ /**
+ * Display the menu
+ *
+ * @example
+ * <script>
+ * let menu;
+ *
+ * function showMenu() {
+ * menu.show();
+ * }
+ * <\/script>
+ *
+ * <Menu bind:this={menu} />
+ */
+ export async function show() {
+ if (!menuEl) return;
+
+ isMenuOpen = true;
+
+ // Menu position should be updated *only* after the dialog has been shown
+ updateMenuPosition();
+
+ // Focuses the first link in the dropdown after the DOM updates
+ await tick();
+ menuEl.querySelector('a')?.focus();
+
+ // When the modal is open, track viewport changes and update the menu position
+ floatingUIAutoUpdatePositionCleanupCallback = autoUpdate(
+ trigger!,
+ menuEl!,
+ updateMenuPosition,
+ );
+ }
+
+ /**
+ * Close the menu
+ *
+ * @example
+ * <script>
+ * let menu;
+ *
+ * function closeMenu() {
+ * menu.close();
+ * }
+ * <\/script>
+ *
+ * <Menu bind:this={menu} />
+ */
+ export function close() {
+ if (!menuEl) return;
+
+ isMenuOpen = false;
+ cleanUpFloatingUIAutoPosition();
+ }
+
+ function toggle() {
+ if (isMenuOpen) {
+ close();
+ } else {
+ show();
+ handleShowMenu?.();
+ }
+ }
+
+ const menuId = generateUuid();
+
+ let menuEl: HTMLUListElement | undefined;
+ let trigger: HTMLButtonElement | undefined;
+
+ function handleKeyUp(event: KeyboardEvent) {
+ if (event.key === 'Escape') {
+ close();
+ }
+ }
+
+ /**
+ * Dismiss the dialog when clicking anywhere with the dialog open
+ */
+ const handleBodyClick: MouseEventHandler<HTMLElement> = (event) => {
+ const clickedElement = event.target as HTMLElement;
+
+ // Only close the dialog if the click is "outside" of the trigger
+ // Otherwise, it will be closed immediately
+ if (!trigger?.contains(clickedElement)) {
+ close();
+ }
+ };
+
+ /// MARK: Menu Positioning through `FloatingUI`
+
+ /**
+ * Update the position of the menu to align it with the trigger
+ */
+ async function updateMenuPosition() {
+ const { x, y } = await computePosition(trigger!, menuEl!, {
+ middleware: [
+ offset({
+ mainAxis: 10,
+ }),
+
+ flip(),
+ shift(),
+ ],
+ placement: 'bottom-end',
+ });
+
+ Object.assign(menuEl!.style, {
+ left: `${forcedXPosition || x}px`,
+ top: `${y}px`,
+ });
+ }
+
+ let floatingUIAutoUpdatePositionCleanupCallback: Opt<() => void>;
+
+ /**
+ * Cleans up the `FloatingUI` auto-update listener, which should only be "active"
+ * while the menu is open
+ */
+ function cleanUpFloatingUIAutoPosition() {
+ floatingUIAutoUpdatePositionCleanupCallback?.();
+ floatingUIAutoUpdatePositionCleanupCallback = undefined;
+ }
+
+ onMount(() => {
+ // Ensures menu is hidden initially
+ if (menuEl) isMenuOpen = false;
+ });
+
+ onDestroy(function () {
+ cleanUpFloatingUIAutoPosition();
+ });
+</script>
+
+<svelte:body on:keyup={handleKeyUp} on:click={handleBodyClick} />
+
+<button
+ class="menu-trigger"
+ aria-controls={menuId}
+ aria-haspopup="menu"
+ aria-expanded={isMenuOpen}
+ bind:this={trigger}
+ on:click={toggle}
+>
+ <slot name="trigger" />
+</button>
+
+<ul
+ id={menuId}
+ hidden={!isMenuOpen}
+ tabindex="-1"
+ class="menu-popover focus-visible"
+ bind:this={menuEl}
+>
+ {#each options as option}
+ <li class="menu-item" role="presentation">
+ <slot name="option" {option} />
+ </li>
+ {/each}
+</ul>
+
+<style>
+ :root {
+ --menu-common-padding: 4px 8px;
+ }
+
+ .menu-trigger {
+ background-color: var(--menu-trigger-background-color);
+ border-radius: var(--menu-trigger-border-radius);
+ font: var(--menu-trigger-font);
+ padding: var(--menu-trigger-padding, var(--menu-common-padding));
+ }
+
+ .menu-popover {
+ background-color: var(--menu-popover-background-color, var(--pageBg));
+ padding: var(--menu-popover-padding, 0);
+ border: var(--menu-popover-border, none);
+ border-radius: var(
+ --menu-popover-border-radius,
+ var(--global-border-radius-large)
+ );
+ box-shadow: var(--menu-popover-box-shadow, var(--shadow-medium));
+ position: absolute;
+ inset: auto;
+ z-index: var(--menu-popover-z-index, 2);
+ }
+
+ .menu-popover::backdrop {
+ background: var(--menu-popover-backdrop-background, none);
+ }
+
+ .menu-item {
+ padding: var(--menu-item-padding, var(--menu-common-padding));
+ margin: var(--menu-item-margin, 0);
+ white-space: nowrap;
+ }
+</style>