mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-21 01:53:33 +00:00
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:
@@ -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"
|
||||
>
|
||||
⚡
|
||||
</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;
|
||||
|
||||
Reference in New Issue
Block a user