From be10dba4bdc1646de1da6fcccbfc8103491084a2 Mon Sep 17 00:00:00 2001 From: timmy Date: Mon, 17 Aug 2026 22:55:04 +0000 Subject: [PATCH] feat: contain mobile issue capture focus (Closes #1046) --- frontend/create-issue-sheet.js | 52 +++++++++ frontend/dashboard.js | 22 +++- .../e2e/test_mobile_offline_issue_release.py | 12 ++- tests/test_create_issue_modal.py | 101 ++++++++++++++++++ 4 files changed, 185 insertions(+), 2 deletions(-) create mode 100644 tests/test_create_issue_modal.py diff --git a/frontend/create-issue-sheet.js b/frontend/create-issue-sheet.js index a88f391..c490ef1 100644 --- a/frontend/create-issue-sheet.js +++ b/frontend/create-issue-sheet.js @@ -8,6 +8,57 @@ function newIssueOperationId() { return String(Date.now()) + '-' + Math.random().toString(16).slice(2); } +function createIssueModalLifecycle({ root, background = [], document, requestClose }) { + let launcher = null; + let active = false; + let backgroundState = []; + const focusableSelector = [ + 'a[href]', 'button:not([disabled])', 'input:not([disabled])', + 'select:not([disabled])', 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', + ].join(','); + const focusable = () => Array.from(root.querySelectorAll(focusableSelector)).filter(element => + !element.hidden && !element.disabled && element.getClientRects().length > 0 + ); + function keydown(event) { + if (!active) return; + if (event.key === 'Escape') { + event.preventDefault(); + requestClose(); + return; + } + if (event.key !== 'Tab') return; + const controls = focusable(); + if (!controls.length) return; + const first = controls[0]; + const last = controls[controls.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + } + root.addEventListener('keydown', keydown); + return { + open(trigger = null) { + launcher = trigger?.isConnected ? trigger : document.activeElement; + backgroundState = background.map(element => [element, element.inert]); + backgroundState.forEach(([element]) => { element.inert = true; }); + active = true; + (root.querySelector('#cancel-new-issue') || focusable()[0])?.focus(); + }, + close({ restore = true } = {}) { + active = false; + backgroundState.forEach(([element, inert]) => { element.inert = inert; }); + backgroundState = []; + if (restore && launcher?.isConnected) launcher.focus(); + launcher = null; + }, + }; +} + function normalizeSharedContent(value = {}) { const clean = input => String(input || '').replace(/\s+/g, ' ').trim(); const text = String(value.text || '').trim().slice(0, 9500); @@ -840,5 +891,6 @@ createIssueCapture.buildRelatedDraft = buildRelatedDraft; createIssueCapture.relatedChecklistDraft = relatedChecklistDraft; createIssueCapture.linkChecklistTask = linkChecklistTask; createIssueCapture.createChecklistPromotion = createChecklistPromotion; +createIssueCapture.createModalLifecycle = createIssueModalLifecycle; if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture; diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 1f4be91..42dad75 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -4692,7 +4692,25 @@ sharedLaunchState = null; } + let createIssueModalLifecycle = null; + let createIssueLauncher = null; + function issueCaptureModal() { + if (createIssueModalLifecycle) return createIssueModalLifecycle; + const root = qs('#create-issue-sheet'); + const background = Array.from(document.body.children).filter(element => + element !== root && element.tagName !== 'SCRIPT' + ); + createIssueModalLifecycle = createIssueCapture.createModalLifecycle({ + root, background, document, requestClose:() => closeCreateIssueSheet(), + }); + return createIssueModalLifecycle; + } + async function openCreateIssueSheet(navigate = true) { + const root = qs('#create-issue-sheet'); + if (!root.classList.contains('open') && document.activeElement && !root.contains(document.activeElement)) { + createIssueLauncher = document.activeElement; + } if (!issueCapture && !await ensureIssueCapture()) return; if (navigate) { taskOverlayHistory.open('new'); @@ -4751,6 +4769,7 @@ updateIssueCreateActions(); qs('#create-issue-sheet').classList.add('open'); creatingIssue = true; + issueCaptureModal().open(createIssueLauncher); if (!sharedImageHandled && sharedImageMarker) { sharedImageHandled = true; if (createIssueAttachmentController.state()) { @@ -4816,13 +4835,14 @@ clearTimeout(duplicateCheckTimer); qs('#create-issue-duplicates').hidden = true; creatingIssue = false; + issueCaptureModal().close({restore:!followUpSourceUpdate}); + createIssueLauncher = null; if (followUpSourceUpdate) { const source = followUpSourceUpdate; followUpSourceUpdate = null; notificationReader.open(source.item, source.detail); return; } - qs('#new-issue').focus(); } function applyOutboxResult(result, openCreated = false, startCreated = false) { diff --git a/tests/e2e/test_mobile_offline_issue_release.py b/tests/e2e/test_mobile_offline_issue_release.py index 5c18145..c1c1cba 100644 --- a/tests/e2e/test_mobile_offline_issue_release.py +++ b/tests/e2e/test_mobile_offline_issue_release.py @@ -187,7 +187,17 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo new_action.click() expect(page.locator("#create-issue-sheet")).to_have_class("create-issue-sheet open") - assert page.evaluate("document.activeElement?.id") != "create-issue-title" + assert page.evaluate("document.activeElement?.id") == "cancel-new-issue" + assert page.evaluate("document.querySelector('main').inert") is True + page.keyboard.press("Shift+Tab") + assert page.evaluate("document.activeElement.closest('#create-issue-sheet')?.id") == "create-issue-sheet" + page.keyboard.press("Escape") + expect(page.locator("#create-issue-sheet")).not_to_have_class("create-issue-sheet open") + assert page.evaluate("document.activeElement?.dataset.mobileTask") == "new" + assert page.evaluate("document.querySelector('main').inert") is False + new_action.click() + expect(page.locator("#create-issue-sheet")).to_have_class("create-issue-sheet open") + assert page.evaluate("document.activeElement?.id") == "cancel-new-issue" create_navigation = page.locator(".mobile-create-issue-nav") expect(create_navigation).to_be_visible() create_navigation_buttons = create_navigation.locator("button") diff --git a/tests/test_create_issue_modal.py b/tests/test_create_issue_modal.py new file mode 100644 index 0000000..6559ddd --- /dev/null +++ b/tests/test_create_issue_modal.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + + +MODULE = Path(__file__).parents[1] / "frontend" / "create-issue-sheet.js" + + +def run_node(script: str) -> dict: + result = subprocess.run( + ["node", "-e", script], + check=True, + capture_output=True, + text=True, + ) + return json.loads(result.stdout) + + +def test_issue_capture_modal_inerts_background_focuses_cancel_and_restores_exact_launcher(): + result = run_node( + f""" +const createIssueCapture = require({json.dumps(str(MODULE))}); +function element(id) {{ + return {{id, inert:false, hidden:false, disabled:false, isConnected:true, + focus() {{ document.activeElement = this; }}, + getClientRects() {{ return [{{}}]; }} }}; +}} +const launcher = element('mobile-new'); +const cancel = element('cancel-new-issue'); +const title = element('create-issue-title'); +const background = [element('header'), element('main'), element('mobile-task-dock')]; +const root = {{ + querySelector: selector => selector === '#cancel-new-issue' ? cancel : null, + querySelectorAll: () => [cancel, title], + addEventListener() {{}}, removeEventListener() {{}}, +}}; +const document = {{activeElement: launcher}}; +const lifecycle = createIssueCapture.createModalLifecycle({{ + root, background, document, requestClose() {{}}, +}}); +lifecycle.open(launcher); +const opened = {{active:document.activeElement.id, inert:background.map(item => item.inert)}}; +lifecycle.close(); +process.stdout.write(JSON.stringify({{ + opened, closed:{{active:document.activeElement.id, inert:background.map(item => item.inert)}} +}})); +""" + ) + + assert result == { + "opened": {"active": "cancel-new-issue", "inert": [True, True, True]}, + "closed": {"active": "mobile-new", "inert": [False, False, False]}, + } + + +def test_issue_capture_modal_wraps_visible_focus_and_escape_uses_close_request(): + result = run_node( + f""" +const createIssueCapture = require({json.dumps(str(MODULE))}); +function element(id, visible=true) {{ + return {{id, inert:false, hidden:!visible, disabled:false, isConnected:true, + focus() {{ document.activeElement = this; }}, + getClientRects() {{ return visible ? [{{}}] : []; }} }}; +}} +const launcher = element('empty-work-create'); +const cancel = element('cancel-new-issue'); +const hidden = element('hidden-control', false); +const submit = element('submit-new-issue'); +const listeners = {{}}; +const root = {{ + querySelector: selector => selector === '#cancel-new-issue' ? cancel : null, + querySelectorAll: () => [cancel, hidden, submit], + addEventListener: (name, callback) => {{ listeners[name] = callback; }}, + removeEventListener() {{}}, +}}; +const document = {{activeElement: launcher}}; +let closes = 0; +const lifecycle = createIssueCapture.createModalLifecycle({{ + root, background:[], document, requestClose() {{ closes += 1; }}, +}}); +lifecycle.open(launcher); +document.activeElement = submit; +let prevented = 0; +listeners.keydown({{key:'Tab', shiftKey:false, preventDefault() {{ prevented += 1; }}}}); +const wrappedForward = document.activeElement.id; +document.activeElement = cancel; +listeners.keydown({{key:'Tab', shiftKey:true, preventDefault() {{ prevented += 1; }}}}); +const wrappedBackward = document.activeElement.id; +listeners.keydown({{key:'Escape', shiftKey:false, preventDefault() {{ prevented += 1; }}}}); +process.stdout.write(JSON.stringify({{wrappedForward, wrappedBackward, closes, prevented}})); +""" + ) + + assert result == { + "wrappedForward": "cancel-new-issue", + "wrappedBackward": "submit-new-issue", + "closes": 1, + "prevented": 3, + }