feat: add mobile issue detail navigation (Closes #961)
This commit is contained in:
parent
6954ffc050
commit
6c200d7c97
|
|
@ -469,6 +469,25 @@ 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-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 { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||||
.issue-sheet-header button { min-height:44px; }
|
.issue-sheet-header button { min-height:44px; }
|
||||||
|
.mobile-issue-detail-nav { display:none; }
|
||||||
|
@media (max-width:600px) {
|
||||||
|
.issue-sheet-panel { padding-top:max(12px,env(safe-area-inset-top)); }
|
||||||
|
.mobile-issue-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 {
|
||||||
|
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"] {
|
||||||
|
border-color:#60a5fa; background:#17365a; color:#fff;
|
||||||
|
}
|
||||||
|
#issue-overview, #issue-conversation, #issue-comment, #issue-planning { scroll-margin-top:72px; }
|
||||||
|
}
|
||||||
|
@media (min-width:601px) { .mobile-issue-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 { 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[hidden] { display:none; }
|
||||||
.completed-filed-actions button { min-height:44px; min-width:0; }
|
.completed-filed-actions button { min-height:44px; min-width:0; }
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,20 @@
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
mobileComposerViewport.start();
|
mobileComposerViewport.start();
|
||||||
|
const issueDetailPanel = qs('#issue-sheet .issue-sheet-panel');
|
||||||
|
const mobileIssueDetailNavigation = createMobileIssueDetailNavigation({
|
||||||
|
root:issueDetailPanel,
|
||||||
|
buttons:Object.fromEntries(Array.from(document.querySelectorAll('[data-issue-section]')).map(button => [button.dataset.issueSection, button])),
|
||||||
|
targets:{
|
||||||
|
overview:qs('#issue-overview'),
|
||||||
|
conversation:qs('#issue-conversation'),
|
||||||
|
reply:qs('#issue-comment'),
|
||||||
|
actions:qs('#issue-planning'),
|
||||||
|
},
|
||||||
|
planning:qs('#issue-planning'),
|
||||||
|
prefersReducedMotion:() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||||
|
});
|
||||||
|
mobileIssueDetailNavigation.start();
|
||||||
[
|
[
|
||||||
[qs('.app-menu'), qs('#app-menu-toggle')],
|
[qs('.app-menu'), qs('#app-menu-toggle')],
|
||||||
[qs('.work-settings'), qs('#work-settings-toggle')],
|
[qs('.work-settings'), qs('#work-settings-toggle')],
|
||||||
|
|
|
||||||
|
|
@ -634,6 +634,12 @@
|
||||||
</div>
|
</div>
|
||||||
<button id="close-issue-sheet" type="button">Close sheet</button>
|
<button id="close-issue-sheet" type="button">Close sheet</button>
|
||||||
</div>
|
</div>
|
||||||
|
<nav class="mobile-issue-detail-nav" aria-label="Issue sections">
|
||||||
|
<button type="button" data-issue-section="overview">Overview</button>
|
||||||
|
<button type="button" data-issue-section="conversation">Conversation</button>
|
||||||
|
<button type="button" data-issue-section="reply">Reply</button>
|
||||||
|
<button type="button" data-issue-section="actions">Plan & actions</button>
|
||||||
|
</nav>
|
||||||
<div id="issue-sheet-status" class="small" aria-live="polite">Choose an issue.</div>
|
<div id="issue-sheet-status" class="small" aria-live="polite">Choose an issue.</div>
|
||||||
<section class="completed-filed-actions" id="completed-filed-actions" aria-label="Completed Filed review" hidden>
|
<section class="completed-filed-actions" id="completed-filed-actions" aria-label="Completed Filed review" hidden>
|
||||||
<span id="completed-filed-progress" role="status" aria-live="polite">Completed Filed issue ready for review.</span>
|
<span id="completed-filed-progress" role="status" aria-live="polite">Completed Filed issue ready for review.</span>
|
||||||
|
|
@ -644,7 +650,7 @@
|
||||||
</section>
|
</section>
|
||||||
<button class="issue-retry" id="retry-issue-load" type="button" hidden>Reload latest issue</button>
|
<button class="issue-retry" id="retry-issue-load" type="button" hidden>Reload latest issue</button>
|
||||||
<div class="row"><span id="issue-labels"></span><span class="small" id="issue-assignees"></span></div>
|
<div class="row"><span id="issue-labels"></span><span class="small" id="issue-assignees"></span></div>
|
||||||
<div class="issue-sheet-content markdown-content" id="issue-sheet-body"></div>
|
<div id="issue-overview"><div class="issue-sheet-content markdown-content" id="issue-sheet-body"></div></div>
|
||||||
<form class="checklist-step-editor" id="checklist-step-editor" aria-label="Manage checklist step" hidden>
|
<form class="checklist-step-editor" id="checklist-step-editor" aria-label="Manage checklist step" hidden>
|
||||||
<label for="checklist-step-label">Checklist step</label>
|
<label for="checklist-step-label">Checklist step</label>
|
||||||
<input id="checklist-step-label" type="text" maxlength="240" autocomplete="off" />
|
<input id="checklist-step-label" type="text" maxlength="240" autocomplete="off" />
|
||||||
|
|
@ -688,6 +694,7 @@
|
||||||
<button id="cancel-issue-blocker" type="button">Cancel</button>
|
<button id="cancel-issue-blocker" type="button">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<div id="issue-conversation" tabindex="-1"></div>
|
||||||
<h2>Full conversation</h2>
|
<h2>Full conversation</h2>
|
||||||
<div id="issue-comments"></div>
|
<div id="issue-comments"></div>
|
||||||
<button class="conversation-more" id="load-older-issue-comments" type="button" hidden>Load older messages</button>
|
<button class="conversation-more" id="load-older-issue-comments" type="button" hidden>Load older messages</button>
|
||||||
|
|
@ -1529,6 +1536,7 @@
|
||||||
<script src="static/mention-composer.js"></script>
|
<script src="static/mention-composer.js"></script>
|
||||||
<script src="static/push-notifications.js"></script>
|
<script src="static/push-notifications.js"></script>
|
||||||
<script src="static/issue-filing-receipt.js"></script>
|
<script src="static/issue-filing-receipt.js"></script>
|
||||||
|
<script src="static/mobile-issue-detail-nav.js"></script>
|
||||||
<script src="static/dashboard.js"></script>
|
<script src="static/dashboard.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
70
frontend/mobile-issue-detail-nav.js
Normal file
70
frontend/mobile-issue-detail-nav.js
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
function createMobileIssueDetailNavigation(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) {
|
||||||
|
const target = targets[name];
|
||||||
|
if (!target) return false;
|
||||||
|
if (name === 'actions' && options.planning) options.planning.open = true;
|
||||||
|
target.scrollIntoView({
|
||||||
|
block: 'start',
|
||||||
|
behavior: prefersReducedMotion() ? 'auto' : 'smooth',
|
||||||
|
});
|
||||||
|
if (name === 'reply') target.focus();
|
||||||
|
select(name);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
select('overview');
|
||||||
|
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,
|
||||||
|
select,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined') module.exports = createMobileIssueDetailNavigation;
|
||||||
|
|
@ -75,6 +75,7 @@ const SHELL = [
|
||||||
BASE + 'static/issue-attachment.js',
|
BASE + 'static/issue-attachment.js',
|
||||||
BASE + 'static/issue-filing-review.js',
|
BASE + 'static/issue-filing-review.js',
|
||||||
BASE + 'static/issue-sheet.js',
|
BASE + 'static/issue-sheet.js',
|
||||||
|
BASE + 'static/mobile-issue-detail-nav.js',
|
||||||
BASE + 'static/checklist-conflict.js',
|
BASE + 'static/checklist-conflict.js',
|
||||||
BASE + 'static/voice-transcript-store.js',
|
BASE + 'static/voice-transcript-store.js',
|
||||||
BASE + 'static/voice-issue-capture.js',
|
BASE + 'static/voice-issue-capture.js',
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ FEATURE_SOURCES = {
|
||||||
"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",
|
||||||
"static/search-batch-plan.js", "static/issue-evidence-review.js", "static/issue-evidence-editor.js",
|
"static/search-batch-plan.js", "static/issue-evidence-review.js", "static/issue-evidence-editor.js",
|
||||||
"static/issue-attachment.js", "static/checklist-conflict.js", "static/issue-outbox.js", "static/authored-outbox.js", "static/issue-sheet.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js",
|
"static/issue-attachment.js", "static/checklist-conflict.js", "static/issue-outbox.js", "static/authored-outbox.js", "static/issue-sheet.js", "static/mobile-issue-detail-nav.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js",
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
CACHE_DECLARATION = re.compile(
|
CACHE_DECLARATION = re.compile(
|
||||||
|
|
|
||||||
146
tests/test_mobile_issue_detail_navigation.py
Normal file
146
tests/test_mobile_issue_detail_navigation.py
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
CONTROLLER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-issue-detail-nav.js"
|
||||||
|
FRONTEND = CONTROLLER.parent
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_issue_navigation_opens_and_focuses_each_destination_without_forced_motion():
|
||||||
|
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]; }}
|
||||||
|
click() {{ this.listeners.click({{currentTarget:this, preventDefault() {{}}}}); }}
|
||||||
|
setAttribute(name, value) {{ this.attributes[name] = value; }}
|
||||||
|
removeAttribute(name) {{ delete this.attributes[name]; }}
|
||||||
|
focus() {{ this.focuses += 1; }}
|
||||||
|
scrollIntoView(options) {{ this.scrolls.push(options); }}
|
||||||
|
}}
|
||||||
|
const buttons = Object.fromEntries(['overview','conversation','reply','actions'].map(name => [name,new FakeElement(name)]));
|
||||||
|
const targets = Object.fromEntries(['overview','conversation','reply','actions'].map(name => [name,new FakeElement(name)]));
|
||||||
|
const navigation = createNavigation({{
|
||||||
|
buttons,
|
||||||
|
targets,
|
||||||
|
planning:targets.actions,
|
||||||
|
prefersReducedMotion:() => true,
|
||||||
|
}});
|
||||||
|
navigation.start();
|
||||||
|
buttons.overview.click();
|
||||||
|
buttons.conversation.click();
|
||||||
|
buttons.reply.click();
|
||||||
|
buttons.actions.click();
|
||||||
|
const currentAfterActions = Object.fromEntries(Object.entries(buttons).map(([name,button]) => [name,button.attributes['aria-current'] || null]));
|
||||||
|
navigation.stop();
|
||||||
|
process.stdout.write(JSON.stringify({{
|
||||||
|
overview:targets.overview.scrolls,
|
||||||
|
conversation:targets.conversation.scrolls,
|
||||||
|
reply:targets.reply.scrolls,
|
||||||
|
replyFocuses:targets.reply.focuses,
|
||||||
|
actions:targets.actions.scrolls,
|
||||||
|
planningOpen:targets.actions.open,
|
||||||
|
currentAfterActions,
|
||||||
|
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) == {
|
||||||
|
"overview": [{"block": "start", "behavior": "auto"}],
|
||||||
|
"conversation": [{"block": "start", "behavior": "auto"}],
|
||||||
|
"reply": [{"block": "start", "behavior": "auto"}],
|
||||||
|
"replyFocuses": 1,
|
||||||
|
"actions": [{"block": "start", "behavior": "auto"}],
|
||||||
|
"planningOpen": True,
|
||||||
|
"currentAfterActions": {
|
||||||
|
"overview": None,
|
||||||
|
"conversation": None,
|
||||||
|
"reply": None,
|
||||||
|
"actions": "location",
|
||||||
|
},
|
||||||
|
"listenersAfterStop": [0, 0, 0, 0],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_issue_navigation_tracks_the_visible_section_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(['overview','conversation','reply','actions'].map(name => [name,makeElement(name)]));
|
||||||
|
const targets = Object.fromEntries(['overview','conversation','reply','actions'].map(name => [name,makeElement(name)]));
|
||||||
|
let callback;
|
||||||
|
let disconnected = 0;
|
||||||
|
const navigation = createNavigation({{
|
||||||
|
buttons, targets,
|
||||||
|
observe(handler, observed) {{
|
||||||
|
callback = handler;
|
||||||
|
return {{disconnect() {{ disconnected += 1; }}}};
|
||||||
|
}},
|
||||||
|
}});
|
||||||
|
navigation.start();
|
||||||
|
callback([
|
||||||
|
{{target:targets.overview,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": {
|
||||||
|
"overview": None,
|
||||||
|
"conversation": None,
|
||||||
|
"reply": "location",
|
||||||
|
"actions": None,
|
||||||
|
},
|
||||||
|
"disconnected": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_issue_sheet_ships_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()
|
||||||
|
|
||||||
|
assert '<nav class="mobile-issue-detail-nav"' in html
|
||||||
|
assert 'aria-label="Issue sections"' in html
|
||||||
|
for name, label in (
|
||||||
|
("overview", "Overview"),
|
||||||
|
("conversation", "Conversation"),
|
||||||
|
("reply", "Reply"),
|
||||||
|
("actions", "Plan & actions"),
|
||||||
|
):
|
||||||
|
assert f'data-issue-section="{name}"' in html
|
||||||
|
assert f">{label}</button>" in html
|
||||||
|
assert 'id="issue-overview"' in html
|
||||||
|
assert 'id="issue-conversation"' in html
|
||||||
|
assert 'id="issue-comment"' in html
|
||||||
|
assert 'id="issue-planning"' in html
|
||||||
|
assert '<script src="static/mobile-issue-detail-nav.js"></script>' in html
|
||||||
|
assert "createMobileIssueDetailNavigation({" in dashboard_js
|
||||||
|
assert "window.matchMedia('(prefers-reduced-motion: reduce)').matches" in dashboard_js
|
||||||
|
assert "@media (max-width:600px)" in css
|
||||||
|
assert ".mobile-issue-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
|
||||||
|
|
@ -994,6 +994,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||||
"/dashboard/static/issue-attachment.js",
|
"/dashboard/static/issue-attachment.js",
|
||||||
"/dashboard/static/issue-filing-review.js",
|
"/dashboard/static/issue-filing-review.js",
|
||||||
"/dashboard/static/issue-sheet.js",
|
"/dashboard/static/issue-sheet.js",
|
||||||
|
"/dashboard/static/mobile-issue-detail-nav.js",
|
||||||
"/dashboard/static/checklist-conflict.js",
|
"/dashboard/static/checklist-conflict.js",
|
||||||
"/dashboard/static/voice-transcript-store.js",
|
"/dashboard/static/voice-transcript-store.js",
|
||||||
"/dashboard/static/voice-issue-capture.js",
|
"/dashboard/static/voice-issue-capture.js",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user