feat: add mobile update workspace navigation (Closes #967)
This commit is contained in:
parent
3c86e02efe
commit
6c7b6ebd4d
|
|
@ -469,26 +469,28 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.issue-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.issue-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.issue-sheet-header button { min-height:44px; }
|
||||
.mobile-issue-detail-nav, .mobile-pull-detail-nav { display:none; }
|
||||
.mobile-issue-detail-nav, .mobile-pull-detail-nav, .mobile-update-detail-nav { display:none; }
|
||||
@media (max-width:600px) {
|
||||
.issue-sheet-panel, .pull-sheet-panel { padding-top:max(12px,env(safe-area-inset-top)); }
|
||||
.mobile-issue-detail-nav, .mobile-pull-detail-nav {
|
||||
.issue-sheet-panel, .pull-sheet-panel, .update-sheet-panel { padding-top:max(12px,env(safe-area-inset-top)); }
|
||||
.mobile-issue-detail-nav, .mobile-pull-detail-nav, .mobile-update-detail-nav {
|
||||
position:sticky; top:env(safe-area-inset-top); z-index:6;
|
||||
display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:4px;
|
||||
margin:8px -6px 12px; padding:6px;
|
||||
background:rgba(11,21,38,.98); border-block:1px solid #2a496e;
|
||||
}
|
||||
.mobile-issue-detail-nav button, .mobile-pull-detail-nav button {
|
||||
.mobile-issue-detail-nav button, .mobile-pull-detail-nav button, .mobile-update-detail-nav button {
|
||||
min-width:0; min-height:44px; padding:4px; overflow-wrap:anywhere;
|
||||
border-color:transparent; font-size:12px;
|
||||
}
|
||||
.mobile-issue-detail-nav button[aria-current="location"], .mobile-pull-detail-nav button[aria-current="location"] {
|
||||
.mobile-issue-detail-nav button[aria-current="location"], .mobile-pull-detail-nav button[aria-current="location"], .mobile-update-detail-nav button[aria-current="location"] {
|
||||
border-color:#60a5fa; background:#17365a; color:#fff;
|
||||
}
|
||||
#issue-overview, #issue-conversation, #issue-comment, #issue-planning { scroll-margin-top:72px; }
|
||||
#pull-overview, #pull-conversation, #pull-comment, #pull-review { scroll-margin-top:72px; }
|
||||
#update-conversation, #update-subject-context, #update-reply-workspace { scroll-margin-top:72px; }
|
||||
}
|
||||
@media (min-width:601px) { .mobile-issue-detail-nav, .mobile-pull-detail-nav { display:none; } }
|
||||
@media (min-width:601px) { .mobile-update-detail-nav { display:none; } }
|
||||
.completed-filed-actions { position:fixed; right:0; bottom:0; z-index:57; box-sizing:border-box; width:min(560px,100%); display:grid; grid-template-columns:minmax(0,1fr); align-items:center; gap:8px; margin:0; padding:10px 12px calc(10px + env(safe-area-inset-bottom)); border:1px solid #4ade80; border-radius:12px 0 0; background:rgba(11,21,38,.98); overflow-wrap:anywhere; }
|
||||
.completed-filed-actions[hidden] { display:none; }
|
||||
.completed-filed-actions button { min-height:44px; min-width:0; }
|
||||
|
|
|
|||
|
|
@ -1353,6 +1353,19 @@
|
|||
panel: qs('#update-sheet .update-sheet-panel'),
|
||||
jump: qs('#jump-update-new-activity'),
|
||||
});
|
||||
const mobileUpdateDetailNavigation = createMobileUpdateDetailNavigation({
|
||||
root:qs('#update-sheet .update-sheet-panel'),
|
||||
buttons:Object.fromEntries(Array.from(document.querySelectorAll('[data-update-section]')).map(button => [button.dataset.updateSection, button])),
|
||||
targets:{
|
||||
conversation:qs('#update-conversation'),
|
||||
context:qs('#update-subject-context'),
|
||||
reply:qs('#update-reply-workspace'),
|
||||
},
|
||||
replyComposer:qs('#update-reply'),
|
||||
jumpToNewActivity:() => updateReadPosition.jump(),
|
||||
prefersReducedMotion:() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||
});
|
||||
mobileUpdateDetailNavigation.start();
|
||||
const issueDetailPosition = createWorkDetailPosition({ panel: qs('#issue-sheet .issue-sheet-panel') });
|
||||
const pullDetailPosition = createWorkDetailPosition({ panel: qs('#pull-sheet .pull-sheet-panel') });
|
||||
const reviewDetailPosition = createWorkDetailPosition({ panel: qs('#review-sheet .review-sheet-panel') });
|
||||
|
|
@ -1391,6 +1404,7 @@
|
|||
void updateVoiceReply.open('update:' + item.notification_id);
|
||||
selectedUpdateDetail = null;
|
||||
updateReadPosition.open(String(item.notification_id));
|
||||
mobileUpdateDetailNavigation.reset();
|
||||
updateMentions.dismiss();
|
||||
qs('#update-sheet').classList.add('open');
|
||||
qs('.update-more-actions').open = false;
|
||||
|
|
|
|||
|
|
@ -1098,6 +1098,12 @@
|
|||
</div>
|
||||
</div>
|
||||
<div id="update-triage-progress" class="update-triage-progress small" aria-live="polite" hidden></div>
|
||||
<nav class="mobile-update-detail-nav" aria-label="Update sections">
|
||||
<button type="button" data-update-section="activity">New activity</button>
|
||||
<button type="button" data-update-section="conversation">Conversation</button>
|
||||
<button type="button" data-update-section="context">Subject context</button>
|
||||
<button type="button" data-update-section="reply">Reply</button>
|
||||
</nav>
|
||||
<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>
|
||||
<button class="update-retry" id="retry-update-load" type="button" hidden>Retry loading update</button>
|
||||
|
|
@ -1105,18 +1111,20 @@
|
|||
<span class="pill" id="update-subject-type">Update</span>
|
||||
<span class="pill" id="update-subject-state"></span>
|
||||
</div>
|
||||
<h2>Full conversation</h2>
|
||||
<div id="update-comments"></div>
|
||||
<button class="update-new-activity-jump" id="jump-update-new-activity" type="button" hidden>Jump to new activity</button>
|
||||
<button class="conversation-more" id="load-older-update-comments" type="button" hidden>Load older messages</button>
|
||||
<div id="update-conversation-status" class="small" aria-live="assertive"></div>
|
||||
<button class="update-retry" id="retry-update-conversation" type="button" hidden>Retry conversation</button>
|
||||
<button class="conversation-actions-retry" id="retry-update-comment-actions" type="button" hidden>Retry comment tools</button>
|
||||
<details>
|
||||
<section id="update-conversation" tabindex="-1">
|
||||
<h2>Full conversation</h2>
|
||||
<div id="update-comments"></div>
|
||||
<button class="update-new-activity-jump" id="jump-update-new-activity" type="button" hidden>Jump to new activity</button>
|
||||
<button class="conversation-more" id="load-older-update-comments" type="button" hidden>Load older messages</button>
|
||||
<div id="update-conversation-status" class="small" aria-live="assertive"></div>
|
||||
<button class="update-retry" id="retry-update-conversation" type="button" hidden>Retry conversation</button>
|
||||
<button class="conversation-actions-retry" id="retry-update-comment-actions" type="button" hidden>Retry comment tools</button>
|
||||
</section>
|
||||
<details id="update-subject-context">
|
||||
<summary><h2>Subject context</h2></summary>
|
||||
<div class="update-sheet-content muted markdown-content" id="update-subject-body"></div>
|
||||
</details>
|
||||
<section class="update-reply" aria-labelledby="update-reply-title">
|
||||
<section class="update-reply" id="update-reply-workspace" aria-labelledby="update-reply-title">
|
||||
<h2 id="update-reply-title">Reply</h2>
|
||||
<label for="update-reply" class="small">Continue this issue or pull-request conversation</label>
|
||||
<textarea id="update-reply" maxlength="10000" placeholder="Write a reply"></textarea>
|
||||
|
|
@ -1549,6 +1557,7 @@
|
|||
<script src="static/push-notifications.js"></script>
|
||||
<script src="static/issue-filing-receipt.js"></script>
|
||||
<script src="static/mobile-issue-detail-nav.js"></script>
|
||||
<script src="static/mobile-update-detail-nav.js"></script>
|
||||
<script src="static/dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
83
frontend/mobile-update-detail-nav.js
Normal file
83
frontend/mobile-update-detail-nav.js
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
function createMobileUpdateDetailNavigation(options) {
|
||||
const buttons = options.buttons || {};
|
||||
const targets = options.targets || {};
|
||||
const listeners = new Map();
|
||||
const prefersReducedMotion = options.prefersReducedMotion || (() => false);
|
||||
const targetNames = new Map(Object.entries(targets).map(([name, target]) => [target, name]));
|
||||
let observer = null;
|
||||
|
||||
function select(name) {
|
||||
Object.entries(buttons).forEach(([key, button]) => {
|
||||
if (!button) return;
|
||||
if (key === name) button.setAttribute('aria-current', 'location');
|
||||
else button.removeAttribute('aria-current');
|
||||
});
|
||||
}
|
||||
|
||||
function navigate(name) {
|
||||
if (name === 'activity') {
|
||||
if (options.jumpToNewActivity) options.jumpToNewActivity();
|
||||
select(name);
|
||||
return true;
|
||||
}
|
||||
const target = targets[name];
|
||||
if (!target) return false;
|
||||
if (name === 'context') target.open = true;
|
||||
target.scrollIntoView({
|
||||
block:'start',
|
||||
behavior:prefersReducedMotion() ? 'auto' : 'smooth',
|
||||
});
|
||||
if (name === 'reply' && options.replyComposer) {
|
||||
options.replyComposer.focus({ preventScroll:true });
|
||||
}
|
||||
select(name);
|
||||
return true;
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (targets.context) targets.context.open = false;
|
||||
select('activity');
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
Object.entries(buttons).forEach(([name, button]) => {
|
||||
if (!button || listeners.has(button)) return;
|
||||
const listener = event => {
|
||||
event.preventDefault();
|
||||
navigate(name);
|
||||
};
|
||||
listeners.set(button, listener);
|
||||
button.addEventListener('click', listener);
|
||||
});
|
||||
reset();
|
||||
const observe = options.observe || ((handler, observedTargets) => {
|
||||
if (typeof IntersectionObserver === 'undefined') return null;
|
||||
const instance = new IntersectionObserver(handler, {
|
||||
root:options.root || null,
|
||||
rootMargin:'-20% 0px -60% 0px',
|
||||
threshold:[0, 0.25, 0.5, 0.75, 1],
|
||||
});
|
||||
observedTargets.forEach(target => instance.observe(target));
|
||||
return instance;
|
||||
});
|
||||
observer = observe(entries => {
|
||||
const visible = entries
|
||||
.filter(entry => entry.isIntersecting && targetNames.has(entry.target))
|
||||
.sort((left, right) => right.intersectionRatio - left.intersectionRatio)[0];
|
||||
if (visible) select(targetNames.get(visible.target));
|
||||
}, Array.from(targetNames.keys()).filter(Boolean));
|
||||
},
|
||||
stop() {
|
||||
listeners.forEach((listener, button) => button.removeEventListener('click', listener));
|
||||
listeners.clear();
|
||||
if (observer) observer.disconnect();
|
||||
observer = null;
|
||||
},
|
||||
navigate,
|
||||
reset,
|
||||
select,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined') module.exports = createMobileUpdateDetailNavigation;
|
||||
|
|
@ -78,6 +78,7 @@ const SHELL = [
|
|||
BASE + 'static/issue-filing-review.js',
|
||||
BASE + 'static/issue-sheet.js',
|
||||
BASE + 'static/mobile-issue-detail-nav.js',
|
||||
BASE + 'static/mobile-update-detail-nav.js',
|
||||
BASE + 'static/checklist-conflict.js',
|
||||
BASE + 'static/voice-transcript-store.js',
|
||||
BASE + 'static/voice-issue-capture.js',
|
||||
|
|
|
|||
153
tests/test_mobile_update_detail_navigation.py
Normal file
153
tests/test_mobile_update_detail_navigation.py
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[1] / "frontend"
|
||||
CONTROLLER = FRONTEND / "mobile-update-detail-nav.js"
|
||||
|
||||
|
||||
def test_update_navigation_reuses_new_activity_opens_context_and_focuses_reply():
|
||||
script = f"""
|
||||
const createNavigation = require({json.dumps(str(CONTROLLER))});
|
||||
class FakeElement {{
|
||||
constructor(name) {{
|
||||
this.name=name; this.listeners={{}}; this.attributes={{}}; this.open=false;
|
||||
this.focuses=0; this.scrolls=[];
|
||||
}}
|
||||
addEventListener(name, callback) {{ this.listeners[name]=callback; }}
|
||||
removeEventListener(name) {{ delete this.listeners[name]; }}
|
||||
setAttribute(name, value) {{ this.attributes[name]=value; }}
|
||||
removeAttribute(name) {{ delete this.attributes[name]; }}
|
||||
focus() {{ this.focuses += 1; }}
|
||||
scrollIntoView(options) {{ this.scrolls.push({{...options, openWhenScrolled:this.open}}); }}
|
||||
}}
|
||||
const names=['activity','conversation','context','reply'];
|
||||
const buttons=Object.fromEntries(names.map(name => [name,new FakeElement(name)]));
|
||||
const targets=Object.fromEntries(['conversation','context','reply'].map(name => [name,new FakeElement(name)]));
|
||||
const replyComposer=new FakeElement('composer');
|
||||
let activityJumps=0;
|
||||
const navigation=createNavigation({{
|
||||
buttons, targets, replyComposer,
|
||||
jumpToNewActivity() {{ activityJumps += 1; }},
|
||||
prefersReducedMotion:() => true,
|
||||
}});
|
||||
navigation.start();
|
||||
navigation.navigate('conversation');
|
||||
navigation.navigate('context');
|
||||
const contextOpenAfterNavigation=targets.context.open;
|
||||
navigation.navigate('reply');
|
||||
navigation.navigate('activity');
|
||||
const currentAfterActivity=Object.fromEntries(Object.entries(buttons).map(([name,button]) => [name,button.attributes['aria-current'] || null]));
|
||||
navigation.reset();
|
||||
const contextOpenAfterReset=targets.context.open;
|
||||
const currentAfterReset=Object.fromEntries(Object.entries(buttons).map(([name,button]) => [name,button.attributes['aria-current'] || null]));
|
||||
navigation.stop();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
activityJumps,
|
||||
conversation:targets.conversation.scrolls,
|
||||
context:targets.context.scrolls,
|
||||
contextOpen:contextOpenAfterNavigation,
|
||||
contextOpenAfterReset,
|
||||
reply:targets.reply.scrolls,
|
||||
replyFocuses:replyComposer.focuses,
|
||||
currentAfterActivity,
|
||||
currentAfterReset,
|
||||
listenersAfterStop:Object.values(buttons).map(button => Object.keys(button.listeners).length),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"activityJumps": 1,
|
||||
"conversation": [{"block": "start", "behavior": "auto", "openWhenScrolled": False}],
|
||||
"context": [{"block": "start", "behavior": "auto", "openWhenScrolled": True}],
|
||||
"contextOpen": True,
|
||||
"contextOpenAfterReset": False,
|
||||
"reply": [{"block": "start", "behavior": "auto", "openWhenScrolled": False}],
|
||||
"replyFocuses": 1,
|
||||
"currentAfterActivity": {
|
||||
"activity": "location",
|
||||
"conversation": None,
|
||||
"context": None,
|
||||
"reply": None,
|
||||
},
|
||||
"currentAfterReset": {
|
||||
"activity": "location",
|
||||
"conversation": None,
|
||||
"context": None,
|
||||
"reply": None,
|
||||
},
|
||||
"listenersAfterStop": [0, 0, 0, 0],
|
||||
}
|
||||
|
||||
|
||||
def test_update_navigation_tracks_visible_sections_and_disconnects_observer():
|
||||
script = f"""
|
||||
const createNavigation = require({json.dumps(str(CONTROLLER))});
|
||||
const makeElement=name => ({{
|
||||
name, attributes:{{}},
|
||||
addEventListener() {{}}, removeEventListener() {{}},
|
||||
setAttribute(key,value) {{ this.attributes[key]=value; }},
|
||||
removeAttribute(key) {{ delete this.attributes[key]; }},
|
||||
}});
|
||||
const buttons=Object.fromEntries(['activity','conversation','context','reply'].map(name => [name,makeElement(name)]));
|
||||
const targets=Object.fromEntries(['conversation','context','reply'].map(name => [name,makeElement(name)]));
|
||||
let callback;
|
||||
let disconnected=0;
|
||||
const navigation=createNavigation({{
|
||||
buttons, targets,
|
||||
observe(handler) {{ callback=handler; return {{disconnect() {{ disconnected += 1; }}}}; }},
|
||||
}});
|
||||
navigation.start();
|
||||
callback([
|
||||
{{target:targets.conversation,isIntersecting:true,intersectionRatio:.2}},
|
||||
{{target:targets.reply,isIntersecting:true,intersectionRatio:.8}},
|
||||
]);
|
||||
const current=Object.fromEntries(Object.entries(buttons).map(([name,button]) => [name,button.attributes['aria-current'] || null]));
|
||||
navigation.stop();
|
||||
process.stdout.write(JSON.stringify({{current,disconnected}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"current": {
|
||||
"activity": None,
|
||||
"conversation": None,
|
||||
"context": None,
|
||||
"reply": "location",
|
||||
},
|
||||
"disconnected": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_update_sheet_wires_a_mobile_only_safe_area_navigation_rail():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
dashboard_js = (FRONTEND / "dashboard.js").read_text()
|
||||
service_worker = (FRONTEND / "service-worker.js").read_text()
|
||||
|
||||
assert '<nav class="mobile-update-detail-nav" aria-label="Update sections">' in html
|
||||
for name, label in (
|
||||
("activity", "New activity"),
|
||||
("conversation", "Conversation"),
|
||||
("context", "Subject context"),
|
||||
("reply", "Reply"),
|
||||
):
|
||||
assert f'data-update-section="{name}"' in html
|
||||
assert f">{label}</button>" in html
|
||||
assert 'id="update-conversation"' in html
|
||||
assert 'id="update-subject-context"' in html
|
||||
assert 'id="update-reply-workspace"' in html
|
||||
assert '<script src="static/mobile-update-detail-nav.js"></script>' in html
|
||||
assert "createMobileUpdateDetailNavigation({" in dashboard_js
|
||||
assert "jumpToNewActivity:() => updateReadPosition.jump()" in dashboard_js
|
||||
assert "mobileUpdateDetailNavigation.reset();" in dashboard_js
|
||||
assert ".mobile-update-detail-nav" in css
|
||||
assert "position:sticky" in css
|
||||
assert "min-height:44px" in css
|
||||
assert "env(safe-area-inset-top)" in css
|
||||
assert "@media (min-width:601px)" in css
|
||||
assert "BASE + 'static/mobile-update-detail-nav.js'" in service_worker
|
||||
|
|
@ -999,6 +999,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
|||
"/dashboard/static/issue-filing-review.js",
|
||||
"/dashboard/static/issue-sheet.js",
|
||||
"/dashboard/static/mobile-issue-detail-nav.js",
|
||||
"/dashboard/static/mobile-update-detail-nav.js",
|
||||
"/dashboard/static/checklist-conflict.js",
|
||||
"/dashboard/static/voice-transcript-store.js",
|
||||
"/dashboard/static/voice-issue-capture.js",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user