feat: add mobile update swipe triage (Closes #769)
This commit is contained in:
parent
18f56583ba
commit
931f656f78
|
|
@ -669,6 +669,9 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.review-sheet-panel { width:100%; border-left:0; padding:14px; }
|
.review-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||||
.update-sheet-panel { width:100%; border-left:0; padding:14px; overflow-x:hidden; }
|
.update-sheet-panel { width:100%; border-left:0; padding:14px; overflow-x:hidden; }
|
||||||
.update-sheet-panel { padding-bottom:calc(82px + env(safe-area-inset-bottom)); }
|
.update-sheet-panel { padding-bottom:calc(82px + env(safe-area-inset-bottom)); }
|
||||||
|
.update-gesture-status { display:block; padding:6px 10px; border-radius:999px; text-align:center; background:#102641; }
|
||||||
|
.update-sheet-panel[data-triage-gesture="keep"] { box-shadow:inset 8px 0 #60a5fa; }
|
||||||
|
.update-sheet-panel[data-triage-gesture="read"] { box-shadow:inset -8px 0 #34d399; }
|
||||||
.update-decision-bar { position:fixed; inset:auto 0 0; z-index:57; margin:0; padding-bottom:calc(8px + env(safe-area-inset-bottom)); }
|
.update-decision-bar { position:fixed; inset:auto 0 0; z-index:57; margin:0; padding-bottom:calc(8px + env(safe-area-inset-bottom)); }
|
||||||
.update-more-actions > summary { display:none; }
|
.update-more-actions > summary { display:none; }
|
||||||
.issue-sheet-panel { width:100%; border-left:0; padding:14px; }
|
.issue-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||||
|
|
@ -719,6 +722,10 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.mobile-today-hud button { min-height:44px; max-width:100%; }
|
.mobile-today-hud button { min-height:44px; max-width:100%; }
|
||||||
.mobile-today-hud [data-work-session-adjust-plan] { grid-column:1 / -1; }
|
.mobile-today-hud [data-work-session-adjust-plan] { grid-column:1 / -1; }
|
||||||
}
|
}
|
||||||
|
@media (min-width:701px) { .update-gesture-status { display:none; } }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.update-sheet-panel[data-triage-gesture] { transition:none; }
|
||||||
|
}
|
||||||
.obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 rx=%226%22 fill=%22%230b1526%22/><circle cx=%2212%22 cy=%2212%22 r=%226%22 fill=%22%2360a5fa%22/></svg>') center/contain no-repeat; display:inline-block; }
|
.obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 rx=%226%22 fill=%22%230b1526%22/><circle cx=%2212%22 cy=%2212%22 r=%226%22 fill=%22%2360a5fa%22/></svg>') center/contain no-repeat; display:inline-block; }
|
||||||
.footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; }
|
.footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; }
|
||||||
@keyframes fadein { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
|
@keyframes fadein { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
|
|
||||||
|
|
@ -5558,6 +5558,10 @@
|
||||||
if (updateTriage.active()) updateTriage.keepUnreadAndNext();
|
if (updateTriage.active()) updateTriage.keepUnreadAndNext();
|
||||||
else closeUpdateSheet(true);
|
else closeUpdateSheet(true);
|
||||||
});
|
});
|
||||||
|
createUpdateTriageGesture({
|
||||||
|
surface: qs('#update-sheet .update-sheet-panel'), enabled: () => selectedUpdateDetail,
|
||||||
|
keepUnread: () => qs('#keep-update-unread').click(), markRead: () => qs('#mark-update-read-next').click(),
|
||||||
|
});
|
||||||
qs('#update-ownership-action').addEventListener('click', () => updateOwnership.act());
|
qs('#update-ownership-action').addEventListener('click', () => updateOwnership.act());
|
||||||
qs('#update-ownership-start').addEventListener('click', () => updateOwnership.start());
|
qs('#update-ownership-start').addEventListener('click', () => updateOwnership.start());
|
||||||
qs('#retry-update-load').addEventListener('click', () => {
|
qs('#retry-update-load').addEventListener('click', () => {
|
||||||
|
|
|
||||||
|
|
@ -743,7 +743,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="update-sheet" id="update-sheet" role="dialog" aria-modal="true" aria-labelledby="update-sheet-title">
|
<div class="update-sheet" id="update-sheet" role="dialog" aria-modal="true" aria-labelledby="update-sheet-title">
|
||||||
<section class="update-sheet-panel">
|
<section class="update-sheet-panel" data-triage-gesture="">
|
||||||
<div class="update-sheet-header">
|
<div class="update-sheet-header">
|
||||||
<div>
|
<div>
|
||||||
<div class="small" id="update-sheet-key"></div>
|
<div class="small" id="update-sheet-key"></div>
|
||||||
|
|
@ -751,6 +751,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="update-triage-progress" class="update-triage-progress small" aria-live="polite" hidden></div>
|
<div id="update-triage-progress" class="update-triage-progress small" aria-live="polite" hidden></div>
|
||||||
|
<div id="update-gesture-status" class="update-gesture-status small" aria-live="polite">Swipe right to keep unread or left to mark read. Buttons remain available.</div>
|
||||||
<div id="update-sheet-status" class="small" aria-live="polite">Choose an update.</div>
|
<div id="update-sheet-status" class="small" aria-live="polite">Choose an update.</div>
|
||||||
<button class="update-retry" id="retry-update-load" type="button" hidden>Retry loading update</button>
|
<button class="update-retry" id="retry-update-load" type="button" hidden>Retry loading update</button>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
|
@ -1089,6 +1090,7 @@
|
||||||
<script src="static/update-triage-session.js"></script>
|
<script src="static/update-triage-session.js"></script>
|
||||||
<script src="static/update-read-position.js"></script>
|
<script src="static/update-read-position.js"></script>
|
||||||
<script src="static/update-triage-launcher.js"></script>
|
<script src="static/update-triage-launcher.js"></script>
|
||||||
|
<script src="static/update-triage-gesture.js"></script>
|
||||||
<script src="static/agenda-session-launcher.js"></script>
|
<script src="static/agenda-session-launcher.js"></script>
|
||||||
<script src="static/mobile-launch.js"></script>
|
<script src="static/mobile-launch.js"></script>
|
||||||
<script src="static/mobile-app-shortcuts.js"></script>
|
<script src="static/mobile-app-shortcuts.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,7 @@ const SHELL = [
|
||||||
BASE + 'static/update-triage-session.js',
|
BASE + 'static/update-triage-session.js',
|
||||||
BASE + 'static/update-read-position.js',
|
BASE + 'static/update-read-position.js',
|
||||||
BASE + 'static/update-triage-launcher.js',
|
BASE + 'static/update-triage-launcher.js',
|
||||||
|
BASE + 'static/update-triage-gesture.js',
|
||||||
BASE + 'static/agenda-session-launcher.js',
|
BASE + 'static/agenda-session-launcher.js',
|
||||||
BASE + 'static/mobile-launch.js',
|
BASE + 'static/mobile-launch.js',
|
||||||
BASE + 'static/mobile-app-shortcuts.js',
|
BASE + 'static/mobile-app-shortcuts.js',
|
||||||
|
|
|
||||||
92
frontend/update-triage-gesture.js
Normal file
92
frontend/update-triage-gesture.js
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
(function (root, factory) {
|
||||||
|
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||||
|
else root.createUpdateTriageGesture = factory;
|
||||||
|
})(typeof self !== 'undefined' ? self : this, function createUpdateTriageGesture(options) {
|
||||||
|
const viewportWidth = options.viewportWidth || (() => root.innerWidth);
|
||||||
|
const surface = options.surface;
|
||||||
|
const threshold = options.threshold || 72;
|
||||||
|
const edgeInset = options.edgeInset || 24;
|
||||||
|
const interactive = 'a,button,input,textarea,select,label,summary,[contenteditable="true"],[role="button"]';
|
||||||
|
let active = null;
|
||||||
|
let busy = false;
|
||||||
|
|
||||||
|
function horizontalScroller(target) {
|
||||||
|
for (let node = target; node && node !== surface; node = node.parentElement) {
|
||||||
|
if (Number(node.scrollWidth) > Number(node.clientWidth) + 1) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ignored(event) {
|
||||||
|
return event.pointerType === 'mouse' || event.clientX <= edgeInset ||
|
||||||
|
event.clientX >= viewportWidth() - edgeInset ||
|
||||||
|
Boolean(event.target?.closest?.(interactive)) || horizontalScroller(event.target);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
active = null;
|
||||||
|
delete surface.dataset.triageGesture;
|
||||||
|
}
|
||||||
|
|
||||||
|
function start(event) {
|
||||||
|
if (busy || !options.enabled() || viewportWidth() > 700 || ignored(event) || active) return;
|
||||||
|
active = {id:event.pointerId, x:event.clientX, y:event.clientY, cancelled:false};
|
||||||
|
surface.setPointerCapture?.(event.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function move(event) {
|
||||||
|
if (!active || active.id !== event.pointerId) return;
|
||||||
|
const dx = event.clientX - active.x;
|
||||||
|
const dy = event.clientY - active.y;
|
||||||
|
if (Math.abs(dy) > Math.abs(dx) || Math.abs(dy) > 36) {
|
||||||
|
active.cancelled = true;
|
||||||
|
delete surface.dataset.triageGesture;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Math.abs(dx) < 12) return;
|
||||||
|
event.preventDefault?.();
|
||||||
|
surface.dataset.triageGesture = dx > 0 ? 'keep' : 'read';
|
||||||
|
}
|
||||||
|
|
||||||
|
function decide(callback) {
|
||||||
|
busy = true;
|
||||||
|
let outcome;
|
||||||
|
try {
|
||||||
|
outcome = callback();
|
||||||
|
} catch (error) {
|
||||||
|
busy = false;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (outcome && typeof outcome.then === 'function') {
|
||||||
|
Promise.resolve(outcome).finally(() => { busy = false; });
|
||||||
|
} else {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function finish(event) {
|
||||||
|
if (!active || active.id !== event.pointerId) return;
|
||||||
|
const gesture = active;
|
||||||
|
const dx = event.clientX - gesture.x;
|
||||||
|
const dy = event.clientY - gesture.y;
|
||||||
|
surface.releasePointerCapture?.(event.pointerId);
|
||||||
|
reset();
|
||||||
|
if (gesture.cancelled || Math.abs(dx) < threshold || Math.abs(dx) <= Math.abs(dy)) return;
|
||||||
|
decide(dx > 0 ? options.keepUnread : options.markRead);
|
||||||
|
}
|
||||||
|
|
||||||
|
surface.addEventListener('pointerdown', start);
|
||||||
|
surface.addEventListener('pointermove', move);
|
||||||
|
surface.addEventListener('pointerup', finish);
|
||||||
|
surface.addEventListener('pointercancel', reset);
|
||||||
|
|
||||||
|
function destroy() {
|
||||||
|
surface.removeEventListener('pointerdown', start);
|
||||||
|
surface.removeEventListener('pointermove', move);
|
||||||
|
surface.removeEventListener('pointerup', finish);
|
||||||
|
surface.removeEventListener('pointercancel', reset);
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {destroy};
|
||||||
|
});
|
||||||
|
|
@ -28,7 +28,7 @@ FEATURE_SOURCES = {
|
||||||
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
|
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
|
||||||
"security-center": ("static/security-center.js",),
|
"security-center": ("static/security-center.js",),
|
||||||
"today-timer": (
|
"today-timer": (
|
||||||
"static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/update-triage-launcher.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
|
"static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
|
||||||
"static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js",
|
"static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js",
|
||||||
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
||||||
"static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",
|
"static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",
|
||||||
|
|
|
||||||
|
|
@ -849,6 +849,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||||
"/dashboard/static/update-triage-session.js",
|
"/dashboard/static/update-triage-session.js",
|
||||||
"/dashboard/static/update-read-position.js",
|
"/dashboard/static/update-read-position.js",
|
||||||
"/dashboard/static/update-triage-launcher.js",
|
"/dashboard/static/update-triage-launcher.js",
|
||||||
|
"/dashboard/static/update-triage-gesture.js",
|
||||||
"/dashboard/static/agenda-session-launcher.js",
|
"/dashboard/static/agenda-session-launcher.js",
|
||||||
"/dashboard/static/mobile-launch.js",
|
"/dashboard/static/mobile-launch.js",
|
||||||
"/dashboard/static/mobile-app-shortcuts.js",
|
"/dashboard/static/mobile-app-shortcuts.js",
|
||||||
|
|
|
||||||
110
tests/test_update_triage_gesture.py
Normal file
110
tests/test_update_triage_gesture.py
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.dashboard_bundle import dashboard
|
||||||
|
|
||||||
|
|
||||||
|
GESTURE = Path(__file__).resolve().parents[1] / "frontend" / "update-triage-gesture.js"
|
||||||
|
|
||||||
|
|
||||||
|
def run_gesture(script):
|
||||||
|
source = f"const createGesture = require({json.dumps(str(GESTURE))});\n" + script
|
||||||
|
result = subprocess.run(["node", "-e", source], capture_output=True, text=True)
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
return json.loads(result.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def test_committed_horizontal_swipes_decide_once_and_lock_while_pending():
|
||||||
|
result = run_gesture(r"""
|
||||||
|
class Surface {
|
||||||
|
constructor() { this.listeners = {}; this.dataset = {}; }
|
||||||
|
addEventListener(name, callback) { this.listeners[name] = callback; }
|
||||||
|
removeEventListener(name) { delete this.listeners[name]; }
|
||||||
|
setPointerCapture() {}
|
||||||
|
releasePointerCapture() {}
|
||||||
|
}
|
||||||
|
const surface = new Surface();
|
||||||
|
const calls = [];
|
||||||
|
let enabled = true;
|
||||||
|
let resolveRead;
|
||||||
|
let lockedCalls;
|
||||||
|
const gesture = createGesture({
|
||||||
|
surface,
|
||||||
|
enabled: () => enabled,
|
||||||
|
keepUnread: () => calls.push('keep'),
|
||||||
|
markRead: () => new Promise(resolve => { calls.push('read'); resolveRead = resolve; }),
|
||||||
|
viewportWidth: () => 390,
|
||||||
|
});
|
||||||
|
const target = {closest: () => null, parentElement: null, scrollWidth: 100, clientWidth: 100};
|
||||||
|
const event = (x, y, id=1) => ({pointerId:id, pointerType:'touch', clientX:x, clientY:y, target, preventDefault(){}});
|
||||||
|
surface.listeners.pointerdown(event(180, 200));
|
||||||
|
surface.listeners.pointermove(event(250, 205));
|
||||||
|
surface.listeners.pointerup(event(270, 206));
|
||||||
|
surface.listeners.pointerdown(event(280, 200));
|
||||||
|
surface.listeners.pointermove(event(205, 204));
|
||||||
|
surface.listeners.pointerup(event(190, 205));
|
||||||
|
surface.listeners.pointerdown(event(280, 200));
|
||||||
|
surface.listeners.pointermove(event(190, 203));
|
||||||
|
surface.listeners.pointerup(event(180, 203));
|
||||||
|
Promise.resolve().then(() => {
|
||||||
|
lockedCalls = [...calls];
|
||||||
|
resolveRead();
|
||||||
|
return Promise.resolve();
|
||||||
|
}).then(() => process.stdout.write(JSON.stringify({calls, lockedCalls, direction:surface.dataset.triageGesture || null})));
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
"calls": ["keep", "read"],
|
||||||
|
"lockedCalls": ["keep", "read"],
|
||||||
|
"direction": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_scrolls_short_drags_edges_and_interactive_targets_never_decide():
|
||||||
|
result = run_gesture(r"""
|
||||||
|
class Surface {
|
||||||
|
constructor() { this.listeners = {}; this.dataset = {}; }
|
||||||
|
addEventListener(name, callback) { this.listeners[name] = callback; }
|
||||||
|
removeEventListener(name) { delete this.listeners[name]; }
|
||||||
|
setPointerCapture() {}
|
||||||
|
releasePointerCapture() {}
|
||||||
|
}
|
||||||
|
const surface = new Surface();
|
||||||
|
const calls = [];
|
||||||
|
createGesture({surface, enabled:()=>true, keepUnread:()=>calls.push('keep'), markRead:()=>calls.push('read'), viewportWidth:()=>390});
|
||||||
|
const plain = {closest: () => null, parentElement: null, scrollWidth:100, clientWidth:100};
|
||||||
|
const interactive = {closest: selector => selector.includes('button') ? {} : null, parentElement:null, scrollWidth:100, clientWidth:100};
|
||||||
|
const scroller = {closest: () => null, parentElement:null, scrollWidth:400, clientWidth:100};
|
||||||
|
const event = (x,y,target=plain,id=1) => ({pointerId:id,pointerType:'touch',clientX:x,clientY:y,target,preventDefault(){}});
|
||||||
|
function drag(fromX, fromY, toX, toY, target=plain) {
|
||||||
|
surface.listeners.pointerdown(event(fromX,fromY,target));
|
||||||
|
surface.listeners.pointermove(event(toX,toY,target));
|
||||||
|
surface.listeners.pointerup(event(toX,toY,target));
|
||||||
|
}
|
||||||
|
drag(180,100,205,104);
|
||||||
|
drag(180,100,220,190);
|
||||||
|
drag(8,100,120,104);
|
||||||
|
drag(180,100,280,104,interactive);
|
||||||
|
drag(180,100,280,104,scroller);
|
||||||
|
surface.listeners.pointerdown(event(180,100));
|
||||||
|
surface.listeners.pointercancel(event(280,104));
|
||||||
|
process.stdout.write(JSON.stringify({calls, direction:surface.dataset.triageGesture || null}));
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert result == {"calls": [], "direction": None}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_dashboard_wires_accessible_phone_only_update_swipe_triage():
|
||||||
|
html = await dashboard()
|
||||||
|
|
||||||
|
assert '<script src="static/update-triage-gesture.js"></script>' in html
|
||||||
|
assert 'id="update-gesture-status"' in html
|
||||||
|
assert "createUpdateTriageGesture({" in html
|
||||||
|
assert "qs('#keep-update-unread').click()" in html
|
||||||
|
assert "qs('#mark-update-read-next').click()" in html
|
||||||
|
assert "data-triage-gesture" in html
|
||||||
|
assert "prefers-reduced-motion: reduce" in html
|
||||||
Loading…
Reference in New Issue
Block a user