feat: make mobile work search keyboard-safe (#317)
This commit is contained in:
parent
6143568b95
commit
b5b6b4d0a9
|
|
@ -39,6 +39,8 @@ input[type=\"text\"], textarea { width: 100%; padding: 8px; border-radius: 8px;
|
||||||
textarea { resize: vertical; min-height: 120px; }
|
textarea { resize: vertical; min-height: 120px; }
|
||||||
#cmd-palette { position: fixed; left: 50%; top: 10%; transform: translateX(-50%); width: min(900px, 94vw); background: rgba(11,21,38,.96); border: 1px solid #2a496e; border-radius: 12px; box-shadow: 0 20px 70px rgba(0,0,0,.55); padding: 10px; z-index: 30; display: none; backdrop-filter: blur(12px); }
|
#cmd-palette { position: fixed; left: 50%; top: 10%; transform: translateX(-50%); width: min(900px, 94vw); background: rgba(11,21,38,.96); border: 1px solid #2a496e; border-radius: 12px; box-shadow: 0 20px 70px rgba(0,0,0,.55); padding: 10px; z-index: 30; display: none; backdrop-filter: blur(12px); }
|
||||||
#cmd-palette.open { display: block; }
|
#cmd-palette.open { display: block; }
|
||||||
|
.cmd-palette-header { display:none; align-items:center; justify-content:space-between; gap:10px; }
|
||||||
|
#cmd-results { margin-top:8px; max-height:min(65vh,520px); overflow-y:auto; }
|
||||||
.cmd-item { padding: 10px; min-height:44px; cursor: pointer; border-radius: 10px; color:#e5e7eb; display:flex; gap:10px; align-items:center; justify-content:space-between; }
|
.cmd-item { padding: 10px; min-height:44px; cursor: pointer; border-radius: 10px; color:#e5e7eb; display:flex; gap:10px; align-items:center; justify-content:space-between; }
|
||||||
.cmd-item:hover, .cmd-item.selected { background: #10233a; outline:1px solid #31577f; }
|
.cmd-item:hover, .cmd-item.selected { background: #10233a; outline:1px solid #31577f; }
|
||||||
.cmd-meta { color:#93a4b8; font-size:12px; text-align:right; }
|
.cmd-meta { color:#93a4b8; font-size:12px; text-align:right; }
|
||||||
|
|
@ -300,6 +302,12 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.update-sheet-panel { width:100%; border-left:0; padding:14px; }
|
.update-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||||
.issue-sheet-panel { width:100%; border-left:0; padding:14px; }
|
.issue-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||||
.search-preview-panel { width:100%; border-left:0; padding:14px; padding-bottom:calc(14px + env(safe-area-inset-bottom)); overflow-x:hidden; }
|
.search-preview-panel { width:100%; border-left:0; padding:14px; padding-bottom:calc(14px + env(safe-area-inset-bottom)); overflow-x:hidden; }
|
||||||
|
#cmd-palette { left:0; top:var(--search-viewport-top,0px); transform:none; width:100%; height:var(--search-viewport-height,100dvh); border:0; border-radius:0; padding:12px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); z-index:46; }
|
||||||
|
#cmd-palette.open { display:flex; flex-direction:column; }
|
||||||
|
.cmd-palette-header { display:flex; flex:0 0 auto; min-height:44px; }
|
||||||
|
#close-command-palette { min-height:44px; }
|
||||||
|
#cmd-input { flex:0 0 auto; min-height:44px; }
|
||||||
|
#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; }
|
.create-issue-panel { width:100%; border-left:0; padding:14px; }
|
||||||
.pull-sheet-panel { width:100%; border-left:0; padding:14px; }
|
.pull-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||||
.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); }
|
.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); }
|
||||||
|
|
|
||||||
|
|
@ -1982,6 +1982,13 @@
|
||||||
),
|
),
|
||||||
onState: renderSearchPreview,
|
onState: renderSearchPreview,
|
||||||
});
|
});
|
||||||
|
const mobileSearchViewport = createMobileSearchViewport({
|
||||||
|
palette: qs('#cmd-palette'),
|
||||||
|
results: qs('#cmd-results'),
|
||||||
|
viewport: window.visualViewport,
|
||||||
|
mediaQuery: window.matchMedia('(max-width: 600px)'),
|
||||||
|
schedule: callback => requestAnimationFrame(callback),
|
||||||
|
});
|
||||||
function closeSearchPreview(navigate = true) {
|
function closeSearchPreview(navigate = true) {
|
||||||
if (navigate && taskOverlayHistory.current() === 'search-preview') {
|
if (navigate && taskOverlayHistory.current() === 'search-preview') {
|
||||||
taskOverlayHistory.close();
|
taskOverlayHistory.close();
|
||||||
|
|
@ -2009,6 +2016,7 @@
|
||||||
item.command.run();
|
item.command.run();
|
||||||
qs('#cmd-input').value = '';
|
qs('#cmd-input').value = '';
|
||||||
} else {
|
} else {
|
||||||
|
mobileSearchViewport.rememberScroll();
|
||||||
searchPreview.open(item.result).catch(() => {});
|
searchPreview.open(item.result).catch(() => {});
|
||||||
taskOverlayHistory.open('search-preview');
|
taskOverlayHistory.open('search-preview');
|
||||||
}
|
}
|
||||||
|
|
@ -2045,6 +2053,8 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
qs('#cmd-palette').classList.add('open');
|
qs('#cmd-palette').classList.add('open');
|
||||||
|
mobileSearchViewport.open();
|
||||||
|
mobileSearchViewport.restoreScroll();
|
||||||
qs('#cmd-input').setAttribute('aria-expanded', 'true');
|
qs('#cmd-input').setAttribute('aria-expanded', 'true');
|
||||||
qs('#cmd-input').focus();
|
qs('#cmd-input').focus();
|
||||||
commandSelection = -1;
|
commandSelection = -1;
|
||||||
|
|
@ -2062,11 +2072,15 @@
|
||||||
if (previous === 'find' && kind !== 'find') closeFindWorkSheet(false);
|
if (previous === 'find' && kind !== 'find') closeFindWorkSheet(false);
|
||||||
if (previous === 'search-preview' && kind !== 'search-preview') {
|
if (previous === 'search-preview' && kind !== 'search-preview') {
|
||||||
if (kind === 'search') closeSearchPreview(false);
|
if (kind === 'search') closeSearchPreview(false);
|
||||||
else searchPreview.close();
|
else {
|
||||||
|
searchPreview.close();
|
||||||
|
mobileSearchViewport.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (previous === 'search' && kind !== 'search' && kind !== 'search-preview') {
|
if (previous === 'search' && kind !== 'search' && kind !== 'search-preview') {
|
||||||
qs('#cmd-palette').classList.remove('open');
|
qs('#cmd-palette').classList.remove('open');
|
||||||
qs('#cmd-input').setAttribute('aria-expanded', 'false');
|
qs('#cmd-input').setAttribute('aria-expanded', 'false');
|
||||||
|
mobileSearchViewport.close();
|
||||||
qs('#open-palette').focus();
|
qs('#open-palette').focus();
|
||||||
}
|
}
|
||||||
if (kind === 'new' && previous !== 'new') openCreateIssueSheet(false);
|
if (kind === 'new' && previous !== 'new') openCreateIssueSheet(false);
|
||||||
|
|
@ -2076,6 +2090,7 @@
|
||||||
});
|
});
|
||||||
taskOverlayHistory.start();
|
taskOverlayHistory.start();
|
||||||
qs('#open-palette').addEventListener('click', openCommandPalette);
|
qs('#open-palette').addEventListener('click', openCommandPalette);
|
||||||
|
qs('#close-command-palette').addEventListener('click', () => taskOverlayHistory.close());
|
||||||
qs('#cmd-input').addEventListener('input', (e) => {
|
qs('#cmd-input').addEventListener('input', (e) => {
|
||||||
commandSelection = -1;
|
commandSelection = -1;
|
||||||
renderCommands(e.target.value);
|
renderCommands(e.target.value);
|
||||||
|
|
|
||||||
|
|
@ -153,8 +153,12 @@
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<div id="cmd-palette" role="dialog" aria-label="Command palette">
|
<div id="cmd-palette" role="dialog" aria-label="Command palette">
|
||||||
|
<div class="cmd-palette-header">
|
||||||
|
<strong>Search work</strong>
|
||||||
|
<button id="close-command-palette" type="button">Close</button>
|
||||||
|
</div>
|
||||||
<input id="cmd-input" type="text" role="combobox" aria-autocomplete="list" aria-controls="cmd-results" aria-expanded="false" placeholder="Search commands, issues, and pull requests..." />
|
<input id="cmd-input" type="text" role="combobox" aria-autocomplete="list" aria-controls="cmd-results" aria-expanded="false" placeholder="Search commands, issues, and pull requests..." />
|
||||||
<div id="cmd-results" class="stack" role="listbox" aria-label="Commands and work search results" style="margin-top:8px;max-height:min(65vh,520px);overflow-y:auto;"></div>
|
<div id="cmd-results" class="stack" role="listbox" aria-label="Commands and work search results"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="search-preview" id="search-preview" role="dialog" aria-modal="true" aria-labelledby="search-preview-title">
|
<div class="search-preview" id="search-preview" role="dialog" aria-modal="true" aria-labelledby="search-preview-title">
|
||||||
|
|
@ -529,6 +533,7 @@
|
||||||
<script src="static/mobile-task-dock.js"></script>
|
<script src="static/mobile-task-dock.js"></script>
|
||||||
<script src="static/mobile-launch.js"></script>
|
<script src="static/mobile-launch.js"></script>
|
||||||
<script src="static/install-app.js"></script>
|
<script src="static/install-app.js"></script>
|
||||||
|
<script src="static/mobile-search-viewport.js"></script>
|
||||||
<script src="static/dashboard.js"></script>
|
<script src="static/dashboard.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
50
frontend/mobile-search-viewport.js
Normal file
50
frontend/mobile-search-viewport.js
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
(function (root, factory) {
|
||||||
|
const exported = factory();
|
||||||
|
if (typeof module === 'object' && module.exports) module.exports = exported;
|
||||||
|
else root.createMobileSearchViewport = exported;
|
||||||
|
})(typeof self !== 'undefined' ? self : this, function () {
|
||||||
|
return function createMobileSearchViewport(options) {
|
||||||
|
const palette = options.palette;
|
||||||
|
const results = options.results;
|
||||||
|
const viewport = options.viewport;
|
||||||
|
const mediaQuery = options.mediaQuery;
|
||||||
|
const schedule = options.schedule || (callback => requestAnimationFrame(callback));
|
||||||
|
let active = false;
|
||||||
|
let scrollTop = 0;
|
||||||
|
|
||||||
|
function applyGeometry() {
|
||||||
|
if (!active || !mediaQuery.matches || !viewport) return;
|
||||||
|
palette.style.setProperty('--search-viewport-top', `${viewport.offsetTop || 0}px`);
|
||||||
|
palette.style.setProperty('--search-viewport-height', `${viewport.height}px`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onViewportChange() {
|
||||||
|
schedule(applyGeometry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
open() {
|
||||||
|
if (active || !mediaQuery.matches || !viewport) return;
|
||||||
|
active = true;
|
||||||
|
applyGeometry();
|
||||||
|
viewport.addEventListener('resize', onViewportChange);
|
||||||
|
viewport.addEventListener('scroll', onViewportChange);
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
if (active && viewport) {
|
||||||
|
viewport.removeEventListener('resize', onViewportChange);
|
||||||
|
viewport.removeEventListener('scroll', onViewportChange);
|
||||||
|
}
|
||||||
|
active = false;
|
||||||
|
palette.style.removeProperty('--search-viewport-top');
|
||||||
|
palette.style.removeProperty('--search-viewport-height');
|
||||||
|
},
|
||||||
|
rememberScroll() {
|
||||||
|
scrollTop = results.scrollTop;
|
||||||
|
},
|
||||||
|
restoreScroll() {
|
||||||
|
schedule(() => { results.scrollTop = scrollTop; });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
const BASE = new URL('./', self.location.href).pathname;
|
const BASE = new URL('./', self.location.href).pathname;
|
||||||
importScripts(BASE + 'static/background-issue-sync.js');
|
importScripts(BASE + 'static/background-issue-sync.js');
|
||||||
const CACHE = 'stackchain-dashboard-shell-v32';
|
const CACHE = 'stackchain-dashboard-shell-v33';
|
||||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
BASE,
|
BASE,
|
||||||
|
|
@ -35,6 +35,7 @@ const SHELL = [
|
||||||
BASE + 'static/mobile-task-dock.js',
|
BASE + 'static/mobile-task-dock.js',
|
||||||
BASE + 'static/mobile-launch.js',
|
BASE + 'static/mobile-launch.js',
|
||||||
BASE + 'static/install-app.js',
|
BASE + 'static/install-app.js',
|
||||||
|
BASE + 'static/mobile-search-viewport.js',
|
||||||
BASE + 'static/background-issue-sync.js',
|
BASE + 'static/background-issue-sync.js',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ from tests.dashboard_bundle import dashboard_bundle_text
|
||||||
FRONTEND = Path(__file__).parents[1] / "frontend"
|
FRONTEND = Path(__file__).parents[1] / "frontend"
|
||||||
COMMANDS = FRONTEND / "commands.js"
|
COMMANDS = FRONTEND / "commands.js"
|
||||||
SEARCH_PREVIEW = FRONTEND / "search-preview.js"
|
SEARCH_PREVIEW = FRONTEND / "search-preview.js"
|
||||||
|
MOBILE_SEARCH_VIEWPORT = FRONTEND / "mobile-search-viewport.js"
|
||||||
|
|
||||||
|
|
||||||
class ScriptSourceParser(HTMLParser):
|
class ScriptSourceParser(HTMLParser):
|
||||||
|
|
@ -240,3 +241,56 @@ def test_assigned_issue_preview_hands_off_to_existing_my_work_sheet():
|
||||||
assert "detail.claimable || (detail.assigned_to_me && detail.kind === 'issue')" in html
|
assert "detail.claimable || (detail.assigned_to_me && detail.kind === 'issue')" in html
|
||||||
assert "claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me'" in html
|
assert "claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me'" in html
|
||||||
assert "openPreviewIssueInMyWork" in html
|
assert "openPreviewIssueInMyWork" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_search_viewport_tracks_keyboard_geometry_without_leaking_listeners():
|
||||||
|
script = f"""
|
||||||
|
const createMobileSearchViewport = require({json.dumps(str(MOBILE_SEARCH_VIEWPORT))});
|
||||||
|
const listeners = new Map();
|
||||||
|
const viewport = {{
|
||||||
|
height: 430, offsetTop: 17,
|
||||||
|
addEventListener: (name, fn) => listeners.set(name, fn),
|
||||||
|
removeEventListener: (name, fn) => {{ if (listeners.get(name) === fn) listeners.delete(name); }},
|
||||||
|
}};
|
||||||
|
const values = new Map();
|
||||||
|
const palette = {{ style: {{
|
||||||
|
setProperty: (name, value) => values.set(name, value),
|
||||||
|
removeProperty: name => values.delete(name),
|
||||||
|
}} }};
|
||||||
|
const results = {{ scrollTop: 73 }};
|
||||||
|
const controller = createMobileSearchViewport({{
|
||||||
|
palette, results, viewport, mediaQuery: {{ matches: true }},
|
||||||
|
schedule: fn => fn(),
|
||||||
|
}});
|
||||||
|
controller.open();
|
||||||
|
if (values.get('--search-viewport-top') !== '17px') throw new Error('viewport offset was not applied');
|
||||||
|
if (values.get('--search-viewport-height') !== '430px') throw new Error('viewport height was not applied');
|
||||||
|
if (listeners.size !== 2) throw new Error('viewport listeners were not attached once');
|
||||||
|
controller.rememberScroll();
|
||||||
|
results.scrollTop = 0;
|
||||||
|
controller.restoreScroll();
|
||||||
|
if (results.scrollTop !== 73) throw new Error('result scroll position was not restored');
|
||||||
|
controller.open();
|
||||||
|
if (listeners.size !== 2) throw new Error('duplicate viewport listeners were attached');
|
||||||
|
controller.close();
|
||||||
|
if (listeners.size !== 0 || values.size !== 0) throw new Error('viewport state leaked after close');
|
||||||
|
"""
|
||||||
|
|
||||||
|
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_search_is_a_keyboard_safe_workspace_wired_to_preview_history():
|
||||||
|
html = dashboard_bundle_text()
|
||||||
|
css = (FRONTEND / "dashboard.css").read_text()
|
||||||
|
|
||||||
|
assert 'class="cmd-palette-header"' in html
|
||||||
|
assert 'id="close-command-palette"' in html
|
||||||
|
assert 'style="margin-top:8px;max-height:min(65vh,520px);overflow-y:auto;"' not in html
|
||||||
|
assert 'src="static/mobile-search-viewport.js"' in html
|
||||||
|
assert "height:var(--search-viewport-height,100dvh)" in css
|
||||||
|
assert "top:var(--search-viewport-top,0px)" in css
|
||||||
|
assert "#cmd-results { flex:1; min-height:0; overflow-y:auto;" in css
|
||||||
|
assert "mobileSearchViewport.rememberScroll()" in html
|
||||||
|
assert "mobileSearchViewport.restoreScroll()" in html
|
||||||
|
assert "mobileSearchViewport.open()" in html
|
||||||
|
assert "mobileSearchViewport.close()" in html
|
||||||
|
|
|
||||||
|
|
@ -97,12 +97,19 @@ async function dispatchNotificationClick(route) {{
|
||||||
def test_share_target_sign_in_fix_ships_in_a_new_shell_cache():
|
def test_share_target_sign_in_fix_ships_in_a_new_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v32" in source
|
assert "stackchain-dashboard-shell-v33" in source
|
||||||
assert "BASE + 'static/dashboard.css'" in source
|
assert "BASE + 'static/dashboard.css'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
assert "BASE + 'static/install-app.js'" in source
|
assert "BASE + 'static/install-app.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||||
|
source = WORKER.read_text()
|
||||||
|
|
||||||
|
assert "stackchain-dashboard-shell-v33" in source
|
||||||
|
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag():
|
def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag():
|
||||||
result = run_worker_scenario(
|
result = run_worker_scenario(
|
||||||
"""
|
"""
|
||||||
|
|
@ -268,6 +275,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||||
"/dashboard/static/mobile-task-dock.js",
|
"/dashboard/static/mobile-task-dock.js",
|
||||||
"/dashboard/static/mobile-launch.js",
|
"/dashboard/static/mobile-launch.js",
|
||||||
"/dashboard/static/install-app.js",
|
"/dashboard/static/install-app.js",
|
||||||
|
"/dashboard/static/mobile-search-viewport.js",
|
||||||
"/dashboard/static/background-issue-sync.js",
|
"/dashboard/static/background-issue-sync.js",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user