diff options
Diffstat (limited to 'src/components/Menu.svelte')
| -rw-r--r-- | src/components/Menu.svelte | 218 |
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> |
