feat(web): mobile BottomSheet + viewport-aware dropdown in QuickActionsMenu (TASK-628) (#159)

Fixes the mobile clipping bug where the quick-actions dropdown opened
off-screen when the trigger wrapped to the left edge of the viewport.

- Below 640px, the menu now renders as a BottomSheet (shipped in
  TASK-627) — full-width, swipe-to-dismiss, backdrop tap / Escape.
- On desktop, the popover is kept but gains:
  - viewport-aware alignment: flips from right-anchored to left-
    anchored when the trigger is within 220px of the viewport's left
    edge, measured via getBoundingClientRect() at open time.
  - max-width: calc(100vw - var(--space-4)) as a defensive clamp.
- Action list is shared between modes via a Svelte 5 snippet to avoid
  markup duplication.
- Outside-click handler short-circuits on mobile so the BottomSheet
  owns dismissal.

Preserves existing clipboard copy + toast behavior and trigger styling.

Addresses Problem 1 in IDEA-493. Parent: IDEA-493.
This commit is contained in:
xarmian
2026-04-20 07:46:06 -04:00
committed by GitHub
parent e187573792
commit 8592bed2b4
@@ -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<HTMLButtonElement | null>(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 @@
<svelte:window onclick={handleWindowClick} />
{#snippet actionList()}
{#each filtered as action (action.label)}
<button class="action-item" onclick={() => handleAction(action)}>
{#if action.icon}
<span class="action-icon">{action.icon}</span>
{/if}
<span class="action-label">{action.label}</span>
</button>
{/each}
<div class="dropdown-tagline">Copy a prompt to your agent</div>
{/snippet}
{#if filtered.length > 0}
<div class="quick-actions-menu">
<button
bind:this={triggerEl}
class="trigger-btn"
onclick={(e) => { e.stopPropagation(); open = !open; }}
onclick={handleTriggerClick}
title="Quick actions"
>
&#9889;
</button>
{#if open}
<div class="dropdown">
{#each filtered as action (action.label)}
<button class="action-item" onclick={() => handleAction(action)}>
{#if action.icon}
<span class="action-icon">{action.icon}</span>
{/if}
<span class="action-label">{action.label}</span>
</button>
{/each}
<div class="dropdown-tagline">Copy a prompt to your agent</div>
{#if isMobile}
<BottomSheet open={open} onclose={() => (open = false)} title="Quick actions">
{@render actionList()}
</BottomSheet>
{:else if open}
<div class="dropdown" class:align-left={alignLeft}>
{@render actionList()}
</div>
{/if}
</div>
@@ -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;