Menu.svelte
1 <script lang="ts" generics="T"> 2 import { tick } from 'svelte'; 3 import type { Opt } from '@jet/environment/types/optional'; 4 import type { MouseEventHandler } from 'svelte/elements'; 5 import { onDestroy, onMount } from 'svelte'; 6 import { generateUuid } from '@amp/web-apps-utils/src'; 7 import { 8 computePosition, 9 autoUpdate, 10 offset, 11 flip, 12 shift, 13 } from '@floating-ui/dom'; 14 15 export let options: T[]; 16 // Allows the developer the override the floating-ui calculated offset to a fixed number 17 export let forcedXPosition: number | null = null; 18 19 export let handleShowMenu: () => void = () => {}; 20 21 let isMenuOpen = false; 22 23 /** 24 * Display the menu 25 * 26 * @example 27 * <script> 28 * let menu; 29 * 30 * function showMenu() { 31 * menu.show(); 32 * } 33 * <\/script> 34 * 35 * <Menu bind:this={menu} /> 36 */ 37 export async function show() { 38 if (!menuEl) return; 39 40 isMenuOpen = true; 41 42 // Menu position should be updated *only* after the dialog has been shown 43 updateMenuPosition(); 44 45 // Focuses the first link in the dropdown after the DOM updates 46 await tick(); 47 menuEl.querySelector('a')?.focus(); 48 49 // When the modal is open, track viewport changes and update the menu position 50 floatingUIAutoUpdatePositionCleanupCallback = autoUpdate( 51 trigger!, 52 menuEl!, 53 updateMenuPosition, 54 ); 55 } 56 57 /** 58 * Close the menu 59 * 60 * @example 61 * <script> 62 * let menu; 63 * 64 * function closeMenu() { 65 * menu.close(); 66 * } 67 * <\/script> 68 * 69 * <Menu bind:this={menu} /> 70 */ 71 export function close() { 72 if (!menuEl) return; 73 74 isMenuOpen = false; 75 cleanUpFloatingUIAutoPosition(); 76 } 77 78 function toggle() { 79 if (isMenuOpen) { 80 close(); 81 } else { 82 show(); 83 handleShowMenu?.(); 84 } 85 } 86 87 const menuId = generateUuid(); 88 89 let menuEl: HTMLUListElement | undefined; 90 let trigger: HTMLButtonElement | undefined; 91 92 function handleKeyUp(event: KeyboardEvent) { 93 if (event.key === 'Escape') { 94 close(); 95 } 96 } 97 98 /** 99 * Dismiss the dialog when clicking anywhere with the dialog open 100 */ 101 const handleBodyClick: MouseEventHandler<HTMLElement> = (event) => { 102 const clickedElement = event.target as HTMLElement; 103 104 // Only close the dialog if the click is "outside" of the trigger 105 // Otherwise, it will be closed immediately 106 if (!trigger?.contains(clickedElement)) { 107 close(); 108 } 109 }; 110 111 /// MARK: Menu Positioning through `FloatingUI` 112 113 /** 114 * Update the position of the menu to align it with the trigger 115 */ 116 async function updateMenuPosition() { 117 const { x, y } = await computePosition(trigger!, menuEl!, { 118 middleware: [ 119 offset({ 120 mainAxis: 10, 121 }), 122 123 flip(), 124 shift(), 125 ], 126 placement: 'bottom-end', 127 }); 128 129 Object.assign(menuEl!.style, { 130 left: `${forcedXPosition || x}px`, 131 top: `${y}px`, 132 }); 133 } 134 135 let floatingUIAutoUpdatePositionCleanupCallback: Opt<() => void>; 136 137 /** 138 * Cleans up the `FloatingUI` auto-update listener, which should only be "active" 139 * while the menu is open 140 */ 141 function cleanUpFloatingUIAutoPosition() { 142 floatingUIAutoUpdatePositionCleanupCallback?.(); 143 floatingUIAutoUpdatePositionCleanupCallback = undefined; 144 } 145 146 onMount(() => { 147 // Ensures menu is hidden initially 148 if (menuEl) isMenuOpen = false; 149 }); 150 151 onDestroy(function () { 152 cleanUpFloatingUIAutoPosition(); 153 }); 154 </script> 155 156 <svelte:body on:keyup={handleKeyUp} on:click={handleBodyClick} /> 157 158 <button 159 class="menu-trigger" 160 aria-controls={menuId} 161 aria-haspopup="menu" 162 aria-expanded={isMenuOpen} 163 bind:this={trigger} 164 on:click={toggle} 165 > 166 <slot name="trigger" /> 167 </button> 168 169 <ul 170 id={menuId} 171 hidden={!isMenuOpen} 172 tabindex="-1" 173 class="menu-popover focus-visible" 174 bind:this={menuEl} 175 > 176 {#each options as option} 177 <li class="menu-item" role="presentation"> 178 <slot name="option" {option} /> 179 </li> 180 {/each} 181 </ul> 182 183 <style> 184 :root { 185 --menu-common-padding: 4px 8px; 186 } 187 188 .menu-trigger { 189 background-color: var(--menu-trigger-background-color); 190 border-radius: var(--menu-trigger-border-radius); 191 font: var(--menu-trigger-font); 192 padding: var(--menu-trigger-padding, var(--menu-common-padding)); 193 } 194 195 .menu-popover { 196 background-color: var(--menu-popover-background-color, var(--pageBg)); 197 padding: var(--menu-popover-padding, 0); 198 border: var(--menu-popover-border, none); 199 border-radius: var( 200 --menu-popover-border-radius, 201 var(--global-border-radius-large) 202 ); 203 box-shadow: var(--menu-popover-box-shadow, var(--shadow-medium)); 204 position: absolute; 205 inset: auto; 206 z-index: var(--menu-popover-z-index, 2); 207 } 208 209 .menu-popover::backdrop { 210 background: var(--menu-popover-backdrop-background, none); 211 } 212 213 .menu-item { 214 padding: var(--menu-item-padding, var(--menu-common-padding)); 215 margin: var(--menu-item-margin, 0); 216 white-space: nowrap; 217 } 218 </style>