feat(web): collapse mobile task search into a topbar icon (#1157)
This commit is contained in:
@@ -49,6 +49,7 @@ If task has no UI surface, say why this skill does not apply and continue.
|
||||
- Run the narrowest relevant viewport locally or with screenshots when possible.
|
||||
- Check that text does not overlap, controls remain clickable, focus/keyboard flows still work, and no unintended horizontal scroll appears.
|
||||
- Run the focused Playwright tests. If full E2E cannot run, report the command and blocker.
|
||||
- E2E runs against the standalone build, not a dev server, so rebuild after frontend changes: `make build-web` (and `make build-backend` for Go), or use `make test-e2e` which rebuilds both. Skipping this silently tests stale code. See `/e2e`.
|
||||
|
||||
## Mobile E2E Expectations
|
||||
|
||||
|
||||
@@ -8,10 +8,13 @@ import { getColumns } from "./columns";
|
||||
import { archiveTask, deleteTask, listTasksByWorkspace } from "@/lib/api";
|
||||
import { TaskCreateDialog } from "@/components/task-create-dialog";
|
||||
import { KanbanHeader } from "@/components/kanban/kanban-header";
|
||||
import { MobileSearchBar } from "@/components/kanban/mobile-search-bar";
|
||||
import { Checkbox } from "@kandev/ui/checkbox";
|
||||
import { Label } from "@kandev/ui/label";
|
||||
import type { Task, Workspace, Workflow, WorkflowStep, Repository } from "@/lib/types/http";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||
import { useKanbanDisplaySettings } from "@/hooks/use-kanban-display-settings";
|
||||
import { useDebounce } from "@/hooks/use-debounce";
|
||||
|
||||
@@ -410,6 +413,13 @@ function useTasksPageSetup(props: TasksPageClientProps) {
|
||||
|
||||
export function TasksPageClient(props: TasksPageClientProps) {
|
||||
const s = useTasksPageSetup(props);
|
||||
const { isMobile } = useResponsiveBreakpoint();
|
||||
const isMobileSearchOpen = useAppStore((state) => state.mobileKanban.isSearchOpen);
|
||||
const setMobileSearchOpen = useAppStore((state) => state.setMobileKanbanSearchOpen);
|
||||
|
||||
// Collapse search on unmount so the global flag doesn't auto-open (and focus)
|
||||
// the search bar after navigating to another route.
|
||||
useEffect(() => () => setMobileSearchOpen(false), [setMobileSearchOpen]);
|
||||
return (
|
||||
<div className="h-screen w-full flex flex-col bg-background">
|
||||
<KanbanHeader
|
||||
@@ -420,6 +430,9 @@ export function TasksPageClient(props: TasksPageClientProps) {
|
||||
onSearchChange={s.setSearchQuery}
|
||||
isSearchLoading={s.isLoading && !!s.debouncedQuery}
|
||||
/>
|
||||
{isMobile && isMobileSearchOpen && (
|
||||
<MobileSearchBar searchQuery={s.searchQuery} onSearchChange={s.setSearchQuery} />
|
||||
)}
|
||||
<TasksPageBody
|
||||
showArchived={s.showArchived}
|
||||
setShowArchived={s.setShowArchived}
|
||||
|
||||
@@ -327,6 +327,12 @@ function useKanbanBoardSetup(
|
||||
|
||||
export function KanbanBoard({ onPreviewTask, onOpenTask, onBeforeEdit }: KanbanBoardProps = {}) {
|
||||
const s = useKanbanBoardSetup(onPreviewTask, onOpenTask, onBeforeEdit);
|
||||
const isMobileSearchOpen = useAppStore((state) => state.mobileKanban.isSearchOpen);
|
||||
const setMobileSearchOpen = useAppStore((state) => state.setMobileKanbanSearchOpen);
|
||||
|
||||
// Collapse search on unmount so the global flag doesn't auto-open (and focus)
|
||||
// the search bar after navigating to another route.
|
||||
useEffect(() => () => setMobileSearchOpen(false), [setMobileSearchOpen]);
|
||||
|
||||
if (!s.isMounted) {
|
||||
return <div className="h-dvh w-full bg-background" />;
|
||||
@@ -347,7 +353,7 @@ export function KanbanBoard({ onPreviewTask, onOpenTask, onBeforeEdit }: KanbanB
|
||||
searchQuery={s.searchQuery}
|
||||
onSearchChange={s.setSearchQuery}
|
||||
/>
|
||||
{s.isMobile && (
|
||||
{s.isMobile && isMobileSearchOpen && (
|
||||
<MobileSearchBar searchQuery={s.searchQuery} onSearchChange={s.setSearchQuery} />
|
||||
)}
|
||||
<KanbanBoardDialogs
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { IconMenu2 } from "@tabler/icons-react";
|
||||
import { IconMenu2, IconSearch } from "@tabler/icons-react";
|
||||
import { PageTopbar } from "@/components/page-topbar";
|
||||
import { MobileMenuSheet } from "./mobile-menu-sheet";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
@@ -11,9 +11,7 @@ type KanbanHeaderMobileProps = {
|
||||
currentPage?: "kanban" | "tasks";
|
||||
title: string;
|
||||
workspaceLabel: string;
|
||||
searchQuery?: string;
|
||||
onSearchChange?: (query: string) => void;
|
||||
isSearchLoading?: boolean;
|
||||
showReleaseNotesButton: boolean;
|
||||
onOpenReleaseNotes: () => void;
|
||||
showHealthIndicator: boolean;
|
||||
@@ -25,9 +23,7 @@ export function KanbanHeaderMobile({
|
||||
currentPage = "kanban",
|
||||
title,
|
||||
workspaceLabel,
|
||||
searchQuery = "",
|
||||
onSearchChange,
|
||||
isSearchLoading = false,
|
||||
showReleaseNotesButton,
|
||||
onOpenReleaseNotes,
|
||||
showHealthIndicator,
|
||||
@@ -35,6 +31,15 @@ export function KanbanHeaderMobile({
|
||||
}: KanbanHeaderMobileProps) {
|
||||
const isMenuOpen = useAppStore((state) => state.mobileKanban.isMenuOpen);
|
||||
const setMenuOpen = useAppStore((state) => state.setMobileKanbanMenuOpen);
|
||||
const isSearchOpen = useAppStore((state) => state.mobileKanban.isSearchOpen);
|
||||
const setSearchOpen = useAppStore((state) => state.setMobileKanbanSearchOpen);
|
||||
|
||||
const toggleSearch = () => {
|
||||
const next = !isSearchOpen;
|
||||
setSearchOpen(next);
|
||||
// Clear any active query when collapsing so results aren't filtered by a hidden search.
|
||||
if (!next) onSearchChange?.("");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -44,15 +49,30 @@ export function KanbanHeaderMobile({
|
||||
className="h-10 px-3 py-1"
|
||||
variant={title === "Home" ? "root" : "breadcrumb"}
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-lg"
|
||||
onClick={() => setMenuOpen(true)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<IconMenu2 className="h-4 w-4" />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
<>
|
||||
{onSearchChange && (
|
||||
<Button
|
||||
variant={isSearchOpen ? "secondary" : "outline"}
|
||||
size="icon-lg"
|
||||
onClick={toggleSearch}
|
||||
className="cursor-pointer"
|
||||
aria-pressed={isSearchOpen}
|
||||
aria-label="Search tasks"
|
||||
data-testid="mobile-search-toggle"
|
||||
>
|
||||
<IconSearch className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-lg"
|
||||
onClick={() => setMenuOpen(true)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<IconMenu2 className="h-4 w-4" />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<MobileMenuSheet
|
||||
@@ -60,9 +80,6 @@ export function KanbanHeaderMobile({
|
||||
onOpenChange={setMenuOpen}
|
||||
workspaceId={workspaceId}
|
||||
currentPage={currentPage}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={onSearchChange}
|
||||
isSearchLoading={isSearchLoading}
|
||||
showReleaseNotesButton={showReleaseNotesButton}
|
||||
onOpenReleaseNotes={onOpenReleaseNotes}
|
||||
showHealthIndicator={showHealthIndicator}
|
||||
|
||||
@@ -510,7 +510,7 @@ export function KanbanHeader({
|
||||
currentPage={currentPage}
|
||||
title={title}
|
||||
workspaceLabel={workspaceLabel}
|
||||
{...sharedSearch}
|
||||
onSearchChange={onSearchChange}
|
||||
{...indicatorProps}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -9,12 +9,13 @@ type MobileSearchBarProps = {
|
||||
|
||||
export function MobileSearchBar({ searchQuery, onSearchChange }: MobileSearchBarProps) {
|
||||
return (
|
||||
<div className="px-4 pb-2" data-testid="mobile-search-bar">
|
||||
<div className="border-b border-border px-4 py-2" data-testid="mobile-search-bar">
|
||||
<TaskSearchInput
|
||||
value={searchQuery}
|
||||
onChange={onSearchChange}
|
||||
placeholder="Search tasks..."
|
||||
className="w-full"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ interface TaskSearchInputProps {
|
||||
debounceMs?: number;
|
||||
isLoading?: boolean;
|
||||
className?: string;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
export function TaskSearchInput({
|
||||
@@ -21,15 +22,24 @@ export function TaskSearchInput({
|
||||
debounceMs = 300,
|
||||
isLoading = false,
|
||||
className,
|
||||
autoFocus = false,
|
||||
}: TaskSearchInputProps) {
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// Sync local value when external value changes
|
||||
useEffect(() => {
|
||||
setLocalValue(value);
|
||||
}, [value]);
|
||||
|
||||
// Focus on mount when requested (e.g. when the mobile search bar expands)
|
||||
useEffect(() => {
|
||||
if (autoFocus) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [autoFocus]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
@@ -73,6 +83,7 @@ export function TaskSearchInput({
|
||||
<IconSearch className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground pointer-events-none" />
|
||||
)}
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={localValue}
|
||||
onChange={handleChange}
|
||||
|
||||
@@ -4,6 +4,7 @@ export class MobileKanbanPage {
|
||||
readonly board: Locator;
|
||||
readonly mobileFab: Locator;
|
||||
readonly mobileSearchBar: Locator;
|
||||
readonly mobileSearchToggle: Locator;
|
||||
readonly mobileMenuButton: Locator;
|
||||
readonly mobileTaskSheet: Locator;
|
||||
readonly swimlaneContainer: Locator;
|
||||
@@ -12,6 +13,7 @@ export class MobileKanbanPage {
|
||||
this.board = page.getByTestId("kanban-board");
|
||||
this.mobileFab = page.getByTestId("mobile-fab");
|
||||
this.mobileSearchBar = page.getByTestId("mobile-search-bar");
|
||||
this.mobileSearchToggle = page.getByTestId("mobile-search-toggle");
|
||||
this.mobileMenuButton = page.getByRole("button", { name: "Open menu" });
|
||||
this.mobileTaskSheet = page.getByTestId("mobile-task-sheet");
|
||||
this.swimlaneContainer = page.getByTestId("swimlane-container");
|
||||
@@ -46,6 +48,11 @@ export class MobileKanbanPage {
|
||||
return this.mobileSearchBar.getByPlaceholder("Search tasks...");
|
||||
}
|
||||
|
||||
async openSearch() {
|
||||
await this.mobileSearchToggle.click();
|
||||
await this.mobileSearchBar.waitFor({ state: "visible" });
|
||||
}
|
||||
|
||||
sheetGoToSession(): Locator {
|
||||
return this.mobileTaskSheet.getByRole("button", { name: /Open Session/ });
|
||||
}
|
||||
|
||||
@@ -19,12 +19,62 @@ test.describe("Mobile kanban view", () => {
|
||||
await expect(mobile.mobileKanbanLayout()).toBeVisible();
|
||||
// FAB should be visible for creating tasks
|
||||
await expect(mobile.mobileFab).toBeVisible();
|
||||
// Search bar should be visible on mobile
|
||||
await expect(mobile.mobileSearchBar).toBeVisible();
|
||||
// Search is collapsed behind a topbar icon by default
|
||||
await expect(mobile.mobileSearchToggle).toBeVisible();
|
||||
await expect(mobile.mobileSearchBar).not.toBeVisible();
|
||||
// Task card should be visible
|
||||
await expect(mobile.taskCardByTitle("Mobile Layout Task")).toBeVisible();
|
||||
});
|
||||
|
||||
test("search toggle reveals and hides the search input", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
await apiClient.createTask(seedData.workspaceId, "Toggle Visible Task", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
|
||||
const mobile = new MobileKanbanPage(testPage);
|
||||
await mobile.goto();
|
||||
|
||||
// Hidden by default, revealed when the topbar search icon is tapped
|
||||
await expect(mobile.mobileSearchBar).not.toBeVisible();
|
||||
await mobile.openSearch();
|
||||
await expect(mobile.mobileSearchBar).toBeVisible();
|
||||
// Input is focused on reveal so the keyboard opens immediately
|
||||
await expect(mobile.searchInput()).toBeFocused();
|
||||
|
||||
// Tapping the icon again collapses the search bar
|
||||
await mobile.mobileSearchToggle.click();
|
||||
await expect(mobile.mobileSearchBar).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("collapsing search clears an active query", async ({ testPage, apiClient, seedData }) => {
|
||||
await apiClient.createTask(seedData.workspaceId, "Clearable Alpha", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
await apiClient.createTask(seedData.workspaceId, "Other Beta", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
|
||||
const mobile = new MobileKanbanPage(testPage);
|
||||
await mobile.goto();
|
||||
|
||||
await mobile.openSearch();
|
||||
await mobile.searchInput().fill("Alpha");
|
||||
await expect(mobile.taskCardByTitle("Other Beta")).not.toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Collapsing clears the query so the full list is shown again
|
||||
await mobile.mobileSearchToggle.click();
|
||||
await expect(mobile.mobileSearchBar).not.toBeVisible();
|
||||
await expect(mobile.taskCardByTitle("Clearable Alpha")).toBeVisible({ timeout: 5000 });
|
||||
await expect(mobile.taskCardByTitle("Other Beta")).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test("shows mobile menu via hamburger button", async ({ testPage }) => {
|
||||
const mobile = new MobileKanbanPage(testPage);
|
||||
await mobile.goto();
|
||||
@@ -100,7 +150,8 @@ test.describe("Mobile kanban view", () => {
|
||||
await expect(mobile.taskCardByTitle("Searchable Alpha")).toBeVisible();
|
||||
await expect(mobile.taskCardByTitle("Hidden Beta")).toBeVisible();
|
||||
|
||||
// Type in search
|
||||
// Reveal the search input from the topbar, then type in it
|
||||
await mobile.openSearch();
|
||||
await mobile.searchInput().fill("Alpha");
|
||||
|
||||
// Only matching task should remain visible
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
|
||||
test.describe("Mobile task list search", () => {
|
||||
test("topbar search icon reveals, filters, and clears on collapse", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
await apiClient.createTask(seedData.workspaceId, "List Alpha Task", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
await apiClient.createTask(seedData.workspaceId, "List Beta Task", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
|
||||
await testPage.goto("/tasks");
|
||||
await testPage.waitForLoadState("networkidle");
|
||||
|
||||
const searchBar = testPage.getByTestId("mobile-search-bar");
|
||||
const searchToggle = testPage.getByTestId("mobile-search-toggle");
|
||||
|
||||
// Both tasks are listed and search is collapsed behind the topbar icon
|
||||
await expect(testPage.getByText("List Alpha Task")).toBeVisible();
|
||||
await expect(testPage.getByText("List Beta Task")).toBeVisible();
|
||||
await expect(searchBar).not.toBeVisible();
|
||||
|
||||
// Tapping the icon reveals the focused search input
|
||||
await searchToggle.click();
|
||||
await expect(searchBar).toBeVisible();
|
||||
await expect(searchBar.getByPlaceholder("Search tasks...")).toBeFocused();
|
||||
|
||||
// Typing filters the list down to the match
|
||||
await searchBar.getByPlaceholder("Search tasks...").fill("Alpha");
|
||||
await expect(testPage.getByText("List Alpha Task")).toBeVisible({ timeout: 5000 });
|
||||
await expect(testPage.getByText("List Beta Task")).not.toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Collapsing clears the query so the full list returns
|
||||
await searchToggle.click();
|
||||
await expect(searchBar).not.toBeVisible();
|
||||
await expect(testPage.getByText("List Alpha Task")).toBeVisible({ timeout: 5000 });
|
||||
await expect(testPage.getByText("List Beta Task")).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,7 @@ export type ConnectionState = {
|
||||
export type MobileKanbanState = {
|
||||
activeColumnIndex: number;
|
||||
isMenuOpen: boolean;
|
||||
isSearchOpen: boolean;
|
||||
};
|
||||
|
||||
export type MobileSessionPanel = "chat" | "plan" | "changes" | "files" | "terminal";
|
||||
@@ -150,6 +151,7 @@ export type UISliceActions = {
|
||||
setConnectionStatus: (status: ConnectionState["status"], error?: string | null) => void;
|
||||
setMobileKanbanColumnIndex: (index: number) => void;
|
||||
setMobileKanbanMenuOpen: (open: boolean) => void;
|
||||
setMobileKanbanSearchOpen: (open: boolean) => void;
|
||||
setMobileSessionPanel: (sessionId: string, panel: MobileSessionPanel) => void;
|
||||
setMobileSessionTaskSwitcherOpen: (open: boolean) => void;
|
||||
setPlanMode: (sessionId: string, enabled: boolean) => void;
|
||||
|
||||
@@ -109,7 +109,7 @@ export const defaultUIState: UISliceState = {
|
||||
rightPanel: { activeTabBySessionId: {} },
|
||||
diffs: { files: [] },
|
||||
connection: { status: "disconnected", error: null },
|
||||
mobileKanban: { activeColumnIndex: 0, isMenuOpen: false },
|
||||
mobileKanban: { activeColumnIndex: 0, isMenuOpen: false, isSearchOpen: false },
|
||||
mobileSession: { activePanelBySessionId: {}, isTaskSwitcherOpen: false },
|
||||
chatInput: { planModeBySessionId: {} },
|
||||
documentPanel: { activeDocumentBySessionId: {} },
|
||||
@@ -183,6 +183,10 @@ function buildMobileActions(set: ImmerSet) {
|
||||
set((draft) => {
|
||||
draft.mobileKanban.isMenuOpen = open;
|
||||
}),
|
||||
setMobileKanbanSearchOpen: (open: boolean) =>
|
||||
set((draft) => {
|
||||
draft.mobileKanban.isSearchOpen = open;
|
||||
}),
|
||||
setMobileSessionPanel: (
|
||||
sessionId: string,
|
||||
panel: UISliceState["mobileSession"]["activePanelBySessionId"][string],
|
||||
|
||||
@@ -330,6 +330,7 @@ export type AppState = {
|
||||
setConnectionStatus: (status: ConnectionState["status"], error?: string | null) => void;
|
||||
setMobileKanbanColumnIndex: (index: number) => void;
|
||||
setMobileKanbanMenuOpen: (open: boolean) => void;
|
||||
setMobileKanbanSearchOpen: (open: boolean) => void;
|
||||
setMobileSessionPanel: (sessionId: string, panel: UISliceTypes.MobileSessionPanel) => void;
|
||||
setMobileSessionTaskSwitcherOpen: (open: boolean) => void;
|
||||
setPlanMode: (sessionId: string, enabled: boolean) => void;
|
||||
|
||||
Reference in New Issue
Block a user