Make mobile issue capture a focus-contained modal journey #1047
|
|
@ -8,6 +8,59 @@ function newIssueOperationId() {
|
||||||
return String(Date.now()) + '-' + Math.random().toString(16).slice(2);
|
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 = [];
|
||||||
|
const panel = root.querySelector('.create-issue-panel');
|
||||||
|
if (panel) panel.scrollTop = 0;
|
||||||
|
if (restore && launcher?.isConnected) launcher.focus();
|
||||||
|
launcher = null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeSharedContent(value = {}) {
|
function normalizeSharedContent(value = {}) {
|
||||||
const clean = input => String(input || '').replace(/\s+/g, ' ').trim();
|
const clean = input => String(input || '').replace(/\s+/g, ' ').trim();
|
||||||
const text = String(value.text || '').trim().slice(0, 9500);
|
const text = String(value.text || '').trim().slice(0, 9500);
|
||||||
|
|
@ -840,5 +893,6 @@ createIssueCapture.buildRelatedDraft = buildRelatedDraft;
|
||||||
createIssueCapture.relatedChecklistDraft = relatedChecklistDraft;
|
createIssueCapture.relatedChecklistDraft = relatedChecklistDraft;
|
||||||
createIssueCapture.linkChecklistTask = linkChecklistTask;
|
createIssueCapture.linkChecklistTask = linkChecklistTask;
|
||||||
createIssueCapture.createChecklistPromotion = createChecklistPromotion;
|
createIssueCapture.createChecklistPromotion = createChecklistPromotion;
|
||||||
|
createIssueCapture.createModalLifecycle = createIssueModalLifecycle;
|
||||||
|
|
||||||
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture;
|
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture;
|
||||||
|
|
|
||||||
|
|
@ -4692,7 +4692,25 @@
|
||||||
sharedLaunchState = null;
|
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' && element.getAttribute('role') !== 'dialog'
|
||||||
|
);
|
||||||
|
createIssueModalLifecycle = createIssueCapture.createModalLifecycle({
|
||||||
|
root, background, document, requestClose:() => closeCreateIssueSheet(),
|
||||||
|
});
|
||||||
|
return createIssueModalLifecycle;
|
||||||
|
}
|
||||||
|
|
||||||
async function openCreateIssueSheet(navigate = true) {
|
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 (!issueCapture && !await ensureIssueCapture()) return;
|
||||||
if (navigate) {
|
if (navigate) {
|
||||||
taskOverlayHistory.open('new');
|
taskOverlayHistory.open('new');
|
||||||
|
|
@ -4751,6 +4769,7 @@
|
||||||
updateIssueCreateActions();
|
updateIssueCreateActions();
|
||||||
qs('#create-issue-sheet').classList.add('open');
|
qs('#create-issue-sheet').classList.add('open');
|
||||||
creatingIssue = true;
|
creatingIssue = true;
|
||||||
|
issueCaptureModal().open(createIssueLauncher);
|
||||||
if (!sharedImageHandled && sharedImageMarker) {
|
if (!sharedImageHandled && sharedImageMarker) {
|
||||||
sharedImageHandled = true;
|
sharedImageHandled = true;
|
||||||
if (createIssueAttachmentController.state()) {
|
if (createIssueAttachmentController.state()) {
|
||||||
|
|
@ -4807,7 +4826,7 @@
|
||||||
function closeCreateIssueSheet(navigate = true, preserveDraft = true) {
|
function closeCreateIssueSheet(navigate = true, preserveDraft = true) {
|
||||||
if (navigate && taskOverlayHistory.current() === 'new') {
|
if (navigate && taskOverlayHistory.current() === 'new') {
|
||||||
suppressCreateDraftOnHistoryClose = !preserveDraft;
|
suppressCreateDraftOnHistoryClose = !preserveDraft;
|
||||||
taskOverlayHistory.close();
|
taskOverlayHistory.leave();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
qs('#create-issue-sheet').classList.remove('open');
|
qs('#create-issue-sheet').classList.remove('open');
|
||||||
|
|
@ -4816,13 +4835,14 @@
|
||||||
clearTimeout(duplicateCheckTimer);
|
clearTimeout(duplicateCheckTimer);
|
||||||
qs('#create-issue-duplicates').hidden = true;
|
qs('#create-issue-duplicates').hidden = true;
|
||||||
creatingIssue = false;
|
creatingIssue = false;
|
||||||
|
issueCaptureModal().close({restore:!followUpSourceUpdate});
|
||||||
|
createIssueLauncher = null;
|
||||||
if (followUpSourceUpdate) {
|
if (followUpSourceUpdate) {
|
||||||
const source = followUpSourceUpdate;
|
const source = followUpSourceUpdate;
|
||||||
followUpSourceUpdate = null;
|
followUpSourceUpdate = null;
|
||||||
notificationReader.open(source.item, source.detail);
|
notificationReader.open(source.item, source.detail);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
qs('#new-issue').focus();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyOutboxResult(result, openCreated = false, startCreated = false) {
|
function applyOutboxResult(result, openCreated = false, startCreated = false) {
|
||||||
|
|
|
||||||
|
|
@ -187,7 +187,17 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
|
||||||
|
|
||||||
new_action.click()
|
new_action.click()
|
||||||
expect(page.locator("#create-issue-sheet")).to_have_class("create-issue-sheet open")
|
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")
|
create_navigation = page.locator(".mobile-create-issue-nav")
|
||||||
expect(create_navigation).to_be_visible()
|
expect(create_navigation).to_be_visible()
|
||||||
create_navigation_buttons = create_navigation.locator("button")
|
create_navigation_buttons = create_navigation.locator("button")
|
||||||
|
|
|
||||||
103
tests/test_create_issue_modal.py
Normal file
103
tests/test_create_issue_modal.py
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
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 panel = {{...element('create-issue-panel'), scrollTop:420}};
|
||||||
|
const background = [element('header'), element('main'), element('mobile-task-dock')];
|
||||||
|
const root = {{
|
||||||
|
querySelector: selector => selector === '#cancel-new-issue' ? cancel :
|
||||||
|
(selector === '.create-issue-panel' ? panel : 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), scrollTop:panel.scrollTop}}
|
||||||
|
}}));
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
"opened": {"active": "cancel-new-issue", "inert": [True, True, True]},
|
||||||
|
"closed": {"active": "mobile-new", "inert": [False, False, False], "scrollTop": 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user