FN-6378: contain mobile board overscroll
Contain horizontal overscroll on mobile kanban board scrollers so iOS edge drags do not rubber-band columns off screen. - Add horizontal overscroll containment to board, workflow column, and lane column scrollers while preserving proximity snap scrolling. - Add CSS fixture regression coverage for mobile/base board and lane overscroll containment. - Document the iOS horizontal overscroll containment root cause and fix. Files changed: ...-board-ios-horizontal-overscroll-containment.md | 64 +++++++++++++++++++++ .../board-mobile-overscroll-containment.test.ts | 65 ++++++++++++++++++++++ packages/dashboard/app/components/Lane.css | 2 + packages/dashboard/app/styles.css | 1 + 4 files changed, 132 insertions(+) Fusion-Task-Id: FN-6378 Fusion-Task-Lineage: 70b4852b-feb0-42b5-81ba-13bfd143e0bc
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
---
|
||||
title: "Mobile board iOS horizontal overscroll containment"
|
||||
date: 2026-06-13
|
||||
category: ui-bugs
|
||||
module: packages/dashboard/app/styles.css
|
||||
problem_type: ui_bug
|
||||
component: frontend_css
|
||||
symptoms:
|
||||
- "On iOS Safari/PWA, dragging the kanban board past the first or last column rubber-bands the column strip off screen"
|
||||
- "Horizontal edge overscroll can expose empty space and chain to the document even though the board's inner column scroll is intentional"
|
||||
root_cause: css_scroll_containment_gap
|
||||
resolution_type: code_fix
|
||||
severity: medium
|
||||
related_components:
|
||||
- packages/dashboard/app/components/Lane.css
|
||||
- packages/dashboard/app/__tests__/board-mobile-overscroll-containment.test.ts
|
||||
tags:
|
||||
- ios-safari
|
||||
- mobile-board
|
||||
- overscroll-behavior
|
||||
- scroll-snap
|
||||
- css-regression-test
|
||||
- kanban
|
||||
applies_when:
|
||||
- "A horizontally scrollable board or lane strip uses `overflow-x: auto` with mobile momentum scrolling"
|
||||
- "Edge dragging should keep native inner scrolling but must not chain or park content off screen"
|
||||
---
|
||||
|
||||
# Mobile board iOS horizontal overscroll containment
|
||||
|
||||
## Problem
|
||||
|
||||
The mobile kanban board intentionally scrolls horizontally between columns using `overflow-x: auto`, `-webkit-overflow-scrolling: touch`, and `scroll-snap-type: x proximity`. On iOS Safari/PWA, that same momentum scroller can rubber-band past its first or last column if the scroller does not contain horizontal overscroll. The visible result is that the columns slide away from the viewport edge, exposing empty space and sometimes chaining the drag to the document.
|
||||
|
||||
## Root cause
|
||||
|
||||
The board had page-level mobile overscroll protection on `html, body`, but the board itself is the horizontal scroll container. The base `.board` and the mobile `@media (max-width: 768px) .board` rules declared the intended scroll and snap properties without `overscroll-behavior-x`, so iOS edge overscroll was not contained at the board boundary. Workflow and multi-lane board variants in `Lane.css` had the same independent horizontal scrollers.
|
||||
|
||||
## Solution
|
||||
|
||||
Add axis-specific containment to each horizontal board strip:
|
||||
|
||||
```css
|
||||
.board,
|
||||
.board.board-workflow-columns,
|
||||
.lane-columns {
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-x: contain;
|
||||
scroll-snap-type: x proximity;
|
||||
}
|
||||
```
|
||||
|
||||
Keep `contain` rather than `none`: the board can retain its native inner scroll feel while edge overscroll stops at the board/lane container instead of chaining outward. Do not replace this with `overflow: hidden`/`clip`, and do not switch snap back to `x mandatory`; both would regress intentional mobile column navigation.
|
||||
|
||||
## Regression coverage
|
||||
|
||||
Use a CSS-fixture test that loads the combined dashboard CSS and asserts:
|
||||
|
||||
- the mobile `.board` rule still has `overflow-x: auto` and `scroll-snap-type: x proximity`;
|
||||
- the mobile `.board` rule declares `overscroll-behavior-x: contain`;
|
||||
- the base `.board`, `.board.board-workflow-columns`, and `.lane-columns` horizontal scrollers also declare containment;
|
||||
- no checked board path uses `scroll-snap-type: x mandatory`.
|
||||
|
||||
For FN-6378 this lives in `packages/dashboard/app/__tests__/board-mobile-overscroll-containment.test.ts`.
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { loadAllAppCss, loadAllAppCssBaseOnly } from "../test/cssFixture";
|
||||
|
||||
/** Extract all content inside @media (max-width: 768px) blocks. */
|
||||
function extractMobileMediaBlocks(content: string): string {
|
||||
const blocks: string[] = [];
|
||||
const regex = /@media[^{]*\(max-width: 768px\)[^{]*\{/g;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
const startIdx = match.index + match[0].length;
|
||||
let braceCount = 1;
|
||||
let endIdx = startIdx;
|
||||
while (braceCount > 0 && endIdx < content.length) {
|
||||
if (content[endIdx] === "{") braceCount++;
|
||||
if (content[endIdx] === "}") braceCount--;
|
||||
endIdx++;
|
||||
}
|
||||
if (braceCount === 0) {
|
||||
blocks.push(content.slice(startIdx, endIdx - 1));
|
||||
}
|
||||
}
|
||||
return blocks.join("\n");
|
||||
}
|
||||
|
||||
function extractRuleBlock(content: string, selector: string): string {
|
||||
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return content.match(new RegExp(`${escapedSelector}\\s*\\{[^}]*\\}`))?.[0] ?? "";
|
||||
}
|
||||
|
||||
describe("board-mobile-overscroll-containment (FN-6378)", () => {
|
||||
const cssContent = loadAllAppCss();
|
||||
const baseCss = loadAllAppCssBaseOnly();
|
||||
const mobileCss = extractMobileMediaBlocks(cssContent);
|
||||
|
||||
it("mobile .board contains horizontal overscroll while preserving intentional scroll and proximity snap", () => {
|
||||
const boardBlock = extractRuleBlock(mobileCss, ".board");
|
||||
|
||||
expect(boardBlock).toContain("overflow-x: auto");
|
||||
expect(boardBlock).toContain("overscroll-behavior-x: contain");
|
||||
expect(boardBlock).toContain("scroll-snap-type: x proximity");
|
||||
expect(boardBlock).not.toContain("scroll-snap-type: x mandatory");
|
||||
});
|
||||
|
||||
it("base .board contains horizontal overscroll for shared and tablet board scrollers", () => {
|
||||
const boardBlock = extractRuleBlock(baseCss, ".board");
|
||||
|
||||
expect(boardBlock).toContain("overflow-x: auto");
|
||||
expect(boardBlock).toContain("overscroll-behavior-x: contain");
|
||||
expect(boardBlock).toContain("scroll-snap-type: x proximity");
|
||||
expect(boardBlock).not.toContain("scroll-snap-type: x mandatory");
|
||||
});
|
||||
|
||||
it("workflow columns and multi-lane column strips contain horizontal overscroll", () => {
|
||||
const workflowColumnsBlock = extractRuleBlock(baseCss, ".board.board-workflow-columns");
|
||||
const laneColumnsBlock = extractRuleBlock(baseCss, ".lane-columns");
|
||||
|
||||
for (const block of [workflowColumnsBlock, laneColumnsBlock]) {
|
||||
expect(block).toContain("overflow-x: auto");
|
||||
expect(block).toContain("overscroll-behavior-x: contain");
|
||||
expect(block).toContain("scroll-snap-type: x proximity");
|
||||
expect(block).not.toContain("scroll-snap-type: x mandatory");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -58,6 +58,7 @@
|
||||
min-height: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
overscroll-behavior-x: contain;
|
||||
scroll-snap-type: x proximity;
|
||||
}
|
||||
|
||||
@@ -123,6 +124,7 @@
|
||||
padding: 12px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
overscroll-behavior-x: contain;
|
||||
scroll-snap-type: x proximity;
|
||||
scrollbar-color: var(--border) transparent;
|
||||
scrollbar-width: thin;
|
||||
|
||||
@@ -996,6 +996,7 @@ body {
|
||||
padding: var(--board-padding);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
overscroll-behavior-x: contain;
|
||||
scroll-snap-type: x proximity;
|
||||
scroll-padding-inline: 50%;
|
||||
scrollbar-color: var(--border) transparent;
|
||||
|
||||
Reference in New Issue
Block a user