Files
apps.apple.com/src/components/Menu.svelte
2025-11-04 05:03:50 +08:00

219 lines
5.4 KiB
Svelte

<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>