/ src / components / Menu.svelte
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>