Keep mobile conversation replies keyboard-safe #324
|
|
@ -310,6 +310,12 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
#cmd-results { flex:1; min-height:0; overflow-y:auto; max-height:none; overscroll-behavior:contain; padding-bottom:env(safe-area-inset-bottom); }
|
||||
.create-issue-panel { width:100%; border-left:0; padding:14px; }
|
||||
.pull-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.issue-sheet-panel.composer-keyboard-active,
|
||||
.pull-sheet-panel.composer-keyboard-active,
|
||||
.update-sheet-panel.composer-keyboard-active { position:fixed; top:var(--composer-viewport-top,0px); right:0; height:var(--composer-viewport-height,100dvh); overscroll-behavior:contain; padding-bottom:calc(14px + env(safe-area-inset-bottom)); }
|
||||
.composer-keyboard-active .issue-comment-composer,
|
||||
.composer-keyboard-active .pull-comment-composer,
|
||||
.composer-keyboard-active .update-reply { scroll-margin-block:12px; padding-bottom:env(safe-area-inset-bottom); }
|
||||
.mobile-task-dock { position:fixed; inset:auto 0 0; z-index:45; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:2px; padding:6px 8px; padding-bottom:env(safe-area-inset-bottom); border-top:1px solid #2a496e; background:rgba(11,21,38,.98); backdrop-filter:blur(12px); }
|
||||
}
|
||||
.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; }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,16 @@
|
|||
(function(){
|
||||
const qs = (s, el=document) => el.querySelector(s);
|
||||
const fmt = (d) => new Date(d).toLocaleString();
|
||||
const mobileComposerViewport = createMobileComposerViewport({
|
||||
viewport: window.visualViewport,
|
||||
mediaQuery: window.matchMedia('(max-width: 600px)'),
|
||||
entries: [
|
||||
{ panel:qs('#issue-sheet .issue-sheet-panel'), workspace:qs('.issue-comment-composer'), composer:qs('#issue-comment'), submit:qs('#send-issue-comment'), status:qs('#issue-comment-status') },
|
||||
{ panel:qs('#pull-sheet .pull-sheet-panel'), workspace:qs('.pull-comment-composer'), composer:qs('#pull-comment'), submit:qs('#send-pull-comment'), status:qs('#pull-comment-status') },
|
||||
{ panel:qs('#update-sheet .update-sheet-panel'), workspace:qs('.update-reply'), composer:qs('#update-reply'), submit:qs('#send-update-reply'), status:qs('#update-reply-status') },
|
||||
],
|
||||
});
|
||||
mobileComposerViewport.start();
|
||||
[
|
||||
[qs('.app-menu'), qs('#app-menu-toggle')],
|
||||
[qs('.work-settings'), qs('#work-settings-toggle')],
|
||||
|
|
@ -515,6 +525,8 @@
|
|||
qs('#retry-work-route').addEventListener('click', () => workRoute.sync());
|
||||
|
||||
function closeOpenWorkSheets() {
|
||||
['#issue-sheet .issue-sheet-panel', '#pull-sheet .pull-sheet-panel', '#update-sheet .update-sheet-panel']
|
||||
.forEach(selector => mobileComposerViewport.close(qs(selector)));
|
||||
['#issue-sheet', '#pull-sheet', '#review-sheet', '#update-sheet'].forEach(selector =>
|
||||
qs(selector).classList.remove('open')
|
||||
);
|
||||
|
|
@ -1206,6 +1218,7 @@
|
|||
workRoute.close();
|
||||
return;
|
||||
}
|
||||
mobileComposerViewport.close(qs('#issue-sheet .issue-sheet-panel'));
|
||||
qs('#issue-sheet').classList.remove('open');
|
||||
selectedIssue = null;
|
||||
selectedIssueDetail = null;
|
||||
|
|
@ -1336,6 +1349,7 @@
|
|||
workRoute.close();
|
||||
return;
|
||||
}
|
||||
mobileComposerViewport.close(qs('#pull-sheet .pull-sheet-panel'));
|
||||
qs('#pull-sheet').classList.remove('open');
|
||||
selectedPull = null;
|
||||
selectedPullDetail = null;
|
||||
|
|
@ -1749,6 +1763,7 @@
|
|||
workRoute.close();
|
||||
return;
|
||||
}
|
||||
mobileComposerViewport.close(qs('#update-sheet .update-sheet-panel'));
|
||||
qs('#update-sheet').classList.remove('open');
|
||||
selectedUpdate = null;
|
||||
if (restoreTrigger && updateTrigger?.isConnected) updateTrigger.focus();
|
||||
|
|
|
|||
|
|
@ -536,6 +536,7 @@
|
|||
<script src="static/mobile-launch.js"></script>
|
||||
<script src="static/install-app.js"></script>
|
||||
<script src="static/mobile-search-viewport.js"></script>
|
||||
<script src="static/mobile-composer-viewport.js"></script>
|
||||
<script src="static/dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
92
frontend/mobile-composer-viewport.js
Normal file
92
frontend/mobile-composer-viewport.js
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
(function (root, factory) {
|
||||
const exported = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = exported;
|
||||
else root.createMobileComposerViewport = exported;
|
||||
})(typeof self !== 'undefined' ? self : this, function () {
|
||||
return function createMobileComposerViewport(options) {
|
||||
const entries = options.entries || [];
|
||||
const viewport = options.viewport;
|
||||
const mediaQuery = options.mediaQuery;
|
||||
const documentRef = options.document || document;
|
||||
const schedule = options.schedule || (callback => requestAnimationFrame(callback));
|
||||
let active = null;
|
||||
let started = false;
|
||||
|
||||
function reveal() {
|
||||
if (!active) return;
|
||||
active.workspace.scrollIntoView({ block:'end', inline:'nearest' });
|
||||
}
|
||||
|
||||
function applyGeometry() {
|
||||
if (!active || !viewport || !mediaQuery.matches) return;
|
||||
active.panel.style.setProperty('--composer-viewport-top', `${viewport.offsetTop || 0}px`);
|
||||
active.panel.style.setProperty('--composer-viewport-height', `${viewport.height}px`);
|
||||
reveal();
|
||||
}
|
||||
|
||||
function onViewportChange() {
|
||||
schedule(applyGeometry);
|
||||
}
|
||||
|
||||
function activate(entry) {
|
||||
if (!viewport || !mediaQuery.matches) return;
|
||||
if (active === entry) {
|
||||
schedule(applyGeometry);
|
||||
return;
|
||||
}
|
||||
if (active) deactivate(active, false);
|
||||
active = entry;
|
||||
entry.scrollTop = entry.panel.scrollTop;
|
||||
entry.panel.classList?.add('composer-keyboard-active');
|
||||
viewport.addEventListener('resize', onViewportChange);
|
||||
viewport.addEventListener('scroll', onViewportChange);
|
||||
applyGeometry();
|
||||
}
|
||||
|
||||
function deactivate(entry, restore = true) {
|
||||
if (active !== entry) return;
|
||||
viewport?.removeEventListener('resize', onViewportChange);
|
||||
viewport?.removeEventListener('scroll', onViewportChange);
|
||||
entry.panel.classList?.remove('composer-keyboard-active');
|
||||
entry.panel.style.removeProperty('--composer-viewport-top');
|
||||
entry.panel.style.removeProperty('--composer-viewport-height');
|
||||
active = null;
|
||||
if (restore) schedule(() => { entry.panel.scrollTop = entry.scrollTop; });
|
||||
}
|
||||
|
||||
function bind(entry) {
|
||||
entry.onFocus = () => activate(entry);
|
||||
entry.onFocusOut = () => schedule(() => {
|
||||
if (!entry.workspace.contains(documentRef.activeElement)) deactivate(entry);
|
||||
});
|
||||
entry.composer.addEventListener('focus', entry.onFocus);
|
||||
entry.workspace.addEventListener('focusout', entry.onFocusOut);
|
||||
}
|
||||
|
||||
function unbind(entry) {
|
||||
entry.composer.removeEventListener('focus', entry.onFocus);
|
||||
entry.workspace.removeEventListener('focusout', entry.onFocusOut);
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
if (started) return;
|
||||
started = true;
|
||||
entries.forEach(bind);
|
||||
},
|
||||
close(panel) {
|
||||
const entry = entries.find(candidate => candidate.panel === panel);
|
||||
if (entry) deactivate(entry, false);
|
||||
},
|
||||
stop() {
|
||||
if (!started) return;
|
||||
if (active) deactivate(active, false);
|
||||
entries.forEach(unbind);
|
||||
started = false;
|
||||
},
|
||||
revealActive() {
|
||||
schedule(applyGeometry);
|
||||
},
|
||||
};
|
||||
};
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v35';
|
||||
const CACHE = 'stackchain-dashboard-shell-v36';
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const SHELL = [
|
||||
BASE,
|
||||
|
|
@ -32,11 +32,13 @@ const SHELL = [
|
|||
BASE + 'static/pull-sheet.js',
|
||||
BASE + 'static/review-sheet.js',
|
||||
BASE + 'static/work-route.js',
|
||||
BASE + 'static/task-overlay-history.js',
|
||||
BASE + 'static/context-poller.js',
|
||||
BASE + 'static/mobile-task-dock.js',
|
||||
BASE + 'static/mobile-launch.js',
|
||||
BASE + 'static/install-app.js',
|
||||
BASE + 'static/mobile-search-viewport.js',
|
||||
BASE + 'static/mobile-composer-viewport.js',
|
||||
BASE + 'static/background-issue-sync.js',
|
||||
];
|
||||
|
||||
|
|
|
|||
38
tests/test_mobile_composer_integration.py
Normal file
38
tests/test_mobile_composer_integration.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
HTML = ROOT / "frontend" / "index.html"
|
||||
DASHBOARD = ROOT / "frontend" / "dashboard.js"
|
||||
CSS = ROOT / "frontend" / "dashboard.css"
|
||||
WORKER = ROOT / "frontend" / "service-worker.js"
|
||||
|
||||
|
||||
def test_all_conversation_composers_are_wired_to_keyboard_safe_panels():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert '<script src="static/mobile-composer-viewport.js"></script>' in html
|
||||
assert "createMobileComposerViewport({" in dashboard
|
||||
for panel, workspace, composer, submit, status in [
|
||||
("#issue-sheet .issue-sheet-panel", ".issue-comment-composer", "#issue-comment", "#send-issue-comment", "#issue-comment-status"),
|
||||
("#pull-sheet .pull-sheet-panel", ".pull-comment-composer", "#pull-comment", "#send-pull-comment", "#pull-comment-status"),
|
||||
("#update-sheet .update-sheet-panel", ".update-reply", "#update-reply", "#send-update-reply", "#update-reply-status"),
|
||||
]:
|
||||
for selector in (panel, workspace, composer, submit, status):
|
||||
assert repr(selector) in dashboard
|
||||
assert "height:var(--composer-viewport-height,100dvh)" in css
|
||||
assert "top:var(--composer-viewport-top,0px)" in css
|
||||
assert ".composer-keyboard-active" in css
|
||||
|
||||
|
||||
def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
||||
html = HTML.read_text()
|
||||
worker = WORKER.read_text()
|
||||
local_assets = set(re.findall(r'(?:src|href)="((?:static/|manifest\.webmanifest)[^"?#]*)', html))
|
||||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
||||
|
||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||
assert "stackchain-dashboard-shell-v36" in worker
|
||||
129
tests/test_mobile_composer_viewport.py
Normal file
129
tests/test_mobile_composer_viewport.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
CONTROLLER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-composer-viewport.js"
|
||||
|
||||
|
||||
def run_controller_scenario(scenario: str) -> dict:
|
||||
harness = f"""
|
||||
const createController = require({json.dumps(str(CONTROLLER))});
|
||||
class Target {{
|
||||
constructor() {{ this.listeners = {{}}; }}
|
||||
addEventListener(name, handler) {{ (this.listeners[name] ||= new Set()).add(handler); }}
|
||||
removeEventListener(name, handler) {{ this.listeners[name]?.delete(handler); }}
|
||||
dispatch(name, event = {{}}) {{ for (const handler of this.listeners[name] || []) handler(event); }}
|
||||
count(name) {{ return this.listeners[name]?.size || 0; }}
|
||||
}}
|
||||
function element() {{
|
||||
const target = new Target();
|
||||
target.style = {{
|
||||
values: {{}},
|
||||
setProperty(name, value) {{ this.values[name] = value; }},
|
||||
removeProperty(name) {{ delete this.values[name]; }},
|
||||
}};
|
||||
target.scrollTop = 0;
|
||||
target.reveals = [];
|
||||
target.scrollIntoView = options => target.reveals.push(options);
|
||||
target.contains = candidate => candidate === target || candidate?.workspace === target;
|
||||
return target;
|
||||
}}
|
||||
const viewport = new Target();
|
||||
viewport.offsetTop = 12;
|
||||
viewport.height = 360;
|
||||
const mediaQuery = {{matches:true}};
|
||||
const panel = element();
|
||||
const workspace = element();
|
||||
const composer = element(); composer.workspace = workspace;
|
||||
const submit = element(); submit.workspace = workspace;
|
||||
const status = element(); status.workspace = workspace;
|
||||
const document = {{activeElement:null}};
|
||||
const scheduled = [];
|
||||
const controller = createController({{
|
||||
entries:[{{panel, workspace, composer, submit, status}}], viewport, mediaQuery, document,
|
||||
schedule: callback => scheduled.push(callback),
|
||||
}});
|
||||
function flush() {{ while (scheduled.length) scheduled.shift()(); }}
|
||||
{scenario}
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_mobile_composer_uses_visual_viewport_and_reveals_delivery_controls():
|
||||
result = run_controller_scenario(
|
||||
"""
|
||||
controller.start();
|
||||
panel.scrollTop = 240;
|
||||
document.activeElement = composer;
|
||||
composer.dispatch('focus');
|
||||
viewport.offsetTop = 20;
|
||||
viewport.height = 300;
|
||||
viewport.dispatch('resize');
|
||||
flush();
|
||||
process.stdout.write(JSON.stringify({
|
||||
geometry: panel.style.values,
|
||||
resizeListeners: viewport.count('resize'),
|
||||
scrollListeners: viewport.count('scroll'),
|
||||
revealCount: workspace.reveals.length,
|
||||
revealOptions: workspace.reveals.at(-1),
|
||||
}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"geometry": {
|
||||
"--composer-viewport-top": "20px",
|
||||
"--composer-viewport-height": "300px",
|
||||
},
|
||||
"resizeListeners": 1,
|
||||
"scrollListeners": 1,
|
||||
"revealCount": 2,
|
||||
"revealOptions": {"block": "end", "inline": "nearest"},
|
||||
}
|
||||
|
||||
|
||||
def test_blur_restores_scroll_and_close_clears_geometry_without_duplicate_listeners():
|
||||
result = run_controller_scenario(
|
||||
"""
|
||||
controller.start();
|
||||
controller.start();
|
||||
panel.scrollTop = 240;
|
||||
document.activeElement = composer;
|
||||
composer.dispatch('focus');
|
||||
composer.dispatch('focus');
|
||||
document.activeElement = null;
|
||||
workspace.dispatch('focusout');
|
||||
flush();
|
||||
const afterBlur = {scrollTop:panel.scrollTop, geometry:{...panel.style.values}, resize:viewport.count('resize')};
|
||||
panel.scrollTop = 99;
|
||||
document.activeElement = composer;
|
||||
composer.dispatch('focus');
|
||||
controller.close(panel);
|
||||
flush();
|
||||
process.stdout.write(JSON.stringify({
|
||||
afterBlur,
|
||||
afterClose:{scrollTop:panel.scrollTop, geometry:panel.style.values, resize:viewport.count('resize'), scroll:viewport.count('scroll')},
|
||||
}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"afterBlur": {"scrollTop": 240, "geometry": {}, "resize": 0},
|
||||
"afterClose": {"scrollTop": 99, "geometry": {}, "resize": 0, "scroll": 0},
|
||||
}
|
||||
|
||||
|
||||
def test_desktop_and_missing_visual_viewport_are_noop():
|
||||
result = run_controller_scenario(
|
||||
"""
|
||||
mediaQuery.matches = false;
|
||||
controller.start();
|
||||
document.activeElement = composer;
|
||||
composer.dispatch('focus');
|
||||
process.stdout.write(JSON.stringify({geometry:panel.style.values, resize:viewport.count('resize'), reveals:workspace.reveals.length}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {"geometry": {}, "resize": 0, "reveals": 0}
|
||||
|
|
@ -97,7 +97,7 @@ async function dispatchNotificationClick(route) {{
|
|||
def test_share_target_sign_in_fix_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v35" in source
|
||||
assert "stackchain-dashboard-shell-v36" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
|
|
@ -106,14 +106,14 @@ def test_share_target_sign_in_fix_ships_in_a_new_shell_cache():
|
|||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v35" in source
|
||||
assert "stackchain-dashboard-shell-v36" in source
|
||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||
|
||||
|
||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v35" in source
|
||||
assert "stackchain-dashboard-shell-v36" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
|
|
@ -296,11 +296,13 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
|||
"/dashboard/static/pull-sheet.js",
|
||||
"/dashboard/static/review-sheet.js",
|
||||
"/dashboard/static/work-route.js",
|
||||
"/dashboard/static/task-overlay-history.js",
|
||||
"/dashboard/static/context-poller.js",
|
||||
"/dashboard/static/mobile-task-dock.js",
|
||||
"/dashboard/static/mobile-launch.js",
|
||||
"/dashboard/static/install-app.js",
|
||||
"/dashboard/static/mobile-search-viewport.js",
|
||||
"/dashboard/static/mobile-composer-viewport.js",
|
||||
"/dashboard/static/background-issue-sync.js",
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user