diff --git a/web/src/lib/components/common/QuickActionsMenu.svelte b/web/src/lib/components/common/QuickActionsMenu.svelte index d3222e23..67b7eb92 100644 --- a/web/src/lib/components/common/QuickActionsMenu.svelte +++ b/web/src/lib/components/common/QuickActionsMenu.svelte @@ -2,6 +2,7 @@ import type { QuickAction, Item, Collection } from '$lib/types'; import { parseFields, formatItemRef } from '$lib/types'; import { toastStore } from '$lib/stores/toast.svelte'; + import BottomSheet from '$lib/components/common/BottomSheet.svelte'; interface Props { actions: QuickAction[]; @@ -13,9 +14,26 @@ let { actions, item = null, collection, scope }: Props = $props(); let open = $state(false); + let alignLeft = $state(false); + let triggerEl = $state(null); let filtered = $derived(actions.filter((a) => a.scope === scope)); + // ── Viewport detection ──────────────────────────────────────────────── + // Track mobile viewport so we can swap the absolute-positioned popover + // for a BottomSheet that never clips off-screen. + let isMobile = $state(false); + $effect(() => { + if (typeof window === 'undefined') return; + const mq = window.matchMedia('(max-width: 639.98px)'); + isMobile = mq.matches; + const onChange = (e: MediaQueryListEvent) => { + isMobile = e.matches; + }; + mq.addEventListener('change', onChange); + return () => mq.removeEventListener('change', onChange); + }); + function resolvePrompt(action: QuickAction): string { let prompt = action.prompt; const fields = item ? parseFields(item) : {}; @@ -74,7 +92,24 @@ open = false; } + function handleTriggerClick(e: MouseEvent) { + e.stopPropagation(); + const nextOpen = !open; + // Only compute alignment when opening on desktop; the mobile branch + // renders a BottomSheet which doesn't need trigger-relative positioning. + if (nextOpen && !isMobile && triggerEl) { + const rect = triggerEl.getBoundingClientRect(); + // If the trigger is too close to the left edge, the default + // right-anchored 200px dropdown would clip — switch to left-anchored. + alignLeft = rect.left < 220; + } + open = nextOpen; + } + function handleWindowClick(e: MouseEvent) { + // On mobile the BottomSheet owns dismissal (backdrop tap, Escape, + // swipe-down) — skip the outside-click handler so it doesn't race. + if (isMobile) return; const target = e.target as HTMLElement; if (!target.closest('.quick-actions-menu')) { open = false; @@ -84,27 +119,36 @@ +{#snippet actionList()} + {#each filtered as action (action.label)} + + {/each} + +{/snippet} + {#if filtered.length > 0}
- {#if open} - @@ -138,6 +182,10 @@ right: 0; margin-top: var(--space-1); min-width: 200px; + /* Defensive cap so the popover can never overflow the viewport + horizontally, even if trigger placement or zoom produces an + edge case we didn't anticipate. */ + max-width: calc(100vw - var(--space-4)); background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); @@ -146,6 +194,13 @@ padding: var(--space-1) 0; } + /* When the trigger sits near the viewport's left edge, flip to + left-anchored so the dropdown opens rightward and stays on-screen. */ + .dropdown.align-left { + right: auto; + left: 0; + } + .action-item { display: flex; align-items: center;