feat(FN-1137): improve mobile dropdown and file browser interactions
- Add viewport-aware positioning/clamping for Quick Scripts and Quick Entry dropdown menus - Implement long-press context menu behavior in FileBrowser with iOS momentum scrolling support - Expand dashboard CSS and component logic to stabilize mobile overlay behavior and remove unused inline WebKit style - Add focused tests for FileBrowser, QuickScriptsDropdown, and mobile dropdown positioning plus README notes
This commit is contained in:
@@ -132,6 +132,25 @@ The dashboard stylesheet defines a shared mobile foundation in `app/styles.css`
|
||||
- Text-entry controls (`input`, `select`, `textarea`) use **16px font-size** on mobile to prevent iOS Safari auto-zoom.
|
||||
- **Safe-area pattern (notched devices / Capacitor webview):** use `env(safe-area-inset-top|right|bottom|left, 0px)` for root/layout containers (for example `#root`, `.header`, `.modal`, `.board`) so content avoids status bars and home indicators.
|
||||
|
||||
### Mobile Dropdown & Touch
|
||||
Mobile dropdown behavior follows a consistent viewport-aware anchoring pattern so menus stay usable in narrow viewports and virtual-keyboard scenarios.
|
||||
|
||||
- **Portal dropdown positioning pattern** (QuickEntry model menu, refine menu, QuickScripts):
|
||||
- Compute trigger coordinates with `getBoundingClientRect()`.
|
||||
- Resolve viewport dimensions using `window.visualViewport` when available (fallback to `window.innerWidth/innerHeight`).
|
||||
- Compare available space above vs. below the trigger and open upward when space below is insufficient.
|
||||
- Clamp horizontal placement to viewport padding and clamp menu height to available space.
|
||||
- Recalculate while open on `resize`, capture-phase `scroll`, and `visualViewport` `resize`/`scroll`.
|
||||
- **FileBrowser touch context menu pattern**:
|
||||
- Keep desktop/right-click support via `onContextMenu`.
|
||||
- Add long-press for touch (`500ms`) with a separate early feedback timer (`200ms`) that applies `.file-node--long-pressing`.
|
||||
- Cancel long-press on touch move beyond a 10px threshold or on touch end/cancel.
|
||||
- Guard click-through after long-press so context-menu opening does not also trigger file selection/navigation.
|
||||
- Clamp context menu placement using visual viewport offsets for virtual-keyboard-safe positioning.
|
||||
- **Momentum scrolling convention**:
|
||||
- For scrollable dropdown lists and modal content containers, apply `-webkit-overflow-scrolling: touch` to preserve iOS momentum scrolling.
|
||||
- Use base selectors for reusable scroll lists (for example: `.dep-dropdown`, `.model-combobox-list`, `.quick-scripts-dropdown__list`, `.file-browser-list`) and reinforce modal surfaces in the main mobile media query (`@media (max-width: 768px)`).
|
||||
|
||||
### Executor Status Bar
|
||||
A persistent footer status bar at the bottom of the dashboard displays real-time executor statistics in project view. The status bar provides immediate visibility into the engine's state without opening modals or hovering over badges.
|
||||
|
||||
|
||||
@@ -65,6 +65,15 @@ interface DialogState {
|
||||
|
||||
const INITIAL_DIALOG: DialogState = { type: null, entry: null, entryFullPath: "" };
|
||||
|
||||
const LONG_PRESS_FEEDBACK_MS = 200;
|
||||
const LONG_PRESS_DURATION_MS = 500;
|
||||
const TOUCH_MOVE_THRESHOLD = 10;
|
||||
|
||||
interface TouchPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
// ── Context Menu Component ──────────────────────────────────────────────
|
||||
|
||||
interface ContextMenuItem {
|
||||
@@ -90,19 +99,29 @@ function FileContextMenu({ x, y, entry, onAction, onClose }: FileContextMenuProp
|
||||
useEffect(() => {
|
||||
const menu = menuRef.current;
|
||||
if (!menu) return;
|
||||
|
||||
const rect = menu.getBoundingClientRect();
|
||||
const vv = window.visualViewport;
|
||||
const viewportWidth = vv?.width && vv.width > 0 ? vv.width : window.innerWidth;
|
||||
const viewportHeight = vv?.height && vv.height > 0 ? vv.height : window.innerHeight;
|
||||
const offsetLeft = vv?.offsetLeft ?? 0;
|
||||
const offsetTop = vv?.offsetTop ?? 0;
|
||||
|
||||
const pad = 8;
|
||||
let ax = x;
|
||||
let ay = y;
|
||||
if (ax + rect.width > window.innerWidth - pad) {
|
||||
ax = window.innerWidth - pad - rect.width;
|
||||
let ax = x - offsetLeft;
|
||||
let ay = y - offsetTop;
|
||||
|
||||
if (ax + rect.width > viewportWidth - pad) {
|
||||
ax = viewportWidth - pad - rect.width;
|
||||
}
|
||||
if (ay + rect.height > window.innerHeight - pad) {
|
||||
ay = window.innerHeight - pad - rect.height;
|
||||
if (ay + rect.height > viewportHeight - pad) {
|
||||
ay = viewportHeight - pad - rect.height;
|
||||
}
|
||||
|
||||
if (ax < pad) ax = pad;
|
||||
if (ay < pad) ay = pad;
|
||||
setAdjustedPos({ x: ax, y: ay });
|
||||
|
||||
setAdjustedPos({ x: ax + offsetLeft, y: ay + offsetTop });
|
||||
}, [x, y]);
|
||||
|
||||
// Close on Escape
|
||||
@@ -291,21 +310,113 @@ export function FileBrowser({
|
||||
const [dialog, setDialog] = useState<DialogState>(INITIAL_DIALOG);
|
||||
const [operationLoading, setOperationLoading] = useState(false);
|
||||
const [operationError, setOperationError] = useState<string | null>(null);
|
||||
const [isLongPressing, setIsLongPressing] = useState(false);
|
||||
const [longPressTargetPath, setLongPressTargetPath] = useState<string | null>(null);
|
||||
|
||||
const longPressTimerRef = useRef<number | null>(null);
|
||||
const longPressFeedbackTimerRef = useRef<number | null>(null);
|
||||
const touchStartRef = useRef<TouchPoint | null>(null);
|
||||
const touchOpenHandledRef = useRef(false);
|
||||
|
||||
const clearLongPressTimers = useCallback(() => {
|
||||
if (longPressTimerRef.current !== null) {
|
||||
window.clearTimeout(longPressTimerRef.current);
|
||||
longPressTimerRef.current = null;
|
||||
}
|
||||
if (longPressFeedbackTimerRef.current !== null) {
|
||||
window.clearTimeout(longPressFeedbackTimerRef.current);
|
||||
longPressFeedbackTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const cancelLongPress = useCallback(() => {
|
||||
clearLongPressTimers();
|
||||
touchStartRef.current = null;
|
||||
setIsLongPressing(false);
|
||||
setLongPressTargetPath(null);
|
||||
}, [clearLongPressTimers]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearLongPressTimers();
|
||||
};
|
||||
}, [clearLongPressTimers]);
|
||||
|
||||
const openContextMenuAt = useCallback((x: number, y: number, entry: FileNode, fullPath: string) => {
|
||||
setContextMenu({
|
||||
visible: true,
|
||||
x,
|
||||
y,
|
||||
entry,
|
||||
entryFullPath: fullPath,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent, entry: FileNode, fullPath: string) => {
|
||||
if (e.touches.length !== 1) return;
|
||||
|
||||
const touch = e.touches[0];
|
||||
if (!touch) return;
|
||||
|
||||
cancelLongPress();
|
||||
touchStartRef.current = { x: touch.clientX, y: touch.clientY };
|
||||
|
||||
longPressFeedbackTimerRef.current = window.setTimeout(() => {
|
||||
setIsLongPressing(true);
|
||||
setLongPressTargetPath(fullPath);
|
||||
}, LONG_PRESS_FEEDBACK_MS);
|
||||
|
||||
longPressTimerRef.current = window.setTimeout(() => {
|
||||
const point = touchStartRef.current;
|
||||
if (!point) return;
|
||||
|
||||
touchOpenHandledRef.current = true;
|
||||
setIsLongPressing(false);
|
||||
setLongPressTargetPath(null);
|
||||
clearLongPressTimers();
|
||||
|
||||
openContextMenuAt(point.x, point.y, entry, fullPath);
|
||||
}, LONG_PRESS_DURATION_MS);
|
||||
}, [cancelLongPress, clearLongPressTimers, openContextMenuAt]);
|
||||
|
||||
const handleTouchMove = useCallback((e: React.TouchEvent) => {
|
||||
const start = touchStartRef.current;
|
||||
const touch = e.touches[0];
|
||||
if (!start || !touch) return;
|
||||
|
||||
if (
|
||||
Math.abs(touch.clientX - start.x) > TOUCH_MOVE_THRESHOLD ||
|
||||
Math.abs(touch.clientY - start.y) > TOUCH_MOVE_THRESHOLD
|
||||
) {
|
||||
cancelLongPress();
|
||||
}
|
||||
}, [cancelLongPress]);
|
||||
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
cancelLongPress();
|
||||
}, [cancelLongPress]);
|
||||
|
||||
// Close context menu on scroll within the file browser
|
||||
useEffect(() => {
|
||||
if (!contextMenu.visible) return;
|
||||
const browserList = document.querySelector(".file-browser-list");
|
||||
const handleClose = () => setContextMenu(INITIAL_CONTEXT_MENU);
|
||||
const handleClose = () => {
|
||||
touchOpenHandledRef.current = false;
|
||||
cancelLongPress();
|
||||
setContextMenu(INITIAL_CONTEXT_MENU);
|
||||
};
|
||||
browserList?.addEventListener("scroll", handleClose);
|
||||
return () => browserList?.removeEventListener("scroll", handleClose);
|
||||
}, [contextMenu.visible]);
|
||||
}, [cancelLongPress, contextMenu.visible]);
|
||||
|
||||
// Close context menu on click outside or Escape
|
||||
useEffect(() => {
|
||||
if (!contextMenu.visible) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setContextMenu(INITIAL_CONTEXT_MENU);
|
||||
if (e.key === "Escape") {
|
||||
touchOpenHandledRef.current = false;
|
||||
setContextMenu(INITIAL_CONTEXT_MENU);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
@@ -314,18 +425,16 @@ export function FileBrowser({
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent, entry: FileNode) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setContextMenu({
|
||||
visible: true,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
entry,
|
||||
entryFullPath: entryPath(currentPath, entry.name),
|
||||
});
|
||||
}, [currentPath]);
|
||||
cancelLongPress();
|
||||
touchOpenHandledRef.current = false;
|
||||
openContextMenuAt(e.clientX, e.clientY, entry, entryPath(currentPath, entry.name));
|
||||
}, [cancelLongPress, currentPath, openContextMenuAt]);
|
||||
|
||||
const handleContextAction = useCallback((action: string) => {
|
||||
if (!contextMenu.entry) return;
|
||||
|
||||
touchOpenHandledRef.current = false;
|
||||
|
||||
const entry = contextMenu.entry;
|
||||
const fullPath = contextMenu.entryFullPath;
|
||||
|
||||
@@ -391,6 +500,21 @@ export function FileBrowser({
|
||||
setOperationError(null);
|
||||
}, []);
|
||||
|
||||
const handleFileNodeClick = useCallback((entry: FileNode, fullPath: string) => {
|
||||
if (touchOpenHandledRef.current) {
|
||||
touchOpenHandledRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (contextMenu.visible) return;
|
||||
|
||||
if (entry.type === "directory") {
|
||||
onNavigate(fullPath);
|
||||
} else {
|
||||
onSelectFile(fullPath);
|
||||
}
|
||||
}, [contextMenu.visible, onNavigate, onSelectFile]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="file-browser-loading">
|
||||
@@ -436,36 +560,38 @@ export function FileBrowser({
|
||||
{entries.length === 0 ? (
|
||||
<div className="file-browser-empty">(empty directory)</div>
|
||||
) : (
|
||||
entries.map((entry) => (
|
||||
<div
|
||||
key={entry.name}
|
||||
className={`file-node file-node--${entry.type}`}
|
||||
onClick={() => {
|
||||
if (contextMenu.visible) return;
|
||||
if (entry.type === "directory") {
|
||||
onNavigate(currentPath === "." ? entry.name : `${currentPath}/${entry.name}`);
|
||||
} else {
|
||||
onSelectFile(currentPath === "." ? entry.name : `${currentPath}/${entry.name}`);
|
||||
}
|
||||
}}
|
||||
onContextMenu={(e) => handleContextMenu(e, entry)}
|
||||
>
|
||||
<div className="file-node-icon">
|
||||
{entry.type === "directory" ? (
|
||||
<Folder size={16} />
|
||||
) : (
|
||||
<File size={16} />
|
||||
entries.map((entry) => {
|
||||
const fullPath = entryPath(currentPath, entry.name);
|
||||
const isLongPressTarget = isLongPressing && longPressTargetPath === fullPath;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.name}
|
||||
className={`file-node file-node--${entry.type} ${isLongPressTarget ? "file-node--long-pressing" : ""}`}
|
||||
onClick={() => handleFileNodeClick(entry, fullPath)}
|
||||
onContextMenu={(e) => handleContextMenu(e, entry)}
|
||||
onTouchStart={(e) => handleTouchStart(e, entry, fullPath)}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onTouchCancel={handleTouchEnd}
|
||||
>
|
||||
<div className="file-node-icon">
|
||||
{entry.type === "directory" ? (
|
||||
<Folder size={16} />
|
||||
) : (
|
||||
<File size={16} />
|
||||
)}
|
||||
</div>
|
||||
<div className="file-node-name">{entry.name}</div>
|
||||
{entry.type === "file" && entry.size !== undefined && (
|
||||
<div className="file-node-size">{formatBytes(entry.size)}</div>
|
||||
)}
|
||||
{entry.mtime && (
|
||||
<div className="file-node-time">{formatTime(entry.mtime)}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="file-node-name">{entry.name}</div>
|
||||
{entry.type === "file" && entry.size !== undefined && (
|
||||
<div className="file-node-size">{formatBytes(entry.size)}</div>
|
||||
)}
|
||||
{entry.mtime && (
|
||||
<div className="file-node-time">{formatTime(entry.mtime)}</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -476,7 +602,10 @@ export function FileBrowser({
|
||||
y={contextMenu.y}
|
||||
entry={contextMenu.entry}
|
||||
onAction={handleContextAction}
|
||||
onClose={() => setContextMenu(INITIAL_CONTEXT_MENU)}
|
||||
onClose={() => {
|
||||
touchOpenHandledRef.current = false;
|
||||
setContextMenu(INITIAL_CONTEXT_MENU);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
const modelMenuRef = useRef<HTMLDivElement>(null);
|
||||
const modelMenuPortalRef = useRef<HTMLDivElement>(null);
|
||||
const agentPickerRef = useRef<HTMLDivElement>(null);
|
||||
const [modelMenuPosition, setModelMenuPosition] = useState<{ top: number; left: number; width: number } | null>(null);
|
||||
const [modelMenuPosition, setModelMenuPosition] = useState<{ top: number; left: number; width: number; maxHeight?: number } | null>(null);
|
||||
const [portalRoot, setPortalRoot] = useState<HTMLElement | null>(null);
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
const [modelsError, setModelsError] = useState<string | null>(null);
|
||||
@@ -516,6 +516,25 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
);
|
||||
}, []);
|
||||
|
||||
const getEffectiveViewport = useCallback(() => {
|
||||
const vv = window.visualViewport;
|
||||
if (vv && vv.width > 0 && vv.height > 0) {
|
||||
return {
|
||||
width: vv.width,
|
||||
height: vv.height,
|
||||
offsetTop: vv.offsetTop,
|
||||
offsetLeft: vv.offsetLeft,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
offsetTop: 0,
|
||||
offsetLeft: 0,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const updateActionsMenuPosition = useCallback(() => {
|
||||
const trigger = actionsMenuRef.current?.querySelector(".quick-entry-actions-trigger") as HTMLElement | null;
|
||||
if (!trigger) return;
|
||||
@@ -563,44 +582,97 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
if (!trigger) return;
|
||||
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const isMobile = viewportWidth <= 640;
|
||||
const { width: viewportWidth, height: viewportHeight, offsetTop, offsetLeft } = getEffectiveViewport();
|
||||
const horizontalPadding = 16;
|
||||
const verticalPadding = 16;
|
||||
const gap = 4;
|
||||
const isMobile = viewportWidth <= 768;
|
||||
|
||||
if (isMobile) {
|
||||
// On mobile: use a wider menu that fills most of the viewport (32px side margins)
|
||||
const mobileWidth = Math.min(viewportWidth - 32, 360);
|
||||
const left = Math.max((viewportWidth - mobileWidth) / 2, 16);
|
||||
setModelMenuPosition({
|
||||
top: rect.bottom + 4,
|
||||
left,
|
||||
width: mobileWidth,
|
||||
});
|
||||
} else {
|
||||
setModelMenuPosition({
|
||||
top: rect.bottom + 4,
|
||||
left: rect.left,
|
||||
width: Math.max(rect.width, 240),
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
const preferredHeight = isMobile
|
||||
? Math.min(viewportHeight * 0.6, 360)
|
||||
: Math.min(viewportHeight * 0.5, 360);
|
||||
|
||||
const preferredWidth = isMobile
|
||||
? Math.min(viewportWidth - horizontalPadding * 2, 360)
|
||||
: Math.max(rect.width, 240);
|
||||
|
||||
const width = Math.min(
|
||||
preferredWidth,
|
||||
Math.max(viewportWidth - horizontalPadding * 2, 200),
|
||||
);
|
||||
|
||||
const triggerTop = rect.top - offsetTop;
|
||||
const triggerBottom = rect.bottom - offsetTop;
|
||||
const triggerLeft = rect.left - offsetLeft;
|
||||
|
||||
const spaceBelow = viewportHeight - triggerBottom;
|
||||
const spaceAbove = triggerTop;
|
||||
const availableBelow = Math.max(spaceBelow - verticalPadding - gap, 160);
|
||||
const availableAbove = Math.max(spaceAbove - verticalPadding - gap, 160);
|
||||
const openUpward = spaceBelow < preferredHeight && spaceAbove > spaceBelow;
|
||||
|
||||
const maxHeight = Math.max(
|
||||
Math.min(openUpward ? availableAbove : availableBelow, preferredHeight),
|
||||
160,
|
||||
);
|
||||
|
||||
const left = Math.min(
|
||||
Math.max(triggerLeft, horizontalPadding),
|
||||
viewportWidth - horizontalPadding - width,
|
||||
) + offsetLeft;
|
||||
|
||||
const top = openUpward
|
||||
? Math.max(verticalPadding + offsetTop, triggerTop - maxHeight - gap + offsetTop)
|
||||
: Math.min(
|
||||
triggerBottom + gap + offsetTop,
|
||||
viewportHeight + offsetTop - verticalPadding - maxHeight,
|
||||
);
|
||||
|
||||
setModelMenuPosition({
|
||||
top,
|
||||
left,
|
||||
width,
|
||||
maxHeight,
|
||||
});
|
||||
}, [getEffectiveViewport]);
|
||||
|
||||
const updateRefineMenuPosition = useCallback(() => {
|
||||
const trigger = refineMenuRef.current?.querySelector(".refine-button") as HTMLElement | null;
|
||||
if (!trigger) return;
|
||||
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const isMobile = viewportWidth <= 640;
|
||||
const { width: viewportWidth, height: viewportHeight, offsetTop, offsetLeft } = getEffectiveViewport();
|
||||
const horizontalPadding = 8;
|
||||
const verticalPadding = 12;
|
||||
const gap = 4;
|
||||
const expectedMenuHeight = Math.min(200, Math.max(viewportHeight - verticalPadding * 2, 160));
|
||||
const menuWidth = Math.min(200, viewportWidth - horizontalPadding * 2);
|
||||
|
||||
// Ensure the menu doesn't overflow off the right edge on small screens
|
||||
const menuWidth = Math.min(200, viewportWidth - 16);
|
||||
const left = Math.min(rect.left, viewportWidth - menuWidth - 8);
|
||||
const triggerTop = rect.top - offsetTop;
|
||||
const triggerBottom = rect.bottom - offsetTop;
|
||||
const triggerLeft = rect.left - offsetLeft;
|
||||
|
||||
const spaceBelow = viewportHeight - triggerBottom;
|
||||
const spaceAbove = triggerTop;
|
||||
const openUpward = spaceBelow < expectedMenuHeight && spaceAbove > spaceBelow;
|
||||
|
||||
const left = Math.min(
|
||||
Math.max(triggerLeft, horizontalPadding),
|
||||
viewportWidth - horizontalPadding - menuWidth,
|
||||
) + offsetLeft;
|
||||
|
||||
const top = openUpward
|
||||
? Math.max(verticalPadding + offsetTop, triggerTop - expectedMenuHeight - gap + offsetTop)
|
||||
: Math.min(
|
||||
triggerBottom + gap + offsetTop,
|
||||
viewportHeight + offsetTop - verticalPadding - expectedMenuHeight,
|
||||
);
|
||||
|
||||
setRefineMenuPosition({
|
||||
top: rect.bottom + 4,
|
||||
top,
|
||||
left,
|
||||
});
|
||||
}, []);
|
||||
}, [getEffectiveViewport]);
|
||||
|
||||
// Keep actions menu portal anchored during scroll/resize
|
||||
useEffect(() => {
|
||||
@@ -626,9 +698,19 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
window.addEventListener("resize", handleReposition);
|
||||
window.addEventListener("scroll", handleReposition, true);
|
||||
|
||||
const vv = window.visualViewport;
|
||||
if (vv) {
|
||||
vv.addEventListener("resize", handleReposition);
|
||||
vv.addEventListener("scroll", handleReposition);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleReposition);
|
||||
window.removeEventListener("scroll", handleReposition, true);
|
||||
if (vv) {
|
||||
vv.removeEventListener("resize", handleReposition);
|
||||
vv.removeEventListener("scroll", handleReposition);
|
||||
}
|
||||
};
|
||||
}, [isModelMenuOpen, updateModelMenuPosition]);
|
||||
|
||||
@@ -641,9 +723,19 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
window.addEventListener("resize", handleReposition);
|
||||
window.addEventListener("scroll", handleReposition, true);
|
||||
|
||||
const vv = window.visualViewport;
|
||||
if (vv) {
|
||||
vv.addEventListener("resize", handleReposition);
|
||||
vv.addEventListener("scroll", handleReposition);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleReposition);
|
||||
window.removeEventListener("scroll", handleReposition, true);
|
||||
if (vv) {
|
||||
vv.removeEventListener("resize", handleReposition);
|
||||
vv.removeEventListener("scroll", handleReposition);
|
||||
}
|
||||
};
|
||||
}, [isRefineMenuOpen, updateRefineMenuPosition]);
|
||||
|
||||
@@ -1163,6 +1255,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
top: `${modelMenuPosition.top}px`,
|
||||
left: `${modelMenuPosition.left}px`,
|
||||
width: `${modelMenuPosition.width}px`,
|
||||
maxHeight: modelMenuPosition.maxHeight ? `${modelMenuPosition.maxHeight}px` : undefined,
|
||||
overflowY: modelMenuPosition.maxHeight ? "auto" : undefined,
|
||||
}}
|
||||
>
|
||||
{activeModelSubmenu === null ? (
|
||||
|
||||
@@ -2,6 +2,12 @@ import { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
||||
import { Terminal, Play, Settings, Loader2, ChevronDown } from "lucide-react";
|
||||
import { fetchScripts } from "../api";
|
||||
|
||||
interface DropdownPosition {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export interface QuickScriptsDropdownProps {
|
||||
onOpenScripts: () => void;
|
||||
onRunScript: (name: string, command: string) => void;
|
||||
@@ -30,10 +36,77 @@ export function QuickScriptsDropdown({
|
||||
const [scripts, setScripts] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||
const [dropdownPosition, setDropdownPosition] = useState<DropdownPosition | null>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const getEffectiveViewport = useCallback(() => {
|
||||
const vv = window.visualViewport;
|
||||
if (vv && vv.width > 0 && vv.height > 0) {
|
||||
return {
|
||||
width: vv.width,
|
||||
height: vv.height,
|
||||
offsetTop: vv.offsetTop,
|
||||
offsetLeft: vv.offsetLeft,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
offsetTop: 0,
|
||||
offsetLeft: 0,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const updateDropdownPosition = useCallback(() => {
|
||||
const trigger = triggerRef.current;
|
||||
if (!trigger) return;
|
||||
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
const menu = menuRef.current;
|
||||
const { width: viewportWidth, height: viewportHeight, offsetTop, offsetLeft } = getEffectiveViewport();
|
||||
const horizontalPadding = 16;
|
||||
const verticalPadding = 16;
|
||||
const gap = 6;
|
||||
|
||||
const measuredWidth = menu?.offsetWidth || Math.max(rect.width, 260);
|
||||
const width = Math.min(
|
||||
measuredWidth,
|
||||
Math.max(viewportWidth - horizontalPadding * 2, 160),
|
||||
);
|
||||
|
||||
const measuredHeight = menu?.offsetHeight || 280;
|
||||
const constrainedHeight = Math.min(
|
||||
measuredHeight,
|
||||
Math.max(viewportHeight - verticalPadding * 2, 160),
|
||||
);
|
||||
|
||||
const triggerTop = rect.top - offsetTop;
|
||||
const triggerBottom = rect.bottom - offsetTop;
|
||||
const triggerLeft = rect.left - offsetLeft;
|
||||
|
||||
const spaceBelow = viewportHeight - triggerBottom;
|
||||
const spaceAbove = triggerTop;
|
||||
|
||||
const openUpward = spaceBelow < constrainedHeight && spaceAbove > spaceBelow;
|
||||
|
||||
const left = Math.min(
|
||||
Math.max(triggerLeft, horizontalPadding),
|
||||
viewportWidth - horizontalPadding - width,
|
||||
) + offsetLeft;
|
||||
|
||||
const top = openUpward
|
||||
? Math.max(verticalPadding + offsetTop, triggerTop - constrainedHeight - gap + offsetTop)
|
||||
: Math.min(
|
||||
triggerBottom + gap + offsetTop,
|
||||
viewportHeight + offsetTop - verticalPadding - constrainedHeight,
|
||||
);
|
||||
|
||||
setDropdownPosition({ top, left, width });
|
||||
}, [getEffectiveViewport]);
|
||||
|
||||
// Script entries sorted alphabetically
|
||||
const scriptEntries = useMemo(() => {
|
||||
return Object.entries(scripts).sort(([a], [b]) => a.localeCompare(b));
|
||||
@@ -112,10 +185,49 @@ export function QuickScriptsDropdown({
|
||||
if (isOpen) {
|
||||
setHighlightedIndex(-1);
|
||||
// Focus menu for keyboard navigation
|
||||
setTimeout(() => menuRef.current?.focus(), 0);
|
||||
const timeoutId = window.setTimeout(() => menuRef.current?.focus(), 0);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
setDropdownPosition(null);
|
||||
}, [isOpen]);
|
||||
|
||||
// Position dropdown when opening and whenever content size changes.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
updateDropdownPosition();
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}, [isOpen, loading, scriptEntries.length, showFooter, updateDropdownPosition]);
|
||||
|
||||
// Keep dropdown anchored on viewport and container changes.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleReposition = () => updateDropdownPosition();
|
||||
|
||||
window.addEventListener("resize", handleReposition);
|
||||
window.addEventListener("scroll", handleReposition, true);
|
||||
|
||||
const vv = window.visualViewport;
|
||||
if (vv) {
|
||||
vv.addEventListener("resize", handleReposition);
|
||||
vv.addEventListener("scroll", handleReposition);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleReposition);
|
||||
window.removeEventListener("scroll", handleReposition, true);
|
||||
if (vv) {
|
||||
vv.removeEventListener("resize", handleReposition);
|
||||
vv.removeEventListener("scroll", handleReposition);
|
||||
}
|
||||
};
|
||||
}, [isOpen, updateDropdownPosition]);
|
||||
|
||||
// Handle keyboard navigation within dropdown
|
||||
const handleDropdownKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
@@ -208,6 +320,17 @@ export function QuickScriptsDropdown({
|
||||
aria-label="Scripts"
|
||||
onKeyDown={handleDropdownKeyDown}
|
||||
data-testid="quick-scripts-dropdown"
|
||||
style={
|
||||
dropdownPosition
|
||||
? {
|
||||
position: "fixed",
|
||||
top: `${dropdownPosition.top}px`,
|
||||
left: `${dropdownPosition.left}px`,
|
||||
width: `${dropdownPosition.width}px`,
|
||||
right: "auto",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="quick-scripts-dropdown__loading" data-testid="quick-scripts-loading">
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { FileBrowser } from "../FileBrowser";
|
||||
import type { FileNode } from "../../api";
|
||||
|
||||
@@ -71,10 +73,19 @@ function renderFileBrowser(overrides: Partial<typeof defaultProps> = {}) {
|
||||
return render(<FileBrowser {...props} />);
|
||||
}
|
||||
|
||||
function contextMenuClick(entryName: string) {
|
||||
function contextMenuClick(entryName: string, coords: { x: number; y: number } = { x: 200, y: 300 }) {
|
||||
const entry = screen.getByText(entryName).closest(".file-node");
|
||||
if (!entry) throw new Error(`Entry not found: ${entryName}`);
|
||||
fireEvent.contextMenu(entry, { clientX: 200, clientY: 300 });
|
||||
fireEvent.contextMenu(entry, { clientX: coords.x, clientY: coords.y });
|
||||
}
|
||||
|
||||
function touchStart(entryName: string, coords: { x: number; y: number } = { x: 200, y: 300 }) {
|
||||
const entry = screen.getByText(entryName).closest(".file-node");
|
||||
if (!entry) throw new Error(`Entry not found: ${entryName}`);
|
||||
fireEvent.touchStart(entry, {
|
||||
touches: [{ clientX: coords.x, clientY: coords.y }],
|
||||
});
|
||||
return entry;
|
||||
}
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────
|
||||
@@ -82,6 +93,22 @@ function contextMenuClick(entryName: string) {
|
||||
describe("FileBrowser", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: 1024,
|
||||
});
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: 768,
|
||||
});
|
||||
Object.defineProperty(window, "visualViewport", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -171,6 +198,78 @@ describe("FileBrowser", () => {
|
||||
expect(screen.getByRole("menu")).toBeDefined();
|
||||
});
|
||||
|
||||
it("opens context menu on long-press for a file entry", () => {
|
||||
vi.useFakeTimers();
|
||||
const onSelectFile = vi.fn();
|
||||
renderFileBrowser({ onSelectFile });
|
||||
|
||||
touchStart("readme.md", { x: 120, y: 180 });
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(220);
|
||||
});
|
||||
|
||||
const fileNode = screen.getByText("readme.md").closest(".file-node");
|
||||
expect(fileNode?.classList.contains("file-node--long-pressing")).toBe(true);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
|
||||
expect(screen.getByRole("menu")).toBeDefined();
|
||||
expect(onSelectFile).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.touchEnd(fileNode!);
|
||||
});
|
||||
|
||||
it("opens context menu on long-press for a directory entry", () => {
|
||||
vi.useFakeTimers();
|
||||
const onNavigate = vi.fn();
|
||||
renderFileBrowser({ onNavigate });
|
||||
|
||||
const dirNode = touchStart("src", { x: 160, y: 210 });
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
expect(screen.getByRole("menu")).toBeDefined();
|
||||
expect(onNavigate).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.touchEnd(dirNode);
|
||||
});
|
||||
|
||||
it("cancels long-press when touch moves beyond threshold", () => {
|
||||
vi.useFakeTimers();
|
||||
renderFileBrowser();
|
||||
|
||||
const fileNode = touchStart("readme.md", { x: 100, y: 100 });
|
||||
fireEvent.touchMove(fileNode, {
|
||||
touches: [{ clientX: 120, clientY: 120 }],
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(700);
|
||||
});
|
||||
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
expect(fileNode.classList.contains("file-node--long-pressing")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps single-tap selection behavior on touch devices", () => {
|
||||
vi.useFakeTimers();
|
||||
const onSelectFile = vi.fn();
|
||||
renderFileBrowser({ onSelectFile });
|
||||
|
||||
const fileNode = touchStart("readme.md", { x: 120, y: 160 });
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(120);
|
||||
});
|
||||
fireEvent.touchEnd(fileNode);
|
||||
fireEvent.click(fileNode);
|
||||
|
||||
expect(onSelectFile).toHaveBeenCalledWith("readme.md");
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
});
|
||||
|
||||
// ── Context Menu Items for Files ────────────────────────────────────
|
||||
|
||||
it("shows file context menu with Download option (not Download as ZIP)", () => {
|
||||
@@ -217,6 +316,64 @@ describe("FileBrowser", () => {
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
});
|
||||
|
||||
it("clamps context menu position within visual viewport bounds", async () => {
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: 640,
|
||||
});
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: 700,
|
||||
});
|
||||
Object.defineProperty(window, "visualViewport", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: {
|
||||
width: 320,
|
||||
height: 480,
|
||||
offsetTop: 20,
|
||||
offsetLeft: 10,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
const nativeGetRect = HTMLElement.prototype.getBoundingClientRect;
|
||||
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function () {
|
||||
if (this.classList?.contains("file-browser-context-menu")) {
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 180,
|
||||
height: 220,
|
||||
top: 0,
|
||||
right: 180,
|
||||
bottom: 220,
|
||||
left: 0,
|
||||
toJSON: () => ({}),
|
||||
};
|
||||
}
|
||||
return nativeGetRect.call(this);
|
||||
});
|
||||
|
||||
renderFileBrowser();
|
||||
contextMenuClick("readme.md", { x: 400, y: 500 });
|
||||
|
||||
const menu = await screen.findByRole("menu");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(menu).toHaveStyle({ left: "142px", top: "272px" });
|
||||
});
|
||||
});
|
||||
|
||||
it("defines 44px mobile touch targets for context menu items", () => {
|
||||
const cssPath = resolve(process.cwd(), "app/styles.css");
|
||||
const css = readFileSync(cssPath, "utf8");
|
||||
expect(css).toMatch(/@media \(max-width: 768px\)\s*\{[\s\S]*\.file-browser-context-menu__item\s*\{[\s\S]*min-height:\s*44px;/);
|
||||
});
|
||||
|
||||
// ── Download Actions ────────────────────────────────────────────────
|
||||
|
||||
it("opens download URL for file when Download is clicked", () => {
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QuickScriptsDropdown } from "../QuickScriptsDropdown";
|
||||
import { fetchScripts } from "../../api";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchScripts: vi.fn(),
|
||||
}));
|
||||
|
||||
const onOpenScripts = vi.fn();
|
||||
const onRunScript = vi.fn();
|
||||
|
||||
const MOCK_SCRIPTS = {
|
||||
build: "pnpm build",
|
||||
lint: "pnpm lint",
|
||||
test: "pnpm test",
|
||||
};
|
||||
|
||||
describe("QuickScriptsDropdown", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
vi.mocked(fetchScripts).mockResolvedValue(MOCK_SCRIPTS);
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: 1280,
|
||||
});
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: 900,
|
||||
});
|
||||
Object.defineProperty(window, "visualViewport", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: undefined,
|
||||
});
|
||||
vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
} as MediaQueryList));
|
||||
});
|
||||
|
||||
function renderDropdown() {
|
||||
render(
|
||||
<QuickScriptsDropdown
|
||||
onOpenScripts={onOpenScripts}
|
||||
onRunScript={onRunScript}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
function mockTriggerRect(rect: Partial<DOMRect>) {
|
||||
const trigger = screen.getByTestId("scripts-btn");
|
||||
vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({
|
||||
x: rect.left ?? 0,
|
||||
y: rect.top ?? 0,
|
||||
width: rect.width ?? 80,
|
||||
height: rect.height ?? 32,
|
||||
top: rect.top ?? 0,
|
||||
right: (rect.left ?? 0) + (rect.width ?? 80),
|
||||
bottom: (rect.top ?? 0) + (rect.height ?? 32),
|
||||
left: rect.left ?? 0,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
|
||||
return trigger;
|
||||
}
|
||||
|
||||
it("renders below trigger when space is available", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDropdown();
|
||||
const trigger = mockTriggerRect({ top: 120, left: 220, width: 120, height: 36 });
|
||||
|
||||
await user.click(trigger);
|
||||
|
||||
const dropdown = await screen.findByTestId("quick-scripts-dropdown");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(dropdown.style.position).toBe("fixed");
|
||||
expect(dropdown.style.top).toBe("162px");
|
||||
expect(dropdown.style.left).toBe("220px");
|
||||
expect(dropdown.style.width).toBe("260px");
|
||||
});
|
||||
});
|
||||
|
||||
it("repositions above trigger when viewport bottom is near", async () => {
|
||||
const user = userEvent.setup();
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: 375,
|
||||
});
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: 667,
|
||||
});
|
||||
|
||||
renderDropdown();
|
||||
const trigger = mockTriggerRect({ top: 560, left: 330, width: 120, height: 36 });
|
||||
|
||||
await user.click(trigger);
|
||||
|
||||
const dropdown = await screen.findByTestId("quick-scripts-dropdown");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(dropdown.style.top).toBe("274px");
|
||||
expect(dropdown.style.left).toBe("99px");
|
||||
expect(dropdown.style.width).toBe("260px");
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps horizontal position to viewport edges on small screens", async () => {
|
||||
const user = userEvent.setup();
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: 360,
|
||||
});
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: 700,
|
||||
});
|
||||
|
||||
renderDropdown();
|
||||
const trigger = mockTriggerRect({ top: 140, left: -40, width: 120, height: 36 });
|
||||
|
||||
await user.click(trigger);
|
||||
|
||||
const dropdown = await screen.findByTestId("quick-scripts-dropdown");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(dropdown.style.left).toBe("16px");
|
||||
});
|
||||
});
|
||||
|
||||
it("repositions on window resize", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDropdown();
|
||||
|
||||
const triggerRect = { top: 120, left: 220, width: 120, height: 36 };
|
||||
const trigger = mockTriggerRect(triggerRect);
|
||||
|
||||
await user.click(trigger);
|
||||
|
||||
const dropdown = await screen.findByTestId("quick-scripts-dropdown");
|
||||
await waitFor(() => {
|
||||
expect(dropdown.style.top).toBe("162px");
|
||||
});
|
||||
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: 360,
|
||||
});
|
||||
fireEvent(window, new Event("resize"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(dropdown.style.top).toBe("64px");
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps keyboard navigation behavior (arrow keys, enter, escape)", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDropdown();
|
||||
const trigger = mockTriggerRect({ top: 120, left: 220, width: 120, height: 36 });
|
||||
|
||||
await user.click(trigger);
|
||||
|
||||
const dropdown = await screen.findByTestId("quick-scripts-dropdown");
|
||||
await user.keyboard("{ArrowDown}");
|
||||
await user.keyboard("{Enter}");
|
||||
|
||||
expect(onRunScript).toHaveBeenCalledWith("build", "pnpm build");
|
||||
|
||||
await user.click(trigger);
|
||||
await screen.findByTestId("quick-scripts-dropdown");
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
|
||||
});
|
||||
|
||||
expect(dropdown).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const stylesPath = path.resolve(__dirname, "../../styles.css");
|
||||
|
||||
describe("mobile dropdown positioning and momentum scrolling css", () => {
|
||||
it("includes iOS momentum scrolling for dep-dropdown", () => {
|
||||
const css = fs.readFileSync(stylesPath, "utf-8");
|
||||
|
||||
expect(css).toMatch(/\.dep-dropdown[\s\S]*-webkit-overflow-scrolling:\s*touch;/);
|
||||
});
|
||||
|
||||
it("includes iOS momentum scrolling for file-browser-list", () => {
|
||||
const css = fs.readFileSync(stylesPath, "utf-8");
|
||||
|
||||
expect(css).toMatch(/\.file-browser-list[\s\S]*-webkit-overflow-scrolling:\s*touch;/);
|
||||
});
|
||||
|
||||
it("includes modal momentum scrolling selectors inside the 768px mobile media query", () => {
|
||||
const css = fs.readFileSync(stylesPath, "utf-8");
|
||||
const momentumBlockMatch = css.match(/@media \(max-width: 768px\)\s*\{[\s\S]*\/\* iOS momentum scrolling for all modal content areas \*\/[\s\S]*?\}/);
|
||||
|
||||
expect(momentumBlockMatch).toBeTruthy();
|
||||
|
||||
const block = momentumBlockMatch?.[0] ?? "";
|
||||
expect(block).toContain(".modal-content");
|
||||
expect(block).toContain(".modal-body");
|
||||
expect(block).toContain(".modal-scroll");
|
||||
expect(block).toContain(".task-detail-content");
|
||||
expect(block).toContain(".agent-log-content");
|
||||
expect(block).toContain(".file-browser-list");
|
||||
expect(block).toContain(".settings-content");
|
||||
expect(block).toContain(".activity-log-content");
|
||||
expect(block).toContain("-webkit-overflow-scrolling: touch;");
|
||||
});
|
||||
});
|
||||
@@ -23450,3 +23450,58 @@ html .column.drag-over * {
|
||||
background: var(--border);
|
||||
margin: 8px 16px;
|
||||
}
|
||||
|
||||
/* FN-1137: Quick scripts dropdown mobile viewport handling */
|
||||
@media (max-width: 768px) {
|
||||
.quick-scripts-dropdown__menu {
|
||||
max-height: min(60vh, 400px);
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
|
||||
/* FN-1137: mobile dep dropdown overflow handling */
|
||||
@media (max-width: 768px) {
|
||||
.dep-dropdown {
|
||||
max-height: min(50vh, 240px);
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
|
||||
/* FN-1137: file browser long-press and mobile context menu touch targets */
|
||||
.file-node {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.file-node--long-pressing {
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.file-browser-context-menu__item {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
/* FN-1137: iOS momentum scrolling conventions */
|
||||
.dep-dropdown,
|
||||
.model-combobox-list,
|
||||
.quick-scripts-dropdown__list,
|
||||
.file-browser-list {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* iOS momentum scrolling for all modal content areas */
|
||||
.modal-content,
|
||||
.modal-body,
|
||||
.modal-scroll,
|
||||
.task-detail-content,
|
||||
.agent-log-content,
|
||||
.file-browser-list,
|
||||
.settings-content,
|
||||
.activity-log-content {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user