From c2da869c47b5a1176088994238872db008020117 Mon Sep 17 00:00:00 2001 From: timmy Date: Sun, 9 Aug 2026 10:36:41 +0000 Subject: [PATCH] feat: collapse mobile card planning controls (#393) --- frontend/card-planning.js | 39 +++++++++++++++++ frontend/dashboard.css | 9 ++++ frontend/dashboard.js | 4 +- frontend/index.html | 1 + frontend/service-worker.js | 1 + tests/test_my_work.py | 81 ++++++++++++++++++++++++++++++++++++ tests/test_service_worker.py | 1 + 7 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 frontend/card-planning.js diff --git a/frontend/card-planning.js b/frontend/card-planning.js new file mode 100644 index 0000000..dedf168 --- /dev/null +++ b/frontend/card-planning.js @@ -0,0 +1,39 @@ +(function (root, factory) { + if (typeof module !== 'undefined' && module.exports) module.exports = factory; + else root.createCardPlanning = factory; +})(typeof self !== 'undefined' ? self : this, function createCardPlanning(root) { + function sync(disclosure) { + disclosure.querySelector('summary')?.setAttribute( + 'aria-expanded', String(disclosure.open) + ); + } + + function wire() { + const disclosures = Array.from(root.querySelectorAll('[data-card-planning]')); + disclosures.forEach(disclosure => { + if (disclosure.dataset.cardPlanningBound === 'true') return; + disclosure.dataset.cardPlanningBound = 'true'; + sync(disclosure); + disclosure.addEventListener('toggle', () => { + if (disclosure.open) { + disclosures.forEach(other => { + if (other === disclosure || !other.open) return; + other.open = false; + sync(other); + }); + } + sync(disclosure); + }); + disclosure.addEventListener('keydown', event => { + if (event.key !== 'Escape' || !disclosure.open) return; + disclosure.open = false; + sync(disclosure); + disclosure.querySelector('summary')?.focus(); + event.preventDefault(); + event.stopPropagation(); + }); + }); + } + + return { wire }; +}); diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 90aed09..912e527 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -121,6 +121,10 @@ textarea { resize: vertical; min-height: 120px; } .later-actions, .today-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; } .later-actions button { min-height:44px; width:100%; } .today-actions button { min-height:44px; width:100%; } +.card-planning { min-width:0; } +.card-planning > summary { display:none; } +.card-planning:not([open]) > .card-planning-actions { display:grid; } +.card-planning-actions { display:grid; gap:8px; min-width:0; } .detail-defer { grid-column:1/-1; max-width:100%; } .detail-defer summary, .detail-defer button { min-height:44px; display:flex; align-items:center; justify-content:center; } .detail-defer summary { cursor:pointer; border:1px solid #60a5fa; border-radius:10px; font-weight:700; } @@ -351,6 +355,11 @@ textarea { resize: vertical; min-height: 120px; } .work-settings:not([open]) > .work-settings-panel { display:none; } .work-settings-panel { display:grid; gap:10px; margin-top:8px; } .my-work-list { grid-template-columns:1fr; } + .my-work-card { min-width:0; overflow-x:hidden; } + .card-planning > summary { min-height:44px; display:flex; align-items:center; justify-content:center; cursor:pointer; border:1px solid #60a5fa; border-radius:10px; font-weight:700; list-style:none; } + .card-planning > summary::-webkit-details-marker { display:none; } + .card-planning:not([open]) > .card-planning-actions { display:none; } + .card-planning[open] > .card-planning-actions { display:grid; margin-top:8px; } .plan-today-panel { width:100%; border-left:0; padding:14px; } .plan-today-item { grid-template-columns:1fr; } .plan-today-item-actions { display:grid; grid-template-columns:repeat(3,1fr); width:100%; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index d8b27bc..fc98232 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -1,6 +1,7 @@ (function(){ const qs = (s, el=document) => el.querySelector(s); const fmt = (d) => new Date(d).toLocaleString(); + const cardPlanning = createCardPlanning(document); const mobileComposerViewport = createMobileComposerViewport({ viewport: window.visualViewport, mediaQuery: window.matchMedia('(max-width: 600px)'), @@ -1215,7 +1216,7 @@ const todayActions = selectedWorkFilter === 'today' ? '
' : '
'; - const planningActions = todayActions + laterActions; + const planningActions = '
' + todayActions + laterActions + '
'; if (item.is_review) { return '
' + contents + '' + readUpdate + markRead + planningActions + '
'; } @@ -1229,6 +1230,7 @@ }).join('') : '
' + (incomplete ? 'More work is available. Load the next page.' : 'No ' + (selectedWorkFilter === 'attention' ? 'items need attention' : (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'later' ? 'deferred work' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))))) + '.') + '
'; + cardPlanning.wire(); document.querySelectorAll('[data-review-index]').forEach(button => { button.addEventListener('click', event => { event.preventDefault(); openRoutedWork(lastMyWork[Number(button.dataset.reviewIndex)], button); }); }); diff --git a/frontend/index.html b/frontend/index.html index de42b17..6e16159 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -582,6 +582,7 @@ + diff --git a/frontend/service-worker.js b/frontend/service-worker.js index f21162c..6a3b886 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -24,6 +24,7 @@ const SHELL = [ BASE + 'static/offline-work.js', BASE + 'static/offline-today.js', BASE + 'static/my-work.js', + BASE + 'static/card-planning.js', BASE + 'static/today-work.js', BASE + 'static/plan-today.js', BASE + 'static/today-sync.js', diff --git a/tests/test_my_work.py b/tests/test_my_work.py index b49e01e..a861f21 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -20,6 +20,87 @@ CONVERSATION = Path(__file__).parents[1] / "frontend" / "conversation.js" PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js" WORK_ROUTE = Path(__file__).parents[1] / "frontend" / "work-route.js" UPDATE_OWNERSHIP = Path(__file__).parents[1] / "frontend" / "update-ownership.js" +CARD_PLANNING = Path(__file__).parents[1] / "frontend" / "card-planning.js" + + +def test_card_planning_disclosures_keep_one_open_and_escape_restores_focus(): + script = f""" +const createCardPlanning = require({json.dumps(str(CARD_PLANNING))}); +function disclosure(name) {{ + const listeners = {{}}; + const attrs = {{}}; + const summary = {{ + focusCount: 0, + setAttribute(key, value) {{ attrs[key] = value; }}, + focus() {{ this.focusCount += 1; }}, + }}; + return {{ + name, open: false, dataset: {{}}, summary, attrs, + querySelector(selector) {{ return selector === 'summary' ? summary : null; }}, + addEventListener(type, listener) {{ listeners[type] = listener; }}, + fire(type, event = {{}}) {{ listeners[type](event); }}, + }}; +}} +const first = disclosure('first'); +const second = disclosure('second'); +const controller = createCardPlanning({{ + querySelectorAll() {{ return [first, second]; }}, +}}); +controller.wire(); +first.open = true; +first.fire('toggle'); +second.open = true; +second.fire('toggle'); +const escape = {{ + key: 'Escape', prevented: 0, stopped: 0, + preventDefault() {{ this.prevented += 1; }}, + stopPropagation() {{ this.stopped += 1; }}, +}}; +second.fire('keydown', escape); +process.stdout.write(JSON.stringify({{ + firstOpen: first.open, + firstExpanded: first.attrs['aria-expanded'], + secondOpen: second.open, + secondExpanded: second.attrs['aria-expanded'], + secondFocus: second.summary.focusCount, + prevented: escape.prevented, + stopped: escape.stopped, +}})); +""" + result = subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ) + + assert json.loads(result.stdout) == { + "firstOpen": False, + "firstExpanded": "false", + "secondOpen": False, + "secondExpanded": "false", + "secondFocus": 1, + "prevented": 1, + "stopped": 1, + } + + +@pytest.mark.anyio +async def test_my_work_cards_progressively_disclose_planning_on_phones(): + html = await dashboard() + service_worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() + + assert '' in html + assert "const cardPlanning = createCardPlanning(document);" in html + assert "cardPlanning.wire();" in html + assert ( + '
' + '' + '
' + ) in html + assert '.card-planning > summary { display:none;' in html + assert '.card-planning:not([open]) > .card-planning-actions { display:grid;' in html + mobile = html.index('@media (max-width: 600px)') + assert '.card-planning > summary { min-height:44px; display:flex;' in html[mobile:] + assert '.card-planning:not([open]) > .card-planning-actions { display:none;' in html[mobile:] + assert "BASE + 'static/card-planning.js'" in service_worker def test_unassigned_issue_update_claims_once_and_becomes_today_ready(): diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 35b38f6..fa931e4 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -340,6 +340,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/offline-work.js", "/dashboard/static/offline-today.js", "/dashboard/static/my-work.js", + "/dashboard/static/card-planning.js", "/dashboard/static/today-work.js", "/dashboard/static/plan-today.js", "/dashboard/static/today-sync.js", -- 2.43.0