FN-7012: restore mobile task tab scrolling
Restores mobile access to all task detail tabs by making the tab strip the horizontal scroller. - Allow the task-detail content, body, modal tabs, tablet tabs, and embedded tabs to shrink within narrow containers. - Preserve touch horizontal panning and momentum scrolling on tab strips without moving horizontal overflow to the detail body. - Cover the Board modal, List embedded pane, and complete tab label set with responsive CSS regression tests. - Add a patch changeset for the published Fusion package. Files changed: .../fn-7012-task-detail-mobile-tabs-scroll.md | 7 +++ .../dashboard/app/components/TaskDetailModal.css | 25 +++++++- ...etailModal.responsive-and-dependencies.test.tsx | 67 +++++++++++++++++++++- 3 files changed, 96 insertions(+), 3 deletions(-) Fusion-Task-Id: FN-7012 Fusion-Task-Lineage: 06b06a81-b9ec-4058-879f-2f5e187a63a4
This commit is contained in:
7
.changeset/fn-7012-task-detail-mobile-tabs-scroll.md
Normal file
7
.changeset/fn-7012-task-detail-mobile-tabs-scroll.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Restore horizontal scrolling for mobile task detail tabs.
|
||||||
|
category: fix
|
||||||
|
dev: Keeps the task-detail tab strip scrollable across Board modal and List embedded surfaces.
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +70,7 @@ The gray top header band (task id + column badge) was over-padded. Trim its vert
|
|||||||
|
|
||||||
.detail-body {
|
.detail-body {
|
||||||
padding: calc(var(--space-lg) + var(--space-xs));
|
padding: calc(var(--space-lg) + var(--space-xs));
|
||||||
|
min-width: 0;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
scrollbar-color: var(--border) transparent;
|
scrollbar-color: var(--border) transparent;
|
||||||
@@ -1012,6 +1014,17 @@ FN-6500 fixes a tablet regression from FN-5599: the task-detail overlay offset a
|
|||||||
height: 92vh;
|
height: 92vh;
|
||||||
max-height: calc(100dvh - var(--overlay-padding-top, 6vh) - var(--space-md));
|
max-height: calc(100dvh - var(--overlay-padding-top, 6vh) - var(--space-md));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-tabs {
|
||||||
|
width: 100%;
|
||||||
|
max-inline-size: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
overscroll-behavior-inline: contain;
|
||||||
|
touch-action: pan-x pan-y;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
@@ -1112,6 +1125,14 @@ Embedded in the full-width board-card panel the detail content must be width-bou
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.task-detail-content--embedded .detail-tabs {
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
overscroll-behavior-inline: contain;
|
||||||
|
touch-action: pan-x pan-y;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
/* The gray header row must wrap (task id left, Back-to-board right) instead of overflowing on narrow panels. */
|
/* The gray header row must wrap (task id left, Back-to-board right) instead of overflowing on narrow panels. */
|
||||||
.task-detail-content--embedded .modal-header {
|
.task-detail-content--embedded .modal-header {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -1917,8 +1938,8 @@ The overflowing task-detail tab strip must keep horizontal touch panning enabled
|
|||||||
FNXC:TaskDetailTabs 2026-06-22-18:00:
|
FNXC:TaskDetailTabs 2026-06-22-18:00:
|
||||||
Remove the divider between the tab selector and the detail area below. Keep the active-tab underline as the only local selection affordance.
|
Remove the divider between the tab selector and the detail area below. Keep the active-tab underline as the only local selection affordance.
|
||||||
|
|
||||||
FNXC:TaskDetailTabs 2026-06-23-20:25:
|
FNXC:TaskDetailTabs 2026-06-25-16:55:
|
||||||
Narrow mobile task detail surfaces from both Board and List must allow horizontal tab scrolling. The tab strip is the horizontal scroller, not the detail body; bound it to the available inline size and preserve pan-x so parent overflow clipping does not trap hidden tabs.
|
Task-detail tabs must remain reachable as a one-row horizontal scroller on mobile across the Board modal and List embedded pane. The tab strip is the scroller, opts out of the global mobile pan-y lock with pan-x pan-y, and keeps tab children non-shrinking so conditional and plugin tabs overflow-scroll instead of clipping or compressing.
|
||||||
*/
|
*/
|
||||||
.detail-tabs {
|
.detail-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -57,6 +57,33 @@ function getCssAtRuleBlockContaining(css: string, atRule: string, selector: stri
|
|||||||
throw new Error(`Missing ${atRule} block containing ${selector}`);
|
throw new Error(`Missing ${atRule} block containing ${selector}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getExactCssRuleBlock(css: string, selector: string): string {
|
||||||
|
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
const ruleMatch = css.match(new RegExp(`(?:^|[}\\n])\\s*${escapedSelector}\\s*\\{([^}]*)\\}`));
|
||||||
|
return ruleMatch?.[1] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCssAtRuleBlockContainingExactRule(css: string, atRule: string, selector: string): string {
|
||||||
|
let startAt = 0;
|
||||||
|
while (startAt < css.length) {
|
||||||
|
const { block, endIndex } = getCssAtRuleBlock(css, atRule, startAt);
|
||||||
|
if (getExactCssRuleBlock(block, selector)) {
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
startAt = endIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Missing ${atRule} block containing exact ${selector}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectHorizontalTabScroller(ruleBlock: string, surface: string): void {
|
||||||
|
expect(ruleBlock, `${surface} overflow-x`).toContain("overflow-x: auto;");
|
||||||
|
expect(ruleBlock, `${surface} overflow-y`).toContain("overflow-y: hidden;");
|
||||||
|
expect(ruleBlock, `${surface} overscroll`).toContain("overscroll-behavior-inline: contain;");
|
||||||
|
expect(ruleBlock, `${surface} touch-action`).toContain("touch-action: pan-x pan-y;");
|
||||||
|
expect(ruleBlock, `${surface} momentum-scroll`).toContain("-webkit-overflow-scrolling: touch;");
|
||||||
|
}
|
||||||
|
|
||||||
describe("TaskDetailModal", () => {
|
describe("TaskDetailModal", () => {
|
||||||
describe("mobile responsive structure", () => {
|
describe("mobile responsive structure", () => {
|
||||||
it("keeps detail metadata as a single wrapping flex row without mobile column fallbacks", () => {
|
it("keeps detail metadata as a single wrapping flex row without mobile column fallbacks", () => {
|
||||||
@@ -120,6 +147,32 @@ describe("TaskDetailModal", () => {
|
|||||||
expect(tabletModalBlock).not.toContain("16px");
|
expect(tabletModalBlock).not.toContain("16px");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps task-detail tabs as horizontal scrollers across modal, embedded, mobile, and tablet surfaces", () => {
|
||||||
|
const css = readDashboardStylesSource();
|
||||||
|
const baseTabsBlock = getExactCssRuleBlock(css, ".detail-tabs");
|
||||||
|
const mobileBlock = getCssAtRuleBlockContainingExactRule(css, "@media (max-width: 768px)", ".detail-tabs");
|
||||||
|
const mobileTabsBlock = getExactCssRuleBlock(mobileBlock, ".detail-tabs");
|
||||||
|
const tabletBlock = getCssAtRuleBlockContainingExactRule(css, "@media (min-width: 769px) and (max-width: 1024px)", ".detail-tabs");
|
||||||
|
const tabletTabsBlock = getExactCssRuleBlock(tabletBlock, ".detail-tabs");
|
||||||
|
const embeddedTabsBlock = getExactCssRuleBlock(css, ".task-detail-content--embedded .detail-tabs");
|
||||||
|
const detailContentBlock = getCssRuleBlock(css, ".task-detail-content");
|
||||||
|
const detailBodyBlock = getCssRuleBlock(css, ".detail-body");
|
||||||
|
const detailTabBlock = getCssRuleBlock(css, ".detail-tab");
|
||||||
|
|
||||||
|
expectHorizontalTabScroller(baseTabsBlock, "base .detail-tabs");
|
||||||
|
expectHorizontalTabScroller(mobileTabsBlock, "mobile .detail-tabs");
|
||||||
|
expectHorizontalTabScroller(tabletTabsBlock, "tablet .detail-tabs");
|
||||||
|
expectHorizontalTabScroller(embeddedTabsBlock, "embedded .detail-tabs");
|
||||||
|
expect(baseTabsBlock).toContain("min-width: 0;");
|
||||||
|
expect(mobileTabsBlock).toContain("min-width: 0;");
|
||||||
|
expect(detailTabBlock).toContain("flex-shrink: 0;");
|
||||||
|
expect(detailContentBlock).toContain("min-height: 0;");
|
||||||
|
expect(detailContentBlock).toContain("min-width: 0;");
|
||||||
|
expect(detailBodyBlock).toContain("min-width: 0;");
|
||||||
|
expect(detailBodyBlock).not.toContain("overflow-x: auto;");
|
||||||
|
expect(detailBodyBlock).not.toContain("overflow: hidden;");
|
||||||
|
});
|
||||||
|
|
||||||
it("renders responsive structural classes (modal-lg, overlay, spacer, tabs, detail-body)", () => {
|
it("renders responsive structural classes (modal-lg, overlay, spacer, tabs, detail-body)", () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<TaskDetailModal
|
<TaskDetailModal
|
||||||
@@ -139,7 +192,19 @@ describe("TaskDetailModal", () => {
|
|||||||
expect(container.querySelector(".detail-timestamps")).toBeTruthy();
|
expect(container.querySelector(".detail-timestamps")).toBeTruthy();
|
||||||
expect(container.querySelectorAll(".detail-timestamp-item").length).toBe(2);
|
expect(container.querySelectorAll(".detail-timestamp-item").length).toBe(2);
|
||||||
const tabs = container.querySelectorAll(".detail-tab");
|
const tabs = container.querySelectorAll(".detail-tab");
|
||||||
expect(tabs.length).toBe(11);
|
expect(Array.from(tabs).map((tab) => tab.textContent?.trim())).toEqual([
|
||||||
|
"Chat",
|
||||||
|
"Definition",
|
||||||
|
"Logs",
|
||||||
|
"Changes",
|
||||||
|
"Review",
|
||||||
|
"Comments",
|
||||||
|
"Artifacts",
|
||||||
|
"Model",
|
||||||
|
"Workflow",
|
||||||
|
"Stats",
|
||||||
|
"Routing",
|
||||||
|
]);
|
||||||
expect(tabs[0].classList.contains("detail-tab-active")).toBe(true);
|
expect(tabs[0].classList.contains("detail-tab-active")).toBe(true);
|
||||||
expect(Array.from(tabs).slice(1).every((t) => !t.classList.contains("detail-tab-active"))).toBe(true);
|
expect(Array.from(tabs).slice(1).every((t) => !t.classList.contains("detail-tab-active"))).toBe(true);
|
||||||
// Responsive CSS controls sizing — no inline padding/fontSize/borderBottom leaks
|
// Responsive CSS controls sizing — no inline padding/fontSize/borderBottom leaks
|
||||||
|
|||||||
Reference in New Issue
Block a user