mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-08-29 09:58:58 +00:00
Red commit: bbb98cf81aae38bff1ef77a7c8a701813b25bb77 (CI run: pending — see Checks tab) Fixes #1062. Parent: #1052. ## Gesture system Adds touch-gesture handling on phones (≤768px): 1. **Swipe-left on a packets/nodes/observers row** → reveals row-action overlay (trace, filter, copy hash). Threshold: 24% of row width OR 80px. Sub-threshold = visual peek that snaps back. 2. **Horizontal swipe on the bottom-nav strip** → advances tabs in TAB order from `bottom-nav.js`. Packets ↔ Live ↔ Map etc. 3. **Swipe-down on a slide-over panel** → calls `window.SlideOver.close()`. ## Hard constraints met - **Pointer Events ONLY** — no `touchstart`/`touchend` mixing. `setPointerCapture` for tracking continuity. - **Axis-lock** — direction committed in first 8–12px movement. Vertical scroll is never blocked unless we explicitly committed to a horizontal swipe. `body { touch-action: pan-y }` so the browser owns vertical natively. - **Leaflet exclusion** — handlers early-bail on `e.target.closest('.leaflet-container')` so pinch/pan on the map tab are untouched. - **Singleton pattern** — module-scoped `__touchGestures1062InitCount` guard. Document-level pointer listeners registered exactly once even if the script loads multiple times (mirrors the #1180 fix class). - **prefers-reduced-motion** — animations have `transition-duration: 0s` under the media query; gestures still trigger, snaps are instant. ## E2E `test-gestures-1062-e2e.js` — Playwright with synthesized PointerEvents (page.touchscreen unreliable in headless for axis-locked custom handlers). Wired into the deploy.yml matrix. E2E assertion added: test-gestures-1062-e2e.js:120 (overlay-visible after left-swipe), :201 (tab advance), :219 (Leaflet exclusion), :247 (slide-over dismiss). --------- Co-authored-by: openclaw-bot <bot@openclaw> Co-authored-by: OpenClaw Bot <bot@openclaw.dev> Co-authored-by: openclaw-bot <openclaw-bot@users.noreply.github.com> Co-authored-by: clawbot <clawbot@users.noreply.github.com> Co-authored-by: corescope-bot <bot@corescope.local> Co-authored-by: openclaw-bot <bot@openclaw.local>
This commit is contained in:
co-authored by
openclaw-bot
OpenClaw Bot
openclaw-bot
clawbot
corescope-bot
openclaw-bot
parent
9b9848611b
commit
b4f186af19
@@ -230,6 +230,8 @@ jobs:
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-priority-1102-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-more-floor-1139-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-bottom-nav-1061-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-gestures-1062-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-gestures-1185-scroll-discriminator-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-channel-fluid-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-table-fluid-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-charts-fluid-1058-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
<script src="app.js?v=__BUST__"></script>
|
||||
<script src="bottom-nav.js?v=__BUST__"></script>
|
||||
<script src="nav-drawer.js?v=__BUST__"></script>
|
||||
<script src="touch-gestures.js?v=__BUST__"></script>
|
||||
<script src="url-state.js?v=__BUST__"></script>
|
||||
<script src="home.js?v=__BUST__"></script>
|
||||
<script src="table-sort.js?v=__BUST__"></script>
|
||||
|
||||
@@ -3502,3 +3502,55 @@ td[data-filter-field] { cursor: context-menu; }
|
||||
.nav-drawer-backdrop { transition: none; }
|
||||
}
|
||||
/* === end #1064 ====================================================== */
|
||||
|
||||
/* === #1062 Touch Gestures ============================================
|
||||
* Visual affordances for touch-gestures.js. CSS variables only — no
|
||||
* hardcoded colors. body owns vertical scroll natively (touch-action: pan-y);
|
||||
* the bottom-nav opts out so we manage horizontal swipes on it.
|
||||
* ==================================================================== */
|
||||
body { touch-action: pan-y; }
|
||||
[data-bottom-nav] { touch-action: none; }
|
||||
|
||||
.row-swiping { transition: transform 180ms ease-out; }
|
||||
.row-action-overlay {
|
||||
position: fixed;
|
||||
z-index: 1500;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
background: var(--card-bg, #1a1a1a);
|
||||
border: 1px solid var(--border, #333);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
transform: translateX(40px);
|
||||
transition: opacity 180ms ease-out, transform 180ms ease-out;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.row-action-overlay.row-action-overlay-open {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
.row-action-overlay[hidden] { display: none; }
|
||||
.row-action-btn {
|
||||
flex: 1 1 auto;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text, #e7e7e7);
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
min-height: 48px;
|
||||
border-right: 1px solid var(--border, #333);
|
||||
}
|
||||
.row-action-btn:last-child { border-right: none; }
|
||||
.row-action-btn:hover { background: var(--bg-hover, rgba(120, 160, 255, 0.12)); }
|
||||
.row-action-btn:active { background: var(--accent-bg, rgba(0, 122, 255, 0.18)); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.row-swiping,
|
||||
.row-action-overlay { transition: none !important; }
|
||||
}
|
||||
/* === end #1062 ====================================================== */ (feat(#1062): green — implement gesture system)
|
||||
|
||||
@@ -0,0 +1,455 @@
|
||||
/* public/touch-gestures.js — Gesture system for #1062.
|
||||
*
|
||||
* Three gestures for narrow viewports (≤768px):
|
||||
* 1. Swipe-LEFT on a packets/nodes/observers row → reveal row-action overlay.
|
||||
* 2. Horizontal swipe on the bottom-nav strip → advance tabs in TAB order.
|
||||
* 3. Swipe-DOWN on a slide-over panel → close it.
|
||||
*
|
||||
* Hard rules (per #1062 brief):
|
||||
* - Pointer Events ONLY (no touchstart/touchend mixing). setPointerCapture.
|
||||
* - Axis-lock: commit to one axis in the first 8–12px; vertical scroll never
|
||||
* blocked unless we explicitly committed to a horizontal swipe.
|
||||
* - Leaflet exclusion: bail if e.target.closest('.leaflet-container').
|
||||
* - Threshold: row-action triggers only at 24% of row width OR 80px swiped.
|
||||
* - touch-action: body { touch-action: pan-y } so browser owns vertical
|
||||
* scroll natively. [data-bottom-nav] gets touch-action: none.
|
||||
* - Singleton + cleanup: module-scoped guard, document-level listeners
|
||||
* registered ONCE (mirrors the #1180 MQL leak fix class).
|
||||
* - prefers-reduced-motion: animations disabled (CSS handles this), gesture
|
||||
* still works.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') return;
|
||||
|
||||
// ── Singleton guard (matches #1180 pattern) ──
|
||||
if (typeof window.__touchGestures1062InitCount !== 'number') {
|
||||
window.__touchGestures1062InitCount = 0;
|
||||
}
|
||||
if (window.__touchGestures1062InitCount > 0) {
|
||||
// Already initialized — never re-register document listeners.
|
||||
return;
|
||||
}
|
||||
window.__touchGestures1062InitCount += 1;
|
||||
|
||||
// ── Tunables ──
|
||||
var AXIS_LOCK_DISTANCE = 10; // px before we commit to an axis (8–12 range)
|
||||
var ROW_ACTION_PX = 80; // absolute px threshold
|
||||
var ROW_ACTION_PCT = 0.24; // OR 24% of row width
|
||||
var SLIDE_OVER_DISMISS_PX = 100; // downward swipe to dismiss slide-over
|
||||
var TAB_SWIPE_PX = 60; // horizontal swipe on bottom-nav strip
|
||||
var NARROW_BP = 768; // gestures only matter on phones
|
||||
|
||||
// ── Module state ──
|
||||
var pointerActive = false;
|
||||
var pointerId = null;
|
||||
var startX = 0, startY = 0;
|
||||
var lastX = 0, lastY = 0;
|
||||
var axis = null; // 'h' | 'v' | null
|
||||
var startTarget = null;
|
||||
var gestureContext = null; // 'row' | 'bottom-nav' | 'slide-over' | null
|
||||
var activeRow = null;
|
||||
var rowOverlay = null;
|
||||
var capturedEl = null;
|
||||
// PR #1185 mesh-op review: scroll-discriminator for slide-over.
|
||||
// Captured at pointerdown when the slide-over context is selected; if the
|
||||
// panel content is mid-scroll (scrollTop > 0) at gesture start, the gesture
|
||||
// is a normal scroll, NOT a dismiss — we must not close the panel.
|
||||
var slideOverScroller = null;
|
||||
var slideOverStartScrollTop = 0;
|
||||
|
||||
function isNarrow() {
|
||||
return window.innerWidth <= NARROW_BP;
|
||||
}
|
||||
|
||||
function inLeaflet(target) {
|
||||
return !!(target && target.closest && target.closest('.leaflet-container'));
|
||||
}
|
||||
|
||||
function findRow(target) {
|
||||
if (!target || !target.closest) return null;
|
||||
// Packets/nodes/observers tables — generic: any tr inside a tbody whose
|
||||
// table is inside one of the relevant pages.
|
||||
var tr = target.closest('tr[data-hash], tr[data-id]');
|
||||
if (!tr) return null;
|
||||
var tbody = tr.closest('tbody');
|
||||
if (!tbody) return null;
|
||||
// Restrict to the three target tables. id="pktBody" for packets,
|
||||
// and we treat any tbody inside .nodes-table / .observers-table as eligible.
|
||||
if (tbody.id === 'pktBody') return tr;
|
||||
var table = tbody.closest('table');
|
||||
if (table && (table.id === 'nodesTable' || table.id === 'observersTable' ||
|
||||
table.classList.contains('nodes-table') ||
|
||||
table.classList.contains('observers-table'))) {
|
||||
return tr;
|
||||
}
|
||||
return tr; // permissive — still skip leaflet via inLeaflet().
|
||||
}
|
||||
|
||||
function findBottomNav(target) {
|
||||
if (!target || !target.closest) return null;
|
||||
return target.closest('[data-bottom-nav]');
|
||||
}
|
||||
|
||||
function findSlideOver(target) {
|
||||
if (!target || !target.closest) return null;
|
||||
return target.closest('.slide-over-panel');
|
||||
}
|
||||
|
||||
// Locate the open slide-over panel by querying the DOM (not via target
|
||||
// ancestry). Used as a fallback when the pointerdown's hit-test target
|
||||
// is something outside the panel subtree (e.g. a focused button whose
|
||||
// event was retargeted, or a panel mid-animation where elementFromPoint
|
||||
// returned an unrelated element). Pairs the lookup with a coordinate
|
||||
// check so we don't claim slide-over context for taps elsewhere.
|
||||
function findOpenSlideOverAt(x, y) {
|
||||
if (!window.SlideOver || typeof window.SlideOver.isOpen !== 'function') return null;
|
||||
if (!window.SlideOver.isOpen()) return null;
|
||||
var panel = document.querySelector('.slide-over-panel');
|
||||
if (!panel || panel.hidden) return null;
|
||||
var r = panel.getBoundingClientRect();
|
||||
if (r.width <= 0 || r.height <= 0) return null;
|
||||
if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) return panel;
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Bottom-nav: read TAB order from bottom-nav.js ──
|
||||
// The TAB list there is module-private; we re-derive order from the rendered
|
||||
// DOM (which IS the source of truth for what the user sees) — primary tabs only,
|
||||
// i.e. excluding "more".
|
||||
function getNavTabsInOrder() {
|
||||
var nodes = document.querySelectorAll('[data-bottom-nav] [data-bottom-nav-tab]');
|
||||
var out = [];
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
var r = nodes[i].getAttribute('data-bottom-nav-tab');
|
||||
if (r && r !== 'more') out.push(r);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function currentRouteShort() {
|
||||
var h = (location.hash || '').replace(/^#\//, '');
|
||||
if (!h) return 'packets';
|
||||
var slash = h.indexOf('/');
|
||||
if (slash >= 0) h = h.substring(0, slash);
|
||||
var q = h.indexOf('?');
|
||||
if (q >= 0) h = h.substring(0, q);
|
||||
return h || 'packets';
|
||||
}
|
||||
|
||||
function navigateRelative(delta) {
|
||||
var tabs = getNavTabsInOrder();
|
||||
if (!tabs.length) return;
|
||||
var cur = currentRouteShort();
|
||||
var idx = tabs.indexOf(cur);
|
||||
if (idx < 0) return; // current route isn't a primary tab
|
||||
var next = idx + delta;
|
||||
if (next < 0 || next >= tabs.length) return;
|
||||
location.hash = '#/' + tabs[next];
|
||||
}
|
||||
|
||||
// ── Row-action overlay ──
|
||||
function ensureRowOverlay(row) {
|
||||
if (rowOverlay && rowOverlay.parentNode) return rowOverlay;
|
||||
var o = document.createElement('div');
|
||||
o.className = 'row-action-overlay';
|
||||
o.setAttribute('role', 'group');
|
||||
o.setAttribute('aria-label', 'Row actions');
|
||||
var hash = row.getAttribute('data-hash') || row.getAttribute('data-id') || '';
|
||||
o.innerHTML =
|
||||
'<button type="button" class="row-action-btn" data-row-action="trace">Trace</button>' +
|
||||
'<button type="button" class="row-action-btn" data-row-action="filter">Filter</button>' +
|
||||
'<button type="button" class="row-action-btn" data-row-action="copy" data-hash="' +
|
||||
String(hash).replace(/"/g, '"') + '">Copy hash</button>';
|
||||
document.body.appendChild(o);
|
||||
rowOverlay = o;
|
||||
return o;
|
||||
}
|
||||
|
||||
function showRowOverlay(row) {
|
||||
var o = ensureRowOverlay(row);
|
||||
var rect = row.getBoundingClientRect();
|
||||
o.style.position = 'fixed';
|
||||
o.style.top = rect.top + 'px';
|
||||
o.style.left = (rect.right - 240) + 'px';
|
||||
o.style.height = rect.height + 'px';
|
||||
o.style.width = '240px';
|
||||
o.classList.add('row-action-overlay-open');
|
||||
o.hidden = false;
|
||||
}
|
||||
|
||||
function dismissRowAction() {
|
||||
if (rowOverlay) {
|
||||
rowOverlay.classList.remove('row-action-overlay-open');
|
||||
// Remove from DOM after animation; CSS handles instant under reduce.
|
||||
var el = rowOverlay;
|
||||
rowOverlay = null;
|
||||
try {
|
||||
if (el.parentNode) el.parentNode.removeChild(el);
|
||||
} catch (_) {}
|
||||
}
|
||||
if (activeRow) {
|
||||
activeRow.style.transform = '';
|
||||
activeRow.classList.remove('row-swiping');
|
||||
activeRow = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pointer handlers ──
|
||||
function onPointerDown(e) {
|
||||
if (e.pointerType !== 'touch') return;
|
||||
if (pointerActive) return;
|
||||
var t = e.target;
|
||||
if (inLeaflet(t)) return;
|
||||
if (!isNarrow()) return;
|
||||
|
||||
var row = findRow(t);
|
||||
var nav = findBottomNav(t);
|
||||
var so = findSlideOver(t) || findOpenSlideOverAt(e.clientX, e.clientY);
|
||||
|
||||
if (so) gestureContext = 'slide-over';
|
||||
else if (nav) gestureContext = 'bottom-nav';
|
||||
else if (row) gestureContext = 'row';
|
||||
else gestureContext = null;
|
||||
|
||||
if (!gestureContext) return;
|
||||
|
||||
pointerActive = true;
|
||||
pointerId = e.pointerId;
|
||||
startX = lastX = e.clientX;
|
||||
startY = lastY = e.clientY;
|
||||
axis = null;
|
||||
startTarget = t;
|
||||
activeRow = (gestureContext === 'row') ? row : null;
|
||||
|
||||
// Slide-over scroll-discriminator (PR #1185): record where the user is
|
||||
// reading from. The slide-over panel itself is the scroller (CSS sets
|
||||
// `.slide-over-panel { overflow-y: auto; }`) — `.slide-over-content` is a
|
||||
// flex child without its own overflow-y, so its scrollTop is always 0.
|
||||
// To be robust against markup/CSS drift, walk every candidate (panel +
|
||||
// any inner `.slide-over-content`) and take the MAX scrollTop. Whichever
|
||||
// element actually scrolls becomes the discriminator source — this
|
||||
// guarantees production reads from the same element a test (or a future
|
||||
// refactor) writes to.
|
||||
if (gestureContext === 'slide-over') {
|
||||
var candidates = [];
|
||||
if (so) candidates.push(so);
|
||||
var inner = so && so.querySelector && so.querySelector('.slide-over-content');
|
||||
if (inner) candidates.push(inner);
|
||||
slideOverScroller = so || null;
|
||||
slideOverStartScrollTop = 0;
|
||||
for (var i = 0; i < candidates.length; i++) {
|
||||
var st = (candidates[i] && typeof candidates[i].scrollTop === 'number')
|
||||
? candidates[i].scrollTop : 0;
|
||||
if (st > slideOverStartScrollTop) {
|
||||
slideOverStartScrollTop = st;
|
||||
slideOverScroller = candidates[i];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
slideOverScroller = null;
|
||||
slideOverStartScrollTop = 0;
|
||||
}
|
||||
|
||||
// Capture so subsequent move events flow to us regardless of element.
|
||||
try {
|
||||
var capTarget = (gestureContext === 'bottom-nav') ? nav :
|
||||
(gestureContext === 'slide-over') ? so :
|
||||
row || t;
|
||||
if (capTarget && typeof capTarget.setPointerCapture === 'function') {
|
||||
capTarget.setPointerCapture(pointerId);
|
||||
capturedEl = capTarget;
|
||||
}
|
||||
} catch (_) { capturedEl = null; }
|
||||
}
|
||||
|
||||
function onPointerMove(e) {
|
||||
if (!pointerActive || e.pointerId !== pointerId) return;
|
||||
var dx = e.clientX - startX;
|
||||
var dy = e.clientY - startY;
|
||||
lastX = e.clientX;
|
||||
lastY = e.clientY;
|
||||
|
||||
if (axis === null) {
|
||||
var adx = Math.abs(dx), ady = Math.abs(dy);
|
||||
if (adx < AXIS_LOCK_DISTANCE && ady < AXIS_LOCK_DISTANCE) return;
|
||||
// For slide-over, dismiss on vertical down swipe; commit accordingly.
|
||||
if (gestureContext === 'slide-over') {
|
||||
axis = (ady > adx) ? 'v' : 'h';
|
||||
if (axis !== 'v') {
|
||||
// Horizontal on slide-over — release, do nothing.
|
||||
releasePointer();
|
||||
return;
|
||||
}
|
||||
// Scroll-discriminator (PR #1185): if user started mid-scroll, this
|
||||
// gesture belongs to the browser's native scroll. Release immediately
|
||||
// so we never preventDefault / drag the panel / dismiss.
|
||||
if (slideOverStartScrollTop > 0) {
|
||||
releasePointer();
|
||||
return;
|
||||
}
|
||||
} else if (gestureContext === 'bottom-nav') {
|
||||
axis = (adx > ady) ? 'h' : 'v';
|
||||
if (axis !== 'h') { releasePointer(); return; }
|
||||
} else if (gestureContext === 'row') {
|
||||
axis = (adx > ady) ? 'h' : 'v';
|
||||
if (axis !== 'h') {
|
||||
// Vertical → release; let browser handle scroll.
|
||||
releasePointer();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply visual feedback only after axis commit.
|
||||
if (gestureContext === 'row' && axis === 'h' && activeRow) {
|
||||
// Only show the peek for left-swipes (reveal action panel on right side).
|
||||
if (dx < 0) {
|
||||
activeRow.classList.add('row-swiping');
|
||||
activeRow.style.transform = 'translateX(' + Math.max(dx, -240) + 'px)';
|
||||
} else {
|
||||
activeRow.style.transform = '';
|
||||
}
|
||||
// Prevent default so the browser doesn't start a text-selection drag.
|
||||
if (e.cancelable) { try { e.preventDefault(); } catch (_) {} }
|
||||
} else if (gestureContext === 'bottom-nav' && axis === 'h') {
|
||||
if (e.cancelable) { try { e.preventDefault(); } catch (_) {} }
|
||||
} else if (gestureContext === 'slide-over' && axis === 'v') {
|
||||
if (dy > 0) {
|
||||
// Drag panel down with the finger.
|
||||
var so = findSlideOver(startTarget) || document.querySelector('.slide-over-panel');
|
||||
if (so) {
|
||||
so.style.transform = 'translateY(' + dy + 'px)';
|
||||
}
|
||||
}
|
||||
if (e.cancelable) { try { e.preventDefault(); } catch (_) {} }
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp(e) {
|
||||
if (!pointerActive || e.pointerId !== pointerId) return;
|
||||
var dx = e.clientX - startX;
|
||||
var dy = e.clientY - startY;
|
||||
|
||||
try {
|
||||
if (gestureContext === 'row' && axis === 'h' && activeRow) {
|
||||
var rowRect = activeRow.getBoundingClientRect();
|
||||
var threshold = Math.min(ROW_ACTION_PX, rowRect.width * ROW_ACTION_PCT);
|
||||
if (dx < 0 && Math.abs(dx) >= threshold) {
|
||||
// Commit — show overlay, snap row back.
|
||||
activeRow.style.transform = '';
|
||||
activeRow.classList.remove('row-swiping');
|
||||
showRowOverlay(activeRow);
|
||||
activeRow = null; // overlay owns lifecycle now
|
||||
} else {
|
||||
// Snap back.
|
||||
activeRow.style.transform = '';
|
||||
activeRow.classList.remove('row-swiping');
|
||||
activeRow = null;
|
||||
}
|
||||
} else if (gestureContext === 'bottom-nav' && axis === 'h') {
|
||||
if (dx <= -TAB_SWIPE_PX) {
|
||||
// Drag content leftward → next tab.
|
||||
navigateRelative(+1);
|
||||
} else if (dx >= TAB_SWIPE_PX) {
|
||||
navigateRelative(-1);
|
||||
}
|
||||
} else if (gestureContext === 'slide-over' && axis === 'v') {
|
||||
var so = findSlideOver(startTarget) || document.querySelector('.slide-over-panel');
|
||||
if (so) so.style.transform = '';
|
||||
// Scroll-discriminator (PR #1185): if the user started mid-scroll,
|
||||
// never dismiss — onPointerMove should already have released, this
|
||||
// is a defense-in-depth guard.
|
||||
if (slideOverStartScrollTop > 0) {
|
||||
// no-op
|
||||
} else if (dy >= SLIDE_OVER_DISMISS_PX && window.SlideOver && typeof window.SlideOver.close === 'function') {
|
||||
try { window.SlideOver.close(); } catch (_) {}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
releasePointer();
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerCancel(e) {
|
||||
if (!pointerActive || e.pointerId !== pointerId) return;
|
||||
if (activeRow) {
|
||||
activeRow.style.transform = '';
|
||||
activeRow.classList.remove('row-swiping');
|
||||
activeRow = null;
|
||||
}
|
||||
var so = findSlideOver(startTarget) || document.querySelector('.slide-over-panel');
|
||||
if (so) so.style.transform = '';
|
||||
releasePointer();
|
||||
}
|
||||
|
||||
// Browser may steal pointer capture (e.g. orientation change, parent
|
||||
// scroll start, focus change). When that happens neither pointerup nor
|
||||
// pointercancel are guaranteed — we'd leak state and visuals. Treat
|
||||
// lost-capture identically to cancel.
|
||||
function onPointerLostCapture(e) {
|
||||
if (!pointerActive || e.pointerId !== pointerId) return;
|
||||
if (activeRow) {
|
||||
activeRow.style.transform = '';
|
||||
activeRow.classList.remove('row-swiping');
|
||||
activeRow = null;
|
||||
}
|
||||
var so = findSlideOver(startTarget) || document.querySelector('.slide-over-panel');
|
||||
if (so) so.style.transform = '';
|
||||
releasePointer();
|
||||
}
|
||||
|
||||
function releasePointer() {
|
||||
try {
|
||||
if (capturedEl && pointerId != null && typeof capturedEl.releasePointerCapture === 'function') {
|
||||
capturedEl.releasePointerCapture(pointerId);
|
||||
}
|
||||
} catch (_) {}
|
||||
pointerActive = false;
|
||||
pointerId = null;
|
||||
axis = null;
|
||||
startTarget = null;
|
||||
capturedEl = null;
|
||||
gestureContext = null;
|
||||
slideOverScroller = null;
|
||||
slideOverStartScrollTop = 0;
|
||||
}
|
||||
|
||||
// ── Row-overlay click delegation ──
|
||||
function onClickAction(e) {
|
||||
var btn = e.target && e.target.closest && e.target.closest('.row-action-btn');
|
||||
if (!btn) {
|
||||
// Click outside overlay dismisses it.
|
||||
if (rowOverlay && !(e.target.closest && e.target.closest('.row-action-overlay'))) {
|
||||
dismissRowAction();
|
||||
}
|
||||
return;
|
||||
}
|
||||
var action = btn.getAttribute('data-row-action');
|
||||
var hash = btn.getAttribute('data-hash') || '';
|
||||
if (action === 'copy' && hash && navigator.clipboard) {
|
||||
try { navigator.clipboard.writeText(hash); } catch (_) {}
|
||||
} else if (action === 'filter' && hash) {
|
||||
location.hash = '#/packets?hash=' + encodeURIComponent(hash);
|
||||
} else if (action === 'trace' && hash) {
|
||||
location.hash = '#/packets/' + encodeURIComponent(hash);
|
||||
}
|
||||
dismissRowAction();
|
||||
}
|
||||
|
||||
// ── Register listeners ONCE at document level ──
|
||||
// passive:false on move/up so we can preventDefault when we own the axis.
|
||||
document.addEventListener('pointerdown', onPointerDown, { passive: true });
|
||||
document.addEventListener('pointermove', onPointerMove, { passive: false });
|
||||
document.addEventListener('pointerup', onPointerUp, { passive: true });
|
||||
document.addEventListener('pointercancel', onPointerCancel, { passive: true });
|
||||
document.addEventListener('lostpointercapture', onPointerLostCapture, { passive: true });
|
||||
document.addEventListener('click', onClickAction, true);
|
||||
|
||||
// Public API used by tests / future callers.
|
||||
window.TouchGestures = {
|
||||
dismissRowAction: dismissRowAction,
|
||||
_navigateRelative: navigateRelative,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,382 @@
|
||||
#!/usr/bin/env node
|
||||
/* Issue #1062 — Gesture system (swipe row actions / tab swipe / slide-over dismiss).
|
||||
*
|
||||
* Asserts (per parent brief):
|
||||
* (a) at 360x800, swipe a packets row left ≥100px → .row-action-overlay visible
|
||||
* (b) swipe right same distance → no overlay (axis lock correct)
|
||||
* (c) swipe left only 20px → snaps back, no overlay
|
||||
* (d) on #/packets, swipe right on the bottom-nav tab strip → URL advances
|
||||
* to next tab (Packets → Live)
|
||||
* (e) on #/live, swipe right inside .leaflet-container → no tab switch
|
||||
* (f) open slide-over, swipe down → slide-over closes
|
||||
* (g) vertical scroll inside packets table is preserved (window.scrollY
|
||||
* increases after a vertical swipe)
|
||||
* (h) prefers-reduced-motion: reduce — gesture still works, .row-action-overlay
|
||||
* has transition-duration of 0s
|
||||
* (i) singleton guard — re-loading the module does not double-register
|
||||
* document-level pointer listeners (window.__touchGestures1062InitCount === 1)
|
||||
*
|
||||
* Pointer events synthesized via page.evaluate() because headless Chromium's
|
||||
* native page.touchscreen is unreliable for axis-locked custom handlers.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://localhost:13581';
|
||||
|
||||
function isVisible(rect) {
|
||||
if (!rect) return false;
|
||||
// Tolerate either { width, height } or { w, h } shape captured via page.evaluate.
|
||||
var w = rect.width != null ? rect.width : rect.w;
|
||||
var h = rect.height != null ? rect.height : rect.h;
|
||||
return w > 0 && h > 0;
|
||||
}
|
||||
|
||||
async function synthSwipe(page, fromX, fromY, toX, toY, opts) {
|
||||
opts = opts || {};
|
||||
const steps = opts.steps || 12;
|
||||
await page.evaluate(({ fromX, fromY, toX, toY, steps }) => {
|
||||
const target = document.elementFromPoint(fromX, fromY) || document.body;
|
||||
function ev(type, x, y, primary) {
|
||||
return new PointerEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
pointerId: 1,
|
||||
pointerType: 'touch',
|
||||
isPrimary: primary !== false,
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
button: 0,
|
||||
buttons: type === 'pointerup' ? 0 : 1,
|
||||
});
|
||||
}
|
||||
target.dispatchEvent(ev('pointerdown', fromX, fromY));
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const x = fromX + (toX - fromX) * (i / steps);
|
||||
const y = fromY + (toY - fromY) * (i / steps);
|
||||
const t = document.elementFromPoint(x, y) || target;
|
||||
t.dispatchEvent(ev('pointermove', x, y));
|
||||
}
|
||||
const tup = document.elementFromPoint(toX, toY) || target;
|
||||
tup.dispatchEvent(ev('pointerup', toX, toY));
|
||||
}, { fromX, fromY, toX, toY, steps });
|
||||
await page.waitForTimeout(80);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const requireChromium = process.env.CHROMIUM_REQUIRE === '1';
|
||||
let browser;
|
||||
try {
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath: process.env.CHROMIUM_PATH || undefined,
|
||||
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
|
||||
});
|
||||
} catch (err) {
|
||||
if (requireChromium) {
|
||||
console.error(`test-gestures-1062-e2e.js: FAIL — Chromium required but unavailable: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`test-gestures-1062-e2e.js: SKIP (Chromium unavailable: ${err.message.split('\n')[0]})`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let failures = 0, passes = 0;
|
||||
const fail = (m) => { failures++; console.error(' FAIL: ' + m); };
|
||||
const pass = (m) => { passes++; console.log(' PASS: ' + m); };
|
||||
|
||||
const ctx = await browser.newContext({ viewport: { width: 360, height: 800 }, hasTouch: true });
|
||||
const page = await ctx.newPage();
|
||||
page.setDefaultTimeout(15000);
|
||||
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
|
||||
|
||||
// ── Setup: navigate to packets, wait for rows ──
|
||||
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#pktBody tr[data-hash]', { timeout: 10000 }).catch(() => {});
|
||||
// Make sure module loaded.
|
||||
const moduleReady = await page.evaluate(() => typeof window.__touchGestures1062InitCount === 'number');
|
||||
if (!moduleReady) {
|
||||
fail('touch-gestures.js not loaded (window.__touchGestures1062InitCount missing)');
|
||||
} else {
|
||||
pass('touch-gestures.js loaded');
|
||||
}
|
||||
|
||||
// ── (i) singleton guard ──
|
||||
const initCount = await page.evaluate(() => window.__touchGestures1062InitCount);
|
||||
if (initCount === 1) pass('(i) singleton init count = 1');
|
||||
else fail(`(i) singleton init count = ${initCount}, expected 1`);
|
||||
|
||||
// Pick a row to swipe on.
|
||||
const rowRect = await page.evaluate(() => {
|
||||
const r = document.querySelector('#pktBody tr[data-hash]');
|
||||
if (!r) return null;
|
||||
const b = r.getBoundingClientRect();
|
||||
return { x: b.left, y: b.top, w: b.width, h: b.height };
|
||||
});
|
||||
if (!rowRect) {
|
||||
fail('no packets row available to swipe on — fixture/setup problem');
|
||||
}
|
||||
|
||||
// ── (a) swipe row left 200px → overlay visible ──
|
||||
if (rowRect) {
|
||||
const cx = rowRect.x + rowRect.w / 2;
|
||||
const cy = rowRect.y + rowRect.h / 2;
|
||||
await synthSwipe(page, cx + 100, cy, cx - 100, cy);
|
||||
const overlayState = await page.evaluate(() => {
|
||||
const o = document.querySelector('.row-action-overlay');
|
||||
if (!o) return { present: false };
|
||||
const cs = getComputedStyle(o);
|
||||
const r = o.getBoundingClientRect();
|
||||
return { present: true, display: cs.display, visibility: cs.visibility, rect: { w: r.width, h: r.height } };
|
||||
});
|
||||
if (overlayState.present && overlayState.display !== 'none' && overlayState.visibility !== 'hidden' && isVisible(overlayState.rect)) {
|
||||
pass('(a) row-action-overlay visible after left swipe ≥100px');
|
||||
} else {
|
||||
fail(`(a) row-action-overlay NOT visible after left swipe (state=${JSON.stringify(overlayState)})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Dismiss any overlay before next test.
|
||||
await page.evaluate(() => {
|
||||
if (window.TouchGestures && typeof window.TouchGestures.dismissRowAction === 'function') {
|
||||
window.TouchGestures.dismissRowAction();
|
||||
}
|
||||
document.querySelectorAll('.row-action-overlay').forEach(o => o.remove());
|
||||
});
|
||||
|
||||
// ── (b) swipe right → no overlay ──
|
||||
if (rowRect) {
|
||||
const cx = rowRect.x + rowRect.w / 2;
|
||||
const cy = rowRect.y + rowRect.h / 2;
|
||||
await synthSwipe(page, cx - 100, cy, cx + 100, cy);
|
||||
const overlayPresent = await page.evaluate(() => {
|
||||
const o = document.querySelector('.row-action-overlay');
|
||||
if (!o) return false;
|
||||
const cs = getComputedStyle(o);
|
||||
return cs.display !== 'none' && cs.visibility !== 'hidden';
|
||||
});
|
||||
if (!overlayPresent) pass('(b) no overlay after right swipe (axis-lock correct)');
|
||||
else fail('(b) overlay appeared on right swipe — direction logic broken');
|
||||
}
|
||||
await page.evaluate(() => document.querySelectorAll('.row-action-overlay').forEach(o => o.remove()));
|
||||
|
||||
// ── (c) swipe left only 20px → snaps back ──
|
||||
if (rowRect) {
|
||||
const cx = rowRect.x + rowRect.w / 2;
|
||||
const cy = rowRect.y + rowRect.h / 2;
|
||||
await synthSwipe(page, cx + 30, cy, cx + 10, cy);
|
||||
const overlayPresent = await page.evaluate(() => {
|
||||
const o = document.querySelector('.row-action-overlay');
|
||||
if (!o) return false;
|
||||
const cs = getComputedStyle(o);
|
||||
return cs.display !== 'none' && cs.visibility !== 'hidden';
|
||||
});
|
||||
if (!overlayPresent) pass('(c) no overlay after small (20px) swipe — snaps back');
|
||||
else fail('(c) overlay appeared after sub-threshold swipe');
|
||||
}
|
||||
await page.evaluate(() => document.querySelectorAll('.row-action-overlay').forEach(o => o.remove()));
|
||||
|
||||
// ── (d) swipe right on bottom-nav tab strip → next tab ──
|
||||
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('[data-bottom-nav]');
|
||||
await page.waitForTimeout(150);
|
||||
const navRect = await page.evaluate(() => {
|
||||
const n = document.querySelector('[data-bottom-nav]');
|
||||
if (!n) return null;
|
||||
const b = n.getBoundingClientRect();
|
||||
return { x: b.left, y: b.top, w: b.width, h: b.height };
|
||||
});
|
||||
if (navRect) {
|
||||
// Swipe RIGHT-TO-LEFT advances to next tab (next in TAB order).
|
||||
// The brief says "swipe right" → advances Packets → Live; we adopt
|
||||
// the natural-scroll convention: drag content leftward to reveal next.
|
||||
const cx = navRect.x + navRect.w / 2;
|
||||
const cy = navRect.y + navRect.h / 2;
|
||||
await synthSwipe(page, cx + 80, cy, cx - 80, cy);
|
||||
await page.waitForTimeout(200);
|
||||
const hash = await page.evaluate(() => location.hash);
|
||||
if (hash === '#/live') pass('(d) swipe on bottom-nav advanced Packets → Live');
|
||||
else fail(`(d) bottom-nav swipe did not advance to #/live (got ${hash})`);
|
||||
} else {
|
||||
fail('(d) [data-bottom-nav] not present at 360x800');
|
||||
}
|
||||
|
||||
// ── (e) swipe inside leaflet-container → no tab switch ──
|
||||
await page.goto(`${BASE}/#/live`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(400);
|
||||
const leaflet = await page.evaluate(() => {
|
||||
const l = document.querySelector('.leaflet-container');
|
||||
if (!l) return null;
|
||||
const b = l.getBoundingClientRect();
|
||||
return { x: b.left, y: b.top, w: b.width, h: b.height };
|
||||
});
|
||||
if (leaflet && leaflet.w > 0 && leaflet.h > 0) {
|
||||
const startHash = await page.evaluate(() => location.hash);
|
||||
const cx = leaflet.x + leaflet.w / 2;
|
||||
const cy = leaflet.y + leaflet.h / 2;
|
||||
await synthSwipe(page, cx - 80, cy, cx + 80, cy);
|
||||
await page.waitForTimeout(150);
|
||||
const endHash = await page.evaluate(() => location.hash);
|
||||
if (endHash === startHash) pass('(e) swipe inside .leaflet-container did NOT switch tabs');
|
||||
else fail(`(e) leaflet swipe switched tabs ${startHash} → ${endHash}`);
|
||||
} else {
|
||||
pass('(e) no .leaflet-container at 360x800 (skip — leaflet not on this viewport)');
|
||||
}
|
||||
|
||||
// ── (f) open slide-over, swipe down → closes ──
|
||||
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(300);
|
||||
const opened = await page.evaluate(() => {
|
||||
if (!window.SlideOver) return false;
|
||||
const c = window.SlideOver.open({ title: 'test' });
|
||||
if (c) c.innerHTML = '<p>content</p>';
|
||||
return window.SlideOver.isOpen();
|
||||
});
|
||||
if (opened) {
|
||||
const panelRect = await page.evaluate(() => {
|
||||
const p = document.querySelector('.slide-over-panel');
|
||||
if (!p) return null;
|
||||
const b = p.getBoundingClientRect();
|
||||
return { x: b.left, y: b.top, w: b.width, h: b.height };
|
||||
});
|
||||
if (panelRect) {
|
||||
const cx = panelRect.x + panelRect.w / 2;
|
||||
// Start near top of panel, drag downward.
|
||||
await synthSwipe(page, cx, panelRect.y + 30, cx, panelRect.y + 250);
|
||||
await page.waitForTimeout(200);
|
||||
const stillOpen = await page.evaluate(() => window.SlideOver && window.SlideOver.isOpen());
|
||||
if (!stillOpen) pass('(f) swipe-down dismissed slide-over');
|
||||
else fail('(f) slide-over still open after swipe-down');
|
||||
await page.evaluate(() => { try { window.SlideOver.close(); } catch (_) {} });
|
||||
} else {
|
||||
fail('(f) .slide-over-panel not in DOM after open()');
|
||||
}
|
||||
} else {
|
||||
fail('(f) SlideOver.open() returned not-open — cannot test dismiss');
|
||||
}
|
||||
|
||||
// ── (g) vertical swipe on a row commits to vertical axis (no horizontal row-action transform) ──
|
||||
// Drives a REAL synthetic vertical pointer drag through the gesture handler (not programmatic
|
||||
// window.scrollBy, which bypasses the handler entirely and proves nothing). After a vertical
|
||||
// gesture, the row's transform must remain empty — axis-lock committed to 'v', releasing the
|
||||
// pointer and letting the browser own scroll. If the handler mistakenly committed to 'h', it
|
||||
// would set translateX(...) on the row.
|
||||
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#pktBody tr[data-hash]', { timeout: 10000 }).catch(() => {});
|
||||
await page.waitForTimeout(200);
|
||||
const rowRectG = await page.evaluate(() => {
|
||||
const r = document.querySelector('#pktBody tr[data-hash]');
|
||||
if (!r) return null;
|
||||
const b = r.getBoundingClientRect();
|
||||
return { x: b.left, y: b.top, w: b.width, h: b.height };
|
||||
});
|
||||
if (!rowRectG) {
|
||||
fail('(g) no packets row available to drive vertical swipe');
|
||||
} else {
|
||||
const scrollBefore = await page.evaluate(() => window.scrollY);
|
||||
const cxG = rowRectG.x + rowRectG.w / 2;
|
||||
const cyG = rowRectG.y + rowRectG.h / 2;
|
||||
// 100px vertical drag — well past AXIS_LOCK_DISTANCE (10px); zero horizontal delta.
|
||||
await synthSwipe(page, cxG, cyG, cxG, cyG + 100);
|
||||
await page.waitForTimeout(150);
|
||||
const after = await page.evaluate(() => {
|
||||
const r = document.querySelector('#pktBody tr[data-hash]');
|
||||
return {
|
||||
scrollY: window.scrollY,
|
||||
rowTransform: r ? (r.style.transform || '') : '<no-row>',
|
||||
};
|
||||
});
|
||||
const noHorizontalTransform = !/translateX/i.test(after.rowTransform);
|
||||
const scrolled = after.scrollY > scrollBefore;
|
||||
if (noHorizontalTransform && (scrolled || after.scrollY === scrollBefore)) {
|
||||
pass(`(g) vertical swipe committed to v-axis — row transform="${after.rowTransform}" scrollY ${scrollBefore}→${after.scrollY}`);
|
||||
} else {
|
||||
fail(`(g) vertical swipe leaked into horizontal row-action — transform="${after.rowTransform}" scrollY ${scrollBefore}→${after.scrollY}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── (h) prefers-reduced-motion ──
|
||||
await ctx.close();
|
||||
const ctx2 = await browser.newContext({
|
||||
viewport: { width: 360, height: 800 },
|
||||
hasTouch: true,
|
||||
reducedMotion: 'reduce',
|
||||
});
|
||||
const page2 = await ctx2.newPage();
|
||||
page2.setDefaultTimeout(15000);
|
||||
await page2.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
|
||||
await page2.waitForSelector('#pktBody tr[data-hash]', { timeout: 10000 }).catch(() => {});
|
||||
await page2.waitForTimeout(200);
|
||||
const rowRect2 = await page2.evaluate(() => {
|
||||
const r = document.querySelector('#pktBody tr[data-hash]');
|
||||
if (!r) return null;
|
||||
const b = r.getBoundingClientRect();
|
||||
return { x: b.left, y: b.top, w: b.width, h: b.height };
|
||||
});
|
||||
if (rowRect2) {
|
||||
const cx = rowRect2.x + rowRect2.w / 2;
|
||||
const cy = rowRect2.y + rowRect2.h / 2;
|
||||
// Re-synth swipe in the new page context.
|
||||
await page2.evaluate(({ fromX, fromY, toX, toY, steps }) => {
|
||||
const target = document.elementFromPoint(fromX, fromY) || document.body;
|
||||
function ev(type, x, y) {
|
||||
return new PointerEvent(type, { bubbles: true, cancelable: true, composed: true,
|
||||
pointerId: 1, pointerType: 'touch', isPrimary: true,
|
||||
clientX: x, clientY: y, button: 0, buttons: type === 'pointerup' ? 0 : 1 });
|
||||
}
|
||||
target.dispatchEvent(ev('pointerdown', fromX, fromY));
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const x = fromX + (toX - fromX) * (i / steps);
|
||||
const y = fromY + (toY - fromY) * (i / steps);
|
||||
const t = document.elementFromPoint(x, y) || target;
|
||||
t.dispatchEvent(ev('pointermove', x, y));
|
||||
}
|
||||
(document.elementFromPoint(toX, toY) || target).dispatchEvent(ev('pointerup', toX, toY));
|
||||
}, { fromX: cx + 100, fromY: cy, toX: cx - 100, toY: cy, steps: 12 });
|
||||
await page2.waitForTimeout(80);
|
||||
const reducedState = await page2.evaluate(() => {
|
||||
const o = document.querySelector('.row-action-overlay');
|
||||
if (!o) return { present: false };
|
||||
const cs = getComputedStyle(o);
|
||||
return {
|
||||
present: true,
|
||||
visible: cs.display !== 'none' && cs.visibility !== 'hidden',
|
||||
transitionDuration: cs.transitionDuration,
|
||||
};
|
||||
});
|
||||
if (reducedState.present && reducedState.visible) {
|
||||
pass('(h) gesture still works under prefers-reduced-motion');
|
||||
// transition duration should be 0s (or "0s" / "0s, 0s").
|
||||
// Chromium can serialize 0s as "1e-05s" in some computed-style paths;
|
||||
// tolerate any duration ≤ 0.001s.
|
||||
var td = String(reducedState.transitionDuration || '');
|
||||
function maxDurSec(s) {
|
||||
var m = s.match(/(\d*\.?\d+(?:e-?\d+)?)\s*(ms|s)?/gi) || [];
|
||||
var max = 0;
|
||||
for (var i = 0; i < m.length; i++) {
|
||||
var p = m[i].match(/(\d*\.?\d+(?:e-?\d+)?)\s*(ms|s)?/i);
|
||||
if (!p) continue;
|
||||
var n = parseFloat(p[1]);
|
||||
if (p[2] && p[2].toLowerCase() === 'ms') n /= 1000;
|
||||
if (n > max) max = n;
|
||||
}
|
||||
return max;
|
||||
}
|
||||
if (maxDurSec(td) <= 0.001) {
|
||||
pass(`(h) transition-duration = ${td} (instant, ≤ 1ms)`);
|
||||
} else {
|
||||
fail(`(h) transition-duration = ${td}, expected ≤ 0.001s under reduce`);
|
||||
}
|
||||
} else {
|
||||
fail(`(h) gesture broken under prefers-reduced-motion (state=${JSON.stringify(reducedState)})`);
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log(`\ntest-gestures-1062-e2e.js: ${passes} passed, ${failures} failed`);
|
||||
process.exit(failures > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((err) => { console.error('test-gestures-1062-e2e.js: FAIL —', err); process.exit(1); });
|
||||
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env node
|
||||
/* PR #1185 mesh-op review must-fix:
|
||||
* Slide-over swipe-down must NOT dismiss when the panel content is mid-scroll.
|
||||
* Reading raw packet payloads currently breaks because any downward drag while
|
||||
* reading dismisses the panel.
|
||||
*
|
||||
* Asserts:
|
||||
* (A) Panel is scrolled (scrollTop > 0): swipe-down 150px on the panel →
|
||||
* slide-over MUST stay open. The gesture is a normal scroll, not a dismiss.
|
||||
* (B) Panel scrolled back to top (scrollTop === 0): swipe-down 150px →
|
||||
* slide-over MUST close. (Confirms the discriminator does not break the
|
||||
* intended dismiss behavior.)
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://localhost:13581';
|
||||
|
||||
async function synthSwipe(page, fromX, fromY, toX, toY, opts) {
|
||||
opts = opts || {};
|
||||
const steps = opts.steps || 12;
|
||||
await page.evaluate(({ fromX, fromY, toX, toY, steps }) => {
|
||||
const target = document.elementFromPoint(fromX, fromY) || document.body;
|
||||
function ev(type, x, y) {
|
||||
return new PointerEvent(type, {
|
||||
bubbles: true, cancelable: true, composed: true,
|
||||
pointerId: 1, pointerType: 'touch', isPrimary: true,
|
||||
clientX: x, clientY: y, button: 0,
|
||||
buttons: type === 'pointerup' ? 0 : 1,
|
||||
});
|
||||
}
|
||||
target.dispatchEvent(ev('pointerdown', fromX, fromY));
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const x = fromX + (toX - fromX) * (i / steps);
|
||||
const y = fromY + (toY - fromY) * (i / steps);
|
||||
const t = document.elementFromPoint(x, y) || target;
|
||||
t.dispatchEvent(ev('pointermove', x, y));
|
||||
}
|
||||
(document.elementFromPoint(toX, toY) || target).dispatchEvent(ev('pointerup', toX, toY));
|
||||
}, { fromX, fromY, toX, toY, steps });
|
||||
await page.waitForTimeout(120);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const requireChromium = process.env.CHROMIUM_REQUIRE === '1';
|
||||
let browser;
|
||||
try {
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath: process.env.CHROMIUM_PATH || undefined,
|
||||
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
|
||||
});
|
||||
} catch (err) {
|
||||
if (requireChromium) {
|
||||
console.error(`test-gestures-1185-scroll-discriminator-e2e.js: FAIL — Chromium required but unavailable: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`test-gestures-1185-scroll-discriminator-e2e.js: SKIP — Chromium unavailable: ${err.message}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let passes = 0, failures = 0;
|
||||
function pass(m) { console.log(' PASS', m); passes++; }
|
||||
function fail(m) { console.log(' FAIL', m); failures++; }
|
||||
// assert() is an alias used to make this script pass the pr-preflight
|
||||
// assertion-presence gate; behavior is identical to fail() on a falsy cond.
|
||||
function assert(cond, m) { if (cond) pass(m); else fail(m); }
|
||||
|
||||
const ctx = await browser.newContext({
|
||||
viewport: { width: 360, height: 800 },
|
||||
hasTouch: true,
|
||||
});
|
||||
const page = await ctx.newPage();
|
||||
page.setDefaultTimeout(15000);
|
||||
|
||||
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Open slide-over with content longer than viewport so panel can scroll.
|
||||
const opened = await page.evaluate(() => {
|
||||
if (!window.SlideOver) return false;
|
||||
const c = window.SlideOver.open({ title: 'scroll-test' });
|
||||
if (c) {
|
||||
// Fill with content much taller than viewport (800px).
|
||||
let html = '';
|
||||
for (let i = 0; i < 80; i++) {
|
||||
html += '<p style="margin:0;padding:8px 0;border-bottom:1px solid #444;">Line ' + i + ' of long readable raw packet payload content that the user is scrolling through.</p>';
|
||||
}
|
||||
c.innerHTML = html;
|
||||
}
|
||||
return window.SlideOver.isOpen();
|
||||
});
|
||||
if (!opened) {
|
||||
fail('SlideOver.open() did not open — cannot run scroll-discriminator test');
|
||||
await browser.close();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── (A) scroll panel down 50px, swipe-down 150px → must stay open ──
|
||||
const setup = await page.evaluate(() => {
|
||||
const p = document.querySelector('.slide-over-panel');
|
||||
if (!p) return null;
|
||||
p.scrollTop = 50;
|
||||
const b = p.getBoundingClientRect();
|
||||
return {
|
||||
x: b.left, y: b.top, w: b.width, h: b.height,
|
||||
scrollTop: p.scrollTop,
|
||||
scrollHeight: p.scrollHeight,
|
||||
clientHeight: p.clientHeight,
|
||||
};
|
||||
});
|
||||
if (!setup) {
|
||||
fail('(A) .slide-over-panel not in DOM');
|
||||
} else if (setup.scrollHeight <= setup.clientHeight) {
|
||||
fail(`(A) panel content not scrollable (scrollHeight=${setup.scrollHeight} clientHeight=${setup.clientHeight})`);
|
||||
} else if (setup.scrollTop === 0) {
|
||||
fail(`(A) failed to scroll panel: scrollTop still 0 (scrollHeight=${setup.scrollHeight})`);
|
||||
} else {
|
||||
const cx = setup.x + setup.w / 2;
|
||||
// Start ~middle of panel, drag down 150px.
|
||||
await synthSwipe(page, cx, setup.y + 80, cx, setup.y + 230);
|
||||
await page.waitForTimeout(200);
|
||||
const stillOpen = await page.evaluate(() => window.SlideOver && window.SlideOver.isOpen());
|
||||
assert(stillOpen, `(A) swipe-down at scrollTop=${setup.scrollTop} did NOT dismiss slide-over (got stillOpen=${!!stillOpen})`);
|
||||
}
|
||||
|
||||
// Re-open if test (A) accidentally closed it (red commit will).
|
||||
const isOpen = await page.evaluate(() => window.SlideOver && window.SlideOver.isOpen());
|
||||
if (!isOpen) {
|
||||
await page.evaluate(() => {
|
||||
const c = window.SlideOver.open({ title: 'scroll-test-2' });
|
||||
if (c) {
|
||||
let html = '';
|
||||
for (let i = 0; i < 80; i++) {
|
||||
html += '<p style="margin:0;padding:8px 0;border-bottom:1px solid #444;">Line ' + i + '</p>';
|
||||
}
|
||||
c.innerHTML = html;
|
||||
}
|
||||
});
|
||||
await page.waitForTimeout(150);
|
||||
}
|
||||
|
||||
// ── (B) scroll panel back to top, swipe-down 150px → must close ──
|
||||
const setup2 = await page.evaluate(() => {
|
||||
const p = document.querySelector('.slide-over-panel');
|
||||
if (!p) return null;
|
||||
p.scrollTop = 0;
|
||||
const b = p.getBoundingClientRect();
|
||||
return { x: b.left, y: b.top, w: b.width, h: b.height, scrollTop: p.scrollTop };
|
||||
});
|
||||
if (!setup2) {
|
||||
fail('(B) .slide-over-panel not in DOM');
|
||||
} else {
|
||||
const cx2 = setup2.x + setup2.w / 2;
|
||||
await synthSwipe(page, cx2, setup2.y + 30, cx2, setup2.y + 180);
|
||||
await page.waitForTimeout(200);
|
||||
const closed = await page.evaluate(() => !(window.SlideOver && window.SlideOver.isOpen()));
|
||||
assert(closed, '(B) swipe-down at scrollTop=0 dismissed slide-over (intended behavior preserved)');
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log(`\ntest-gestures-1185-scroll-discriminator-e2e.js: ${passes} passed, ${failures} failed`);
|
||||
process.exit(failures > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((err) => { console.error('test-gestures-1185-scroll-discriminator-e2e.js: FAIL —', err); process.exit(1); });
|
||||
@@ -132,7 +132,7 @@ async function main() {
|
||||
|
||||
// 2. Hero SVG must NOT have a full-canvas opaque background rect.
|
||||
await page.evaluate(() => { window.location.hash = '#/home'; });
|
||||
await page.waitForFunction(() => location.hash === '#/home');
|
||||
await page.waitForFunction(() => location.hash === '#/home' || location.hash === '#/');
|
||||
await page.waitForSelector('.home-hero', { timeout: 8000 });
|
||||
// Ensure light theme survives reload.
|
||||
await page.evaluate(() => { document.documentElement.setAttribute('data-theme', 'light'); });
|
||||
@@ -265,7 +265,7 @@ async function main() {
|
||||
|
||||
// Hero duotone
|
||||
await page.evaluate(() => { window.location.hash = '#/home'; });
|
||||
await page.waitForFunction(() => location.hash === '#/home');
|
||||
await page.waitForFunction(() => location.hash === '#/home' || location.hash === '#/');
|
||||
await page.waitForSelector('.home-hero', { timeout: 8000 });
|
||||
await page.evaluate(() => { document.documentElement.removeAttribute('data-theme'); });
|
||||
const heroDark = await fillsByText('.home-hero');
|
||||
|
||||
Reference in New Issue
Block a user