FN-7809: collapse List view to single-pane layout on tablet-narrow widths
Fixes the List view's two-pane split layout clipping primary controls and the expanded quick-add composer on tablet-width viewports (769-1024px) by switching those widths to the same single-pane card/detail layout already used on mobile. - Add `useSinglePaneList` gate in ListView.tsx (`viewportMode === "mobile" || "tablet"`) driving split-vs-single-pane structure, detail routing, and resize-handle wiring, while touch-only long-press behavior stays on `isMobile`. - Extend the mobile-only responsive CSS breakpoints in ListView.css from `max-width: 768px` to `max-width: 1024px` so tablet gets the same toolbar/card scaffolding as mobile, while desktop split rules remain unchanged above that tier. - Add `list-view--single-pane` root class and route tablet clicks through the single-pane `onOpenDetail` path instead of the desktop split-pane selection. - Update docs/dashboard-guide.md to describe the tablet single-pane behavior and add an FNXC:ListView comment recording the FN-7809 rationale. - Add regression tests covering tablet single-pane rendering, tablet detail-open routing, and updated CSS-fixture assertions for the widened breakpoint. - Add a patch changeset describing the fix for @runfusion/fusion release notes. Files changed: .changeset/fn-7809-list-tablet-single-pane.md | 7 +++ docs/dashboard-guide.md | 6 +- packages/dashboard/app/components/ListView.css | 11 ++-- packages/dashboard/app/components/ListView.tsx | 49 ++++++++------- .../app/components/__tests__/ListView.test.tsx | 69 +++++++++++++++++++++- 5 files changed, 112 insertions(+), 30 deletions(-) Fusion-Task-Id: FN-7809 Fusion-Task-Lineage: 9e5f8bb7-13ee-4a58-81b1-eb5a9911bd4e Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7809-list-tablet-single-pane.md
Normal file
7
.changeset/fn-7809-list-tablet-single-pane.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Fix the List view controls and quick-add box being cut off on tablet-width screens.
|
||||||
|
category: fix
|
||||||
|
dev: ListView collapses to a single-pane layout at the `useViewportMode()` "tablet" tier (769–1024px) instead of the desktop two-pane split, which lacked horizontal room and clipped the primary action cluster and expanded QuickEntryBox. Split-vs-single now keys off a shared narrow gate; touch-only long-press stays gated on mobile.
|
||||||
@@ -261,8 +261,10 @@ Features:
|
|||||||
- Bulk selection + batch model updates
|
- Bulk selection + batch model updates
|
||||||
- Bulk Pause / Unpause / Archive actions from the selection toolbar (`Pause selected`, `Unpause selected`, `Archive selected`) for fast batch task state management.
|
- Bulk Pause / Unpause / Archive actions from the selection toolbar (`Pause selected`, `Unpause selected`, `Archive selected`) for fast batch task state management.
|
||||||
- Bulk delete from the selection toolbar (`Delete selected`): archived selections are skipped automatically, and dependency-conflict failures can be force-deleted per task after a danger confirmation that removes dependency references.
|
- Bulk delete from the selection toolbar (`Delete selected`): archived selections are skipped automatically, and dependency-conflict failures can be force-deleted per task after a danger confirmation that removes dependency references.
|
||||||
- List rows and mobile cards support the same task context menu as Board cards from right-click, keyboard context menu / Shift+F10, or touch long-press without changing ordinary row selection or tap-to-open behavior. Selecting an action applies that exact action once and dismisses the menu, including **Refine** for completed tasks.
|
- Desktop List view keeps the two-pane table/detail split. Tablet-width and mobile viewports use the single-pane card layout so list controls and quick-add stay full-width; tapping a task opens detail instead of selecting an embedded split pane.
|
||||||
<!-- FNXC:ListContextMenu 2026-06-29-00:00: List context menus are alternate action entry points only; desktop left-click still selects the split-pane detail and mobile tap still opens detail while long-press suppresses the follow-up tap.
|
- List rows and tablet/mobile cards support the same task context menu as Board cards from right-click, keyboard context menu / Shift+F10, or touch long-press without changing ordinary row selection or tap-to-open behavior. Selecting an action applies that exact action once and dismisses the menu, including **Refine** for completed tasks.
|
||||||
|
<!-- FNXC:ListView 2026-07-10-00:00: FN-7809 makes tablet List view use the same single-pane card/detail route as mobile because the desktop split sidebar clipped the primary controls and quick-add composer at 769–1024px.
|
||||||
|
FNXC:ListContextMenu 2026-06-29-00:00: List context menus are alternate action entry points only; desktop left-click still selects the split-pane detail and mobile tap still opens detail while long-press suppresses the follow-up tap.
|
||||||
FNXC:ListContextMenu 2026-06-30-00:20: Keyboard access is part of the Board/List context-menu contract, so docs must include the context-menu key and Shift+F10 alongside pointer and touch entry points.
|
FNXC:ListContextMenu 2026-06-30-00:20: Keyboard access is part of the Board/List context-menu contract, so docs must include the context-menu key and Shift+F10 alongside pointer and touch entry points.
|
||||||
FNXC:DoneTaskRefine 2026-07-01-00:00: Completed List row/card context menus route Refine to the existing task-detail feedback modal so desktop right-click and mobile long-press share the same refinement flow.
|
FNXC:DoneTaskRefine 2026-07-01-00:00: Completed List row/card context menus route Refine to the existing task-detail feedback modal so desktop right-click and mobile long-press share the same refinement flow.
|
||||||
FNXC:TaskContextMenu 2026-07-01-00:00: Mobile List card long-press action taps must select and dismiss through the same shared TaskContextMenu invariant as Board and Task Detail surfaces. -->
|
FNXC:TaskContextMenu 2026-07-01-00:00: Mobile List card long-press action taps must select and dismiss through the same shared TaskContextMenu invariant as Board and Task Detail surfaces. -->
|
||||||
|
|||||||
@@ -961,8 +961,11 @@ In the split sidebar the title cell must allow the title to wrap to two lines (h
|
|||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === List View Mobile Responsive === */
|
/* === List View Single-Pane Responsive ===
|
||||||
@media (max-width: 768px) {
|
FNXC:ListView 2026-07-10-00:00 (FN-7809):
|
||||||
|
Tablet-width List view shares the mobile single-pane scaffolding so the full-width toolbar and QuickEntryBox are not clipped by the desktop split sidebar. Mobile behavior stays unchanged; desktop split rules remain active above the tablet tier.
|
||||||
|
*/
|
||||||
|
@media (max-width: 1024px) {
|
||||||
.list-toolbar {
|
.list-toolbar {
|
||||||
padding: var(--space-md);
|
padding: var(--space-md);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -1025,8 +1028,8 @@ In the split sidebar the title cell must allow the title to wrap to two lines (h
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === List View Mobile Cards (FN-1140) === */
|
/* === List View Single-Pane Cards (FN-1140, FN-7809) === */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 1024px) {
|
||||||
/* Hide the table on mobile — card layout is rendered via JS */
|
/* Hide the table on mobile — card layout is rendered via JS */
|
||||||
.list-table {
|
.list-table {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
@@ -386,6 +386,11 @@ export function ListView({
|
|||||||
});
|
});
|
||||||
const viewportMode = useViewportMode();
|
const viewportMode = useViewportMode();
|
||||||
const isMobile = viewportMode === "mobile";
|
const isMobile = viewportMode === "mobile";
|
||||||
|
/*
|
||||||
|
FNXC:ListView 2026-07-10-00:00 (FN-7809):
|
||||||
|
Tablet-width List view must use the same single-pane layout as mobile because the desktop two-pane sidebar leaves too little horizontal room and clips the primary actions plus expanded QuickEntryBox. Keep touch-only long-press behavior on `isMobile`; this gate only controls split-vs-single-pane structure and detail routing.
|
||||||
|
*/
|
||||||
|
const useSinglePaneList = viewportMode === "mobile" || viewportMode === "tablet";
|
||||||
const { confirm, confirmWithChoice } = useConfirm();
|
const { confirm, confirmWithChoice } = useConfirm();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -507,7 +512,7 @@ export function ListView({
|
|||||||
}, [selectedTaskId, tasks]);
|
}, [selectedTaskId, tasks]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isMobile || typeof ResizeObserver === "undefined") return;
|
if (useSinglePaneList || typeof ResizeObserver === "undefined") return;
|
||||||
const container = splitLayoutRef.current;
|
const container = splitLayoutRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
@@ -531,10 +536,10 @@ export function ListView({
|
|||||||
const observer = new ResizeObserver(applyClamp);
|
const observer = new ResizeObserver(applyClamp);
|
||||||
observer.observe(container);
|
observer.observe(container);
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [isMobile, sidebarWidth]);
|
}, [sidebarWidth, useSinglePaneList]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isMobile || typeof ResizeObserver === "undefined") return;
|
if (useSinglePaneList || typeof ResizeObserver === "undefined") return;
|
||||||
const sidebar = splitSidebarRef.current;
|
const sidebar = splitSidebarRef.current;
|
||||||
const container = splitLayoutRef.current;
|
const container = splitLayoutRef.current;
|
||||||
if (!sidebar || !container) return;
|
if (!sidebar || !container) return;
|
||||||
@@ -561,7 +566,7 @@ export function ListView({
|
|||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
if (saveTimer) clearTimeout(saveTimer);
|
if (saveTimer) clearTimeout(saveTimer);
|
||||||
};
|
};
|
||||||
}, [isMobile, projectId]);
|
}, [projectId, useSinglePaneList]);
|
||||||
|
|
||||||
const toggleBulkEdit = useCallback(() => {
|
const toggleBulkEdit = useCallback(() => {
|
||||||
setBulkEditEnabled((prev) => {
|
setBulkEditEnabled((prev) => {
|
||||||
@@ -1701,7 +1706,7 @@ export function ListView({
|
|||||||
addToast(getErrorMessage(err), "error");
|
addToast(getErrorMessage(err), "error");
|
||||||
}
|
}
|
||||||
} : undefined,
|
} : undefined,
|
||||||
onOpenRefine: () => onOpenDetail(task, { origin: isMobile ? "list-mobile" : undefined, initialAction: "refine" }),
|
onOpenRefine: () => onOpenDetail(task, { origin: useSinglePaneList ? "list-mobile" : undefined, initialAction: "refine" }),
|
||||||
onRespecify: async () => {
|
onRespecify: async () => {
|
||||||
const shouldRebuild = await confirm({
|
const shouldRebuild = await confirm({
|
||||||
title: t("taskDetail.plan.rebuildTitle", "Rebuild Plan"),
|
title: t("taskDetail.plan.rebuildTitle", "Rebuild Plan"),
|
||||||
@@ -1791,7 +1796,7 @@ export function ListView({
|
|||||||
actions.push({ id: model.reviewAction.id, label: model.reviewAction.label, disabled: model.reviewAction.disabled, onSelect: model.reviewAction.onSelect });
|
actions.push({ id: model.reviewAction.id, label: model.reviewAction.label, disabled: model.reviewAction.disabled, onSelect: model.reviewAction.onSelect });
|
||||||
}
|
}
|
||||||
return actions.filter((action) => action.tone === "note" || action.disabled === true || Boolean(action.onSelect));
|
return actions.filter((action) => action.tone === "note" || action.disabled === true || Boolean(action.onSelect));
|
||||||
}, [addToast, autoMerge, columnFlagsById, confirm, getListColumnLabel, handleListContextCheckPrStatus, handleListContextEnableGithubTracking, handleListContextMove, handleListTaskArchive, handleListTaskDelete, handleListTaskRevert, isMobile, listContextMenuColumns, mergeStrategy, onDuplicateTask, onMergeTask, onOpenDetail, onPauseTask, onResetTask, onRetryTask, onUnpauseTask, onArchiveTask, onRevertTask, onTasksUpdated, projectId, t]);
|
}, [addToast, autoMerge, columnFlagsById, confirm, getListColumnLabel, handleListContextCheckPrStatus, handleListContextEnableGithubTracking, handleListContextMove, handleListTaskArchive, handleListTaskDelete, handleListTaskRevert, isMobile, listContextMenuColumns, mergeStrategy, onDuplicateTask, onMergeTask, onOpenDetail, onPauseTask, onResetTask, onRetryTask, onUnpauseTask, onArchiveTask, onRevertTask, onTasksUpdated, projectId, t, useSinglePaneList]);
|
||||||
|
|
||||||
const contextMenuActions = useMemo(
|
const contextMenuActions = useMemo(
|
||||||
() => (contextMenuState ? buildListContextMenuActions(contextMenuState.task) : []),
|
() => (contextMenuState ? buildListContextMenuActions(contextMenuState.task) : []),
|
||||||
@@ -1913,7 +1918,7 @@ export function ListView({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
closeContextMenu();
|
closeContextMenu();
|
||||||
if (isMobile) {
|
if (useSinglePaneList) {
|
||||||
onOpenDetail(task, { origin: "list-mobile" });
|
onOpenDetail(task, { origin: "list-mobile" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1921,7 +1926,7 @@ export function ListView({
|
|||||||
setSelectedTaskId(task.id);
|
setSelectedTaskId(task.id);
|
||||||
setSelectedTaskSnapshot(task);
|
setSelectedTaskSnapshot(task);
|
||||||
},
|
},
|
||||||
[closeContextMenu, isMobile, onOpenDetail]
|
[closeContextMenu, onOpenDetail, useSinglePaneList]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Debounce detail fetches so rapid keyboard/mouse navigation through a
|
// Debounce detail fetches so rapid keyboard/mouse navigation through a
|
||||||
@@ -2020,7 +2025,7 @@ export function ListView({
|
|||||||
style={{ width }} — which wins over the grid `auto` track — updates live and persists.
|
style={{ width }} — which wins over the grid `auto` track — updates live and persists.
|
||||||
*/
|
*/
|
||||||
const handleSplitResizeStart = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
const handleSplitResizeStart = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
if (isMobile) return;
|
if (useSinglePaneList) return;
|
||||||
const container = splitLayoutRef.current;
|
const container = splitLayoutRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -2061,13 +2066,13 @@ export function ListView({
|
|||||||
window.addEventListener("pointermove", onPointerMove);
|
window.addEventListener("pointermove", onPointerMove);
|
||||||
window.addEventListener("pointerup", teardown);
|
window.addEventListener("pointerup", teardown);
|
||||||
window.addEventListener("pointercancel", teardown);
|
window.addEventListener("pointercancel", teardown);
|
||||||
}, [isMobile]);
|
}, [useSinglePaneList]);
|
||||||
|
|
||||||
// FNXC:ListView 2026-06-22-18:00: Tear down any in-flight resize drag on unmount so window pointer listeners never leak.
|
// FNXC:ListView 2026-06-22-18:00: Tear down any in-flight resize drag on unmount so window pointer listeners never leak.
|
||||||
useEffect(() => () => splitResizeTeardownRef.current?.(), []);
|
useEffect(() => () => splitResizeTeardownRef.current?.(), []);
|
||||||
|
|
||||||
const handleSplitResizeKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
|
const handleSplitResizeKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
if (isMobile) return;
|
if (useSinglePaneList) return;
|
||||||
const measuredWidth = splitLayoutRef.current?.clientWidth ?? 0;
|
const measuredWidth = splitLayoutRef.current?.clientWidth ?? 0;
|
||||||
const fallbackWidth = sidebarWidth / LIST_SIDEBAR_MAX_RATIO + LIST_SIDEBAR_KEYBOARD_STEP;
|
const fallbackWidth = sidebarWidth / LIST_SIDEBAR_MAX_RATIO + LIST_SIDEBAR_KEYBOARD_STEP;
|
||||||
const containerWidth = Math.max(measuredWidth, fallbackWidth);
|
const containerWidth = Math.max(measuredWidth, fallbackWidth);
|
||||||
@@ -2091,7 +2096,7 @@ export function ListView({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setSidebarWidth(maxWidth);
|
setSidebarWidth(maxWidth);
|
||||||
}
|
}
|
||||||
}, [isMobile, sidebarWidth]);
|
}, [sidebarWidth, useSinglePaneList]);
|
||||||
|
|
||||||
const handleColumnDragOver = useCallback(
|
const handleColumnDragOver = useCallback(
|
||||||
(e: React.DragEvent, column: ColumnId) => {
|
(e: React.DragEvent, column: ColumnId) => {
|
||||||
@@ -2305,7 +2310,7 @@ export function ListView({
|
|||||||
className="btn btn-sm list-view-options-toggle"
|
className="btn btn-sm list-view-options-toggle"
|
||||||
onClick={() => setViewOptionsOpen((prev) => !prev)}
|
onClick={() => setViewOptionsOpen((prev) => !prev)}
|
||||||
aria-expanded={viewOptionsOpen}
|
aria-expanded={viewOptionsOpen}
|
||||||
aria-controls={isMobile ? "list-view-options-panel-mobile" : "list-view-options-panel"}
|
aria-controls={useSinglePaneList ? "list-view-options-panel-mobile" : "list-view-options-panel"}
|
||||||
>
|
>
|
||||||
<Columns3 size={14} />
|
<Columns3 size={14} />
|
||||||
{t("listView.viewOptions", "View")}
|
{t("listView.viewOptions", "View")}
|
||||||
@@ -2408,7 +2413,7 @@ export function ListView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="list-view">
|
<div className={`list-view${useSinglePaneList ? " list-view--single-pane" : ""}`}>
|
||||||
{contextMenuState && hasContextMenuActions && createPortal(
|
{contextMenuState && hasContextMenuActions && createPortal(
|
||||||
<div
|
<div
|
||||||
ref={contextMenuRef}
|
ref={contextMenuRef}
|
||||||
@@ -2435,7 +2440,7 @@ export function ListView({
|
|||||||
addToast={addToast}
|
addToast={addToast}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isMobile && (
|
{useSinglePaneList && (
|
||||||
<>
|
<>
|
||||||
<div className="list-toolbar">
|
<div className="list-toolbar">
|
||||||
{renderWorkflowSelector()}
|
{renderWorkflowSelector()}
|
||||||
@@ -2460,14 +2465,14 @@ export function ListView({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="list-table-container">
|
<div className="list-table-container">
|
||||||
<div className={isMobile ? "" : "list-split-layout"} data-testid={isMobile ? undefined : "list-split-layout"} ref={splitLayoutRef}>
|
<div className={useSinglePaneList ? "" : "list-split-layout"} data-testid={useSinglePaneList ? undefined : "list-split-layout"} ref={splitLayoutRef}>
|
||||||
<div
|
<div
|
||||||
className={isMobile ? "" : "list-split-sidebar"}
|
className={useSinglePaneList ? "" : "list-split-sidebar"}
|
||||||
data-testid={isMobile ? undefined : "list-split-sidebar"}
|
data-testid={useSinglePaneList ? undefined : "list-split-sidebar"}
|
||||||
ref={splitSidebarRef}
|
ref={splitSidebarRef}
|
||||||
style={isMobile ? undefined : { width: `${sidebarWidth}px` }}
|
style={useSinglePaneList ? undefined : { width: `${sidebarWidth}px` }}
|
||||||
>
|
>
|
||||||
{!isMobile && (
|
{!useSinglePaneList && (
|
||||||
<aside className="list-sidebar-controls" aria-label={t("listView.listControlsLabel", "List controls")}>
|
<aside className="list-sidebar-controls" aria-label={t("listView.listControlsLabel", "List controls")}>
|
||||||
{/*
|
{/*
|
||||||
FNXC:ListView 2026-06-23-23:42:
|
FNXC:ListView 2026-06-23-23:42:
|
||||||
@@ -2536,7 +2541,7 @@ export function ListView({
|
|||||||
<div className="list-empty">
|
<div className="list-empty">
|
||||||
{searchQuery ? t("listView.noTasksMatch", "No tasks match your filter") : t("listView.noTasksYet", "No tasks yet")}
|
{searchQuery ? t("listView.noTasksMatch", "No tasks match your filter") : t("listView.noTasksYet", "No tasks yet")}
|
||||||
</div>
|
</div>
|
||||||
) : isMobile ? (
|
) : useSinglePaneList ? (
|
||||||
<div className="list-cards">
|
<div className="list-cards">
|
||||||
{listColumns.map((columnDef) => {
|
{listColumns.map((columnDef) => {
|
||||||
const column = columnDef.id;
|
const column = columnDef.id;
|
||||||
@@ -2929,7 +2934,7 @@ export function ListView({
|
|||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!isMobile && (
|
{!useSinglePaneList && (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className="list-split-resize-handle"
|
className="list-split-resize-handle"
|
||||||
|
|||||||
@@ -365,6 +365,21 @@ function mockMobileViewport() {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mockTabletViewport() {
|
||||||
|
ensureMatchMedia();
|
||||||
|
Object.defineProperty(window, "innerWidth", { value: 900, configurable: true });
|
||||||
|
return vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
|
||||||
|
matches: query === "(min-width: 769px) and (max-width: 1024px)",
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
function mockDesktopViewport() {
|
function mockDesktopViewport() {
|
||||||
ensureMatchMedia();
|
ensureMatchMedia();
|
||||||
Object.defineProperty(window, "innerWidth", { value: 1280, configurable: true });
|
Object.defineProperty(window, "innerWidth", { value: 1280, configurable: true });
|
||||||
@@ -1705,6 +1720,40 @@ describe("ListView", () => {
|
|||||||
viewportSpy.mockRestore();
|
viewportSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders tablet List view as a single full-width pane without split chrome", () => {
|
||||||
|
const viewportSpy = mockTabletViewport();
|
||||||
|
const tasks = [createMockTask({ id: "FN-001", title: "Tablet task" })];
|
||||||
|
|
||||||
|
const { container } = renderListView({ tasks });
|
||||||
|
|
||||||
|
expect(container.querySelector(".list-view--single-pane")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("list-split-layout")).toBeNull();
|
||||||
|
expect(screen.queryByTestId("list-split-sidebar")).toBeNull();
|
||||||
|
expect(screen.queryByTestId("list-split-resize-handle")).toBeNull();
|
||||||
|
expect(screen.queryByTestId("list-split-detail")).toBeNull();
|
||||||
|
expect(container.querySelector(".list-toolbar .list-action-cluster")).toBeInTheDocument();
|
||||||
|
expect(within(container.querySelector(".list-toolbar .list-action-cluster") as HTMLElement).getByText("+ New Task")).toBeInTheDocument();
|
||||||
|
expect(container.querySelector(".list-quick-entry-above-table .quick-entry-box")).toBeInTheDocument();
|
||||||
|
expect(container.querySelector(".list-cards")).toBeInTheDocument();
|
||||||
|
expect(container.querySelector("table.list-table")).toBeNull();
|
||||||
|
viewportSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens tablet task detail through the single-pane detail route", () => {
|
||||||
|
const viewportSpy = mockTabletViewport();
|
||||||
|
const task = createMockTask({ id: "FN-001", title: "Tablet open" });
|
||||||
|
const onOpenDetail = vi.fn();
|
||||||
|
|
||||||
|
const { container } = renderListView({ tasks: [task], onOpenDetail });
|
||||||
|
|
||||||
|
fireEvent.click(container.querySelector('.list-card[data-id="FN-001"]') as HTMLElement);
|
||||||
|
|
||||||
|
expect(onOpenDetail).toHaveBeenCalledWith(task, { origin: "list-mobile" });
|
||||||
|
expect(onOpenDetail).toHaveBeenCalledTimes(1);
|
||||||
|
expect(screen.queryByTestId("list-split-detail-content")).toBeNull();
|
||||||
|
viewportSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
it("sorts tasks by ID when ID header is clicked", () => {
|
it("sorts tasks by ID when ID header is clicked", () => {
|
||||||
const tasks = [
|
const tasks = [
|
||||||
createMockTask({ id: "FN-003", title: "Third", column: "triage" }),
|
createMockTask({ id: "FN-003", title: "Third", column: "triage" }),
|
||||||
@@ -2100,7 +2149,7 @@ describe("ListView", () => {
|
|||||||
const css = readFileSync("app/components/ListView.css", "utf8");
|
const css = readFileSync("app/components/ListView.css", "utf8");
|
||||||
const actionClusterRule = css.match(/\.list-action-cluster,\s*\n\.list-sidebar-controls__actions\s*\{[^}]*\}/)?.[0] ?? "";
|
const actionClusterRule = css.match(/\.list-action-cluster,\s*\n\.list-sidebar-controls__actions\s*\{[^}]*\}/)?.[0] ?? "";
|
||||||
const toolbarRule = css.match(/\.list-sidebar-controls__toolbar\s*\{[^}]*\}/)?.[0] ?? "";
|
const toolbarRule = css.match(/\.list-sidebar-controls__toolbar\s*\{[^}]*\}/)?.[0] ?? "";
|
||||||
const mobileToolbarRule = css.match(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.list-toolbar\s*\{[^}]*padding:\s*var\(--space-sm\) var\(--space-md\);[^}]*\}/)?.[0] ?? "";
|
const singlePaneToolbarRule = css.match(/@media\s*\(max-width:\s*1024px\)[\s\S]*?\.list-toolbar\s*\{[^}]*padding:\s*var\(--space-sm\) var\(--space-md\);[^}]*\}/)?.[0] ?? "";
|
||||||
|
|
||||||
expect(actionClusterRule).toContain("flex-wrap: nowrap");
|
expect(actionClusterRule).toContain("flex-wrap: nowrap");
|
||||||
expect(actionClusterRule).toContain("justify-content: center");
|
expect(actionClusterRule).toContain("justify-content: center");
|
||||||
@@ -2108,7 +2157,23 @@ describe("ListView", () => {
|
|||||||
expect(actionClusterRule).toContain("min-width: max-content");
|
expect(actionClusterRule).toContain("min-width: max-content");
|
||||||
expect(actionClusterRule).toContain("overflow-x: auto");
|
expect(actionClusterRule).toContain("overflow-x: auto");
|
||||||
expect(toolbarRule).toContain("justify-content: center");
|
expect(toolbarRule).toContain("justify-content: center");
|
||||||
expect(mobileToolbarRule).toContain("justify-content: center");
|
expect(singlePaneToolbarRule).toContain("justify-content: center");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads CSS fixture rules that apply single-pane non-clipping layout at tablet while keeping desktop split rules", () => {
|
||||||
|
const css = loadAllAppCss();
|
||||||
|
const splitHideRule = css.match(/@media\s*\(max-width:\s*1024px\)[\s\S]*?\.list-split-resize-handle,\s*\n\s*\.list-split-detail\s*\{[^}]*display:\s*none;[^}]*\}/)?.[0] ?? "";
|
||||||
|
const cardRule = css.match(/@media\s*\(max-width:\s*1024px\)[\s\S]*?\.list-table\s*\{[^}]*display:\s*none;[^}]*\}[\s\S]*?\.list-cards\s*\{[^}]*width:\s*100%;[^}]*\}/)?.[0] ?? "";
|
||||||
|
const desktopSplitRule = css.match(/\.list-split-layout\s*\{[^}]*grid-template-columns:\s*auto 0 minmax\(0, 1fr\);[^}]*\}/)?.[0] ?? "";
|
||||||
|
|
||||||
|
expect(splitHideRule).toContain("max-width: 1024px");
|
||||||
|
expect(splitHideRule).toContain(".list-split-resize-handle");
|
||||||
|
expect(splitHideRule).toContain("display: none");
|
||||||
|
expect(cardRule).toContain(".list-table");
|
||||||
|
expect(cardRule).toContain("display: none");
|
||||||
|
expect(cardRule).toContain(".list-cards");
|
||||||
|
expect(cardRule).toContain("width: 100%");
|
||||||
|
expect(desktopSplitRule).toContain("grid-template-columns: auto 0 minmax(0, 1fr)");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps Bulk Edit, View, and + New Task together in the mobile toolbar controls", () => {
|
it("keeps Bulk Edit, View, and + New Task together in the mobile toolbar controls", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user