feat(FN-685): add QuickScriptsDropdown component to dashboard header

- Create QuickScriptsDropdown component with script listing, one-click execution, and keyboard navigation (arrow keys, Enter, Escape)
- Replace static Scripts button in Header with dropdown menu showing all configured scripts
- Add loading, empty, and error states with 'Manage Scripts...' footer link
- Add full CSS styles with theme support, animations, and mobile responsiveness
- Add comprehensive tests (510 lines) covering rendering, interactions, keyboard nav, and edge cases
This commit is contained in:
gsxdsm
2026-04-01 09:19:28 -07:00
parent 04e93790b9
commit 5c367d2a97
6 changed files with 998 additions and 9 deletions

View File

@@ -492,6 +492,7 @@ function AppInner() {
onOpenWorkflowSteps={() => setWorkflowStepsOpen(true)}
onOpenAgents={handleOpenAgents}
onOpenScripts={handleOpenScripts}
onRunScript={handleRunScript}
onToggleTerminal={handleToggleTerminal}
onOpenFiles={handleOpenFiles}
filesOpen={filesOpen}

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from "react";
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Workflow, Bot, ChevronLeft } from "lucide-react";
import type { ProjectInfo } from "@fusion/core";
import { ProjectSelector } from "./ProjectSelector";
import { QuickScriptsDropdown } from "./QuickScriptsDropdown";
// GitHub logo icon (Octocat mark) - uses currentColor for theme compatibility
function GitHubLogo({ size = 16 }: { size?: number }) {
@@ -29,6 +30,7 @@ export interface HeaderProps {
onOpenWorkflowSteps?: () => void;
onOpenAgents?: () => void;
onOpenScripts?: () => void;
onRunScript?: (name: string, command: string) => void;
onToggleTerminal?: () => void;
/** Opens the top-level workspace-aware file browser modal. */
onOpenFiles?: () => void;
@@ -76,6 +78,7 @@ export function Header({
onOpenWorkflowSteps,
onOpenAgents,
onOpenScripts,
onRunScript,
onToggleTerminal,
onOpenFiles,
filesOpen,
@@ -396,15 +399,11 @@ export function Header({
)}
{/* Scripts - desktop only */}
{!isMobile && onOpenScripts && (
<button
className="btn-icon"
onClick={onOpenScripts}
title="Scripts"
data-testid="scripts-btn"
>
<Terminal size={16} />
</button>
{!isMobile && onOpenScripts && onRunScript && (
<QuickScriptsDropdown
onOpenScripts={onOpenScripts}
onRunScript={onRunScript}
/>
)}
{/* Settings - always inline on desktop */}

View File

@@ -0,0 +1,510 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { QuickScriptsDropdown } from "./QuickScriptsDropdown";
// Mock the API functions
const mockFetchScripts = vi.fn();
vi.mock("../api", () => ({
fetchScripts: () => mockFetchScripts(),
}));
const mockOnOpenScripts = vi.fn();
const mockOnRunScript = vi.fn();
function renderDropdown(props = {}) {
return render(
<QuickScriptsDropdown
onOpenScripts={mockOnOpenScripts}
onRunScript={mockOnRunScript}
{...props}
/>
);
}
describe("QuickScriptsDropdown", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("rendering", () => {
it("renders the trigger button", () => {
renderDropdown();
expect(screen.getByTestId("scripts-btn")).toBeDefined();
expect(screen.getByTitle("Scripts")).toBeDefined();
});
it("does not show dropdown menu initially", () => {
renderDropdown();
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
});
});
describe("dropdown open/close", () => {
it("opens dropdown when trigger is clicked", async () => {
mockFetchScripts.mockResolvedValue({});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
});
});
it("closes dropdown when clicking outside", async () => {
mockFetchScripts.mockResolvedValue({});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
});
fireEvent.mouseDown(document.body);
await waitFor(() => {
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
});
});
it("closes dropdown on Escape key", async () => {
mockFetchScripts.mockResolvedValue({});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
});
fireEvent.keyDown(document, { key: "Escape" });
await waitFor(() => {
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
});
});
it("closes dropdown when trigger is clicked again", async () => {
mockFetchScripts.mockResolvedValue({});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
});
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
});
});
});
describe("fetching and displaying scripts", () => {
it("shows loading state while fetching", async () => {
mockFetchScripts.mockImplementation(() => new Promise(() => {}));
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
expect(screen.getByTestId("quick-scripts-loading")).toBeDefined();
});
it("fetches and displays scripts", async () => {
mockFetchScripts.mockResolvedValue({
build: "npm run build",
test: "npm test",
lint: "npm run lint",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-script-item-build")).toBeDefined();
expect(screen.getByTestId("quick-script-item-test")).toBeDefined();
expect(screen.getByTestId("quick-script-item-lint")).toBeDefined();
});
});
it("displays script names and truncated commands", async () => {
mockFetchScripts.mockResolvedValue({
"long-command": "this is a very long command that should be truncated",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
const item = screen.getByTestId("quick-script-item-long-command");
expect(item.textContent).toContain("long-command");
expect(item.textContent).toContain("this is a very long command that should be truncat...");
});
});
it("handles short commands without truncation", async () => {
mockFetchScripts.mockResolvedValue({
short: "echo hi",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
const item = screen.getByTestId("quick-script-item-short");
expect(item.textContent).toContain("short");
expect(item.textContent).toContain("echo hi");
});
});
it("sorts scripts alphabetically", async () => {
mockFetchScripts.mockResolvedValue({
zebra: "echo zebra",
alpha: "echo alpha",
beta: "echo beta",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
const items = screen.getAllByRole("option");
expect(items[0].textContent).toContain("alpha");
expect(items[1].textContent).toContain("beta");
expect(items[2].textContent).toContain("zebra");
});
});
});
describe("running scripts", () => {
it("calls onRunScript when a script is clicked", async () => {
mockFetchScripts.mockResolvedValue({
build: "npm run build",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-script-item-build")).toBeDefined();
});
fireEvent.click(screen.getByTestId("quick-script-item-build"));
expect(mockOnRunScript).toHaveBeenCalledWith("build", "npm run build");
});
it("closes dropdown after running script", async () => {
mockFetchScripts.mockResolvedValue({
test: "npm test",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-script-item-test")).toBeDefined();
});
fireEvent.click(screen.getByTestId("quick-script-item-test"));
await waitFor(() => {
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
});
});
});
describe("manage scripts link", () => {
it("shows 'Manage Scripts...' link when scripts exist", async () => {
mockFetchScripts.mockResolvedValue({
build: "npm run build",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-scripts-manage")).toBeDefined();
});
});
it("calls onOpenScripts when 'Manage Scripts...' is clicked", async () => {
mockFetchScripts.mockResolvedValue({
build: "npm run build",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-scripts-manage")).toBeDefined();
});
fireEvent.click(screen.getByTestId("quick-scripts-manage"));
expect(mockOnOpenScripts).toHaveBeenCalled();
});
it("closes dropdown when 'Manage Scripts...' is clicked", async () => {
mockFetchScripts.mockResolvedValue({
build: "npm run build",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-scripts-manage")).toBeDefined();
});
fireEvent.click(screen.getByTestId("quick-scripts-manage"));
await waitFor(() => {
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
});
});
});
describe("empty state", () => {
it("shows empty state when no scripts configured", async () => {
mockFetchScripts.mockResolvedValue({});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-scripts-empty")).toBeDefined();
});
});
it("empty state shows 'Add your first script' button", async () => {
mockFetchScripts.mockResolvedValue({});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByText("Add your first script")).toBeDefined();
});
});
it("clicking 'Add your first script' calls onOpenScripts", async () => {
mockFetchScripts.mockResolvedValue({});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByText("Add your first script")).toBeDefined();
});
fireEvent.click(screen.getByText("Add your first script"));
expect(mockOnOpenScripts).toHaveBeenCalled();
});
it("closes dropdown when empty state action is clicked", async () => {
mockFetchScripts.mockResolvedValue({});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByText("Add your first script")).toBeDefined();
});
fireEvent.click(screen.getByText("Add your first script"));
await waitFor(() => {
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
});
});
});
describe("keyboard navigation", () => {
it("supports ArrowDown to highlight items", async () => {
mockFetchScripts.mockResolvedValue({
alpha: "echo alpha",
beta: "echo beta",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-script-item-alpha")).toBeDefined();
});
const menu = screen.getByTestId("quick-scripts-dropdown");
// First ArrowDown highlights first item
fireEvent.keyDown(menu, { key: "ArrowDown" });
expect(screen.getByTestId("quick-script-item-alpha").className).toContain("highlighted");
// Second ArrowDown highlights second item
fireEvent.keyDown(menu, { key: "ArrowDown" });
expect(screen.getByTestId("quick-script-item-beta").className).toContain("highlighted");
});
it("supports ArrowUp to highlight items", async () => {
mockFetchScripts.mockResolvedValue({
alpha: "echo alpha",
beta: "echo beta",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-script-item-alpha")).toBeDefined();
});
const menu = screen.getByTestId("quick-scripts-dropdown");
// Go to bottom first with End key
fireEvent.keyDown(menu, { key: "End" });
// ArrowUp moves to previous item
fireEvent.keyDown(menu, { key: "ArrowUp" });
expect(screen.getByTestId("quick-script-item-beta").className).toContain("highlighted");
});
it("wraps around with arrow keys", async () => {
mockFetchScripts.mockResolvedValue({
alpha: "echo alpha",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-script-item-alpha")).toBeDefined();
});
const menu = screen.getByTestId("quick-scripts-dropdown");
// ArrowUp from start wraps to end (Manage Scripts...)
fireEvent.keyDown(menu, { key: "ArrowUp" });
expect(screen.getByTestId("quick-scripts-manage").className).toContain("highlighted");
// ArrowDown from end wraps to start
fireEvent.keyDown(menu, { key: "ArrowDown" });
expect(screen.getByTestId("quick-script-item-alpha").className).toContain("highlighted");
});
it("runs script with Enter key", async () => {
mockFetchScripts.mockResolvedValue({
build: "npm run build",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-script-item-build")).toBeDefined();
});
const menu = screen.getByTestId("quick-scripts-dropdown");
// Highlight and press Enter
fireEvent.keyDown(menu, { key: "ArrowDown" });
fireEvent.keyDown(menu, { key: "Enter" });
expect(mockOnRunScript).toHaveBeenCalledWith("build", "npm run build");
});
it("opens manage scripts with Enter key on manage button", async () => {
mockFetchScripts.mockResolvedValue({
build: "npm run build",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-scripts-manage")).toBeDefined();
});
const menu = screen.getByTestId("quick-scripts-dropdown");
// Navigate to last item (Manage Scripts...) and press Enter
fireEvent.keyDown(menu, { key: "End" });
fireEvent.keyDown(menu, { key: "Enter" });
expect(mockOnOpenScripts).toHaveBeenCalled();
});
it("supports Home key to go to first item", async () => {
mockFetchScripts.mockResolvedValue({
alpha: "echo alpha",
beta: "echo beta",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-script-item-alpha")).toBeDefined();
});
const menu = screen.getByTestId("quick-scripts-dropdown");
// Go to end first
fireEvent.keyDown(menu, { key: "End" });
// Home goes to first
fireEvent.keyDown(menu, { key: "Home" });
expect(screen.getByTestId("quick-script-item-alpha").className).toContain("highlighted");
});
it("supports End key to go to last item", async () => {
mockFetchScripts.mockResolvedValue({
alpha: "echo alpha",
beta: "echo beta",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-script-item-alpha")).toBeDefined();
});
const menu = screen.getByTestId("quick-scripts-dropdown");
fireEvent.keyDown(menu, { key: "End" });
expect(screen.getByTestId("quick-scripts-manage").className).toContain("highlighted");
});
});
describe("error handling", () => {
it("handles fetch errors gracefully", async () => {
mockFetchScripts.mockRejectedValue(new Error("Failed to fetch"));
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
// Should show empty state since scripts will be empty object on error
expect(screen.getByTestId("quick-scripts-empty")).toBeDefined();
});
});
});
describe("focus management", () => {
it("menu is focusable with tabIndex", async () => {
mockFetchScripts.mockResolvedValue({});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
});
const menu = screen.getByTestId("quick-scripts-dropdown");
expect(menu).toHaveAttribute("tabIndex", "-1");
});
it("focus moves to trigger when Escape is pressed", async () => {
mockFetchScripts.mockResolvedValue({});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
});
fireEvent.keyDown(document, { key: "Escape" });
await waitFor(() => {
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
});
// Trigger should have focus
expect(document.activeElement).toBe(screen.getByTestId("scripts-btn"));
});
});
});

View File

@@ -0,0 +1,268 @@
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import { Terminal, Play, Settings, Loader2, ChevronDown } from "lucide-react";
import { fetchScripts } from "../api";
export interface QuickScriptsDropdownProps {
onOpenScripts: () => void;
onRunScript: (name: string, command: string) => void;
}
/**
* QuickScriptsDropdown - Dropdown for quick script execution
*
* Features:
* - Dropdown trigger with Terminal icon + chevron
* - Fetches and displays all available scripts
* - Click to run script immediately (opens terminal)
* - "Manage Scripts..." footer to open full modal
* - Keyboard navigation: arrow keys, enter to run, escape to close
* - Loading state while fetching
* - Empty state when no scripts configured
* - Closes on outside click
*/
export function QuickScriptsDropdown({
onOpenScripts,
onRunScript,
}: QuickScriptsDropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const [scripts, setScripts] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const dropdownRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
// Script entries sorted alphabetically
const scriptEntries = useMemo(() => {
return Object.entries(scripts).sort(([a], [b]) => a.localeCompare(b));
}, [scripts]);
// Total items for keyboard navigation (scripts + "Manage Scripts...")
const totalItems = scriptEntries.length + 1;
// Fetch scripts when dropdown opens
useEffect(() => {
if (!isOpen) return;
let cancelled = false;
setLoading(true);
fetchScripts()
.then((data) => {
if (!cancelled) {
setScripts(data);
}
})
.catch(() => {
if (!cancelled) {
setScripts({});
}
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [isOpen]);
// Close dropdown on outside click
useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (e: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(e.target as Node) &&
triggerRef.current &&
!triggerRef.current.contains(e.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isOpen]);
// Close on escape key
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setIsOpen(false);
triggerRef.current?.focus();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen]);
// Reset highlight when dropdown opens and focus the menu
useEffect(() => {
if (isOpen) {
setHighlightedIndex(-1);
// Focus menu for keyboard navigation
setTimeout(() => menuRef.current?.focus(), 0);
}
}, [isOpen]);
// Handle keyboard navigation within dropdown
const handleDropdownKeyDown = useCallback(
(e: React.KeyboardEvent) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setHighlightedIndex((prev) =>
prev < totalItems - 1 ? prev + 1 : 0
);
break;
case "ArrowUp":
e.preventDefault();
setHighlightedIndex((prev) =>
prev > 0 ? prev - 1 : totalItems - 1
);
break;
case "Enter":
e.preventDefault();
if (highlightedIndex >= 0) {
if (highlightedIndex < scriptEntries.length) {
// Run script
const [name, command] = scriptEntries[highlightedIndex];
handleRunScript(name, command);
} else {
// Manage Scripts...
handleManageScripts();
}
}
break;
case "Home":
e.preventDefault();
setHighlightedIndex(0);
break;
case "End":
e.preventDefault();
setHighlightedIndex(totalItems - 1);
break;
}
},
[highlightedIndex, totalItems, scriptEntries]
);
// Toggle dropdown
const toggleDropdown = useCallback(() => {
setIsOpen((prev) => !prev);
}, []);
// Handle run script
const handleRunScript = useCallback(
(name: string, command: string) => {
onRunScript(name, command);
setIsOpen(false);
},
[onRunScript]
);
// Handle manage scripts
const handleManageScripts = useCallback(() => {
onOpenScripts();
setIsOpen(false);
}, [onOpenScripts]);
return (
<div className="quick-scripts-dropdown" ref={dropdownRef}>
{/* Trigger button */}
<button
ref={triggerRef}
className={`quick-scripts-dropdown__trigger ${isOpen ? "open" : ""}`}
onClick={toggleDropdown}
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-label="Quick scripts"
data-testid="scripts-btn"
title="Scripts"
>
<Terminal size={16} />
<ChevronDown
size={14}
className={`quick-scripts-dropdown__trigger-chevron ${isOpen ? "rotate" : ""}`}
/>
</button>
{/* Dropdown menu */}
{isOpen && (
<div
ref={menuRef}
tabIndex={-1}
className="quick-scripts-dropdown__menu"
role="listbox"
aria-label="Scripts"
onKeyDown={handleDropdownKeyDown}
data-testid="quick-scripts-dropdown"
>
{loading ? (
<div className="quick-scripts-dropdown__loading" data-testid="quick-scripts-loading">
<Loader2 size={16} className="animate-spin" />
<span>Loading scripts...</span>
</div>
) : scriptEntries.length === 0 ? (
<div className="quick-scripts-dropdown__empty" data-testid="quick-scripts-empty">
<p>No scripts configured</p>
<button
className="quick-scripts-dropdown__empty-action"
onClick={handleManageScripts}
>
Add your first script
</button>
</div>
) : (
<>
{/* Script list */}
<div className="quick-scripts-dropdown__list">
{scriptEntries.map(([name, command], index) => (
<button
key={name}
className={`quick-scripts-dropdown__item ${
highlightedIndex === index ? "highlighted" : ""
}`}
onClick={() => handleRunScript(name, command)}
role="option"
aria-selected={highlightedIndex === index}
data-testid={`quick-script-item-${name}`}
>
<Play size={14} className="quick-scripts-dropdown__item-icon" />
<div className="quick-scripts-dropdown__item-info">
<span className="quick-scripts-dropdown__item-name">{name}</span>
<span className="quick-scripts-dropdown__item-command" title={command}>
{command.length > 50 ? `${command.slice(0, 50)}...` : command}
</span>
</div>
</button>
))}
</div>
{/* Footer */}
<div className="quick-scripts-dropdown__footer">
<button
className={`quick-scripts-dropdown__manage ${
highlightedIndex === scriptEntries.length ? "highlighted" : ""
}`}
onClick={handleManageScripts}
data-testid="quick-scripts-manage"
>
<Settings size={14} />
<span>Manage Scripts...</span>
</button>
</div>
</>
)}
</div>
)}
</div>
);
}

View File

@@ -13072,6 +13072,204 @@ html .column.drag-over * {
}
}
/* === QuickScriptsDropdown Component === */
.quick-scripts-dropdown {
position: relative;
display: inline-block;
}
.quick-scripts-dropdown__trigger {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 8px;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-md);
color: var(--text);
cursor: pointer;
transition:
background-color var(--transition-fast),
border-color var(--transition-fast);
}
.quick-scripts-dropdown__trigger:hover,
.quick-scripts-dropdown__trigger.open {
background: var(--surface);
border-color: var(--border);
}
.quick-scripts-dropdown__trigger-chevron {
color: var(--text-muted);
transition: transform var(--transition-fast);
}
.quick-scripts-dropdown__trigger-chevron.rotate {
transform: rotate(180deg);
}
.quick-scripts-dropdown__menu {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 50;
min-width: 280px;
max-width: min(400px, 90vw);
max-height: min(60vh, 400px);
overflow-y: auto;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
outline: none;
}
.quick-scripts-dropdown__list {
display: flex;
flex-direction: column;
padding: var(--space-sm);
}
.quick-scripts-dropdown__item {
display: flex;
align-items: center;
gap: var(--space-sm);
width: 100%;
padding: 10px 12px;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-md);
color: var(--text);
text-align: left;
cursor: pointer;
transition:
background-color var(--transition-fast),
border-color var(--transition-fast);
}
.quick-scripts-dropdown__item:hover,
.quick-scripts-dropdown__item.highlighted {
background: var(--surface);
border-color: var(--border);
}
.quick-scripts-dropdown__item-icon {
color: var(--todo);
flex-shrink: 0;
}
.quick-scripts-dropdown__item-info {
display: flex;
flex: 1;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.quick-scripts-dropdown__item-name {
font-size: 13px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.quick-scripts-dropdown__item-command {
font-size: 11px;
color: var(--text-muted);
font-family: monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.quick-scripts-dropdown__footer {
padding: var(--space-sm);
border-top: 1px solid var(--border);
}
.quick-scripts-dropdown__manage {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
width: 100%;
padding: 10px 12px;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-md);
color: var(--todo);
font-weight: 600;
font-size: 13px;
cursor: pointer;
transition:
background-color var(--transition-fast),
border-color var(--transition-fast);
}
.quick-scripts-dropdown__manage:hover,
.quick-scripts-dropdown__manage.highlighted {
background: var(--surface);
border-color: var(--border);
}
.quick-scripts-dropdown__loading {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
padding: var(--space-lg);
color: var(--text-muted);
font-size: 13px;
}
.quick-scripts-dropdown__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-md);
padding: var(--space-lg);
text-align: center;
}
.quick-scripts-dropdown__empty p {
margin: 0;
color: var(--text-muted);
font-size: 13px;
}
.quick-scripts-dropdown__empty-action {
padding: 8px 16px;
background: var(--todo);
color: white;
border: none;
border-radius: var(--radius-md);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: opacity var(--transition-fast);
}
.quick-scripts-dropdown__empty-action:hover {
opacity: 0.9;
}
/* Mobile responsive styles */
@media (max-width: 640px) {
.quick-scripts-dropdown,
.quick-scripts-dropdown__trigger,
.quick-scripts-dropdown__menu {
width: 100%;
min-width: 0;
}
.quick-scripts-dropdown__menu {
left: 0;
right: 0;
max-width: none;
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;