feat: collapse mobile card planning controls (#393)
This commit is contained in:
parent
8ada75ec33
commit
c2da869c47
39
frontend/card-planning.js
Normal file
39
frontend/card-planning.js
Normal file
|
|
@ -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 };
|
||||||
|
});
|
||||||
|
|
@ -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, .today-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
|
||||||
.later-actions button { min-height:44px; width:100%; }
|
.later-actions button { min-height:44px; width:100%; }
|
||||||
.today-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 { 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, .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; }
|
.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:not([open]) > .work-settings-panel { display:none; }
|
||||||
.work-settings-panel { display:grid; gap:10px; margin-top:8px; }
|
.work-settings-panel { display:grid; gap:10px; margin-top:8px; }
|
||||||
.my-work-list { grid-template-columns:1fr; }
|
.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-panel { width:100%; border-left:0; padding:14px; }
|
||||||
.plan-today-item { grid-template-columns:1fr; }
|
.plan-today-item { grid-template-columns:1fr; }
|
||||||
.plan-today-item-actions { display:grid; grid-template-columns:repeat(3,1fr); width:100%; }
|
.plan-today-item-actions { display:grid; grid-template-columns:repeat(3,1fr); width:100%; }
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
(function(){
|
(function(){
|
||||||
const qs = (s, el=document) => el.querySelector(s);
|
const qs = (s, el=document) => el.querySelector(s);
|
||||||
const fmt = (d) => new Date(d).toLocaleString();
|
const fmt = (d) => new Date(d).toLocaleString();
|
||||||
|
const cardPlanning = createCardPlanning(document);
|
||||||
const mobileComposerViewport = createMobileComposerViewport({
|
const mobileComposerViewport = createMobileComposerViewport({
|
||||||
viewport: window.visualViewport,
|
viewport: window.visualViewport,
|
||||||
mediaQuery: window.matchMedia('(max-width: 600px)'),
|
mediaQuery: window.matchMedia('(max-width: 600px)'),
|
||||||
|
|
@ -1215,7 +1216,7 @@
|
||||||
const todayActions = selectedWorkFilter === 'today' ?
|
const todayActions = selectedWorkFilter === 'today' ?
|
||||||
'<div class="today-actions" aria-label="Reorder Today"><button type="button" data-today-move="up" data-work-index="' + index + '"' + (todayPosition.can_up ? '' : ' disabled') + '>Move up</button><button type="button" data-today-move="down" data-work-index="' + index + '"' + (todayPosition.can_down ? '' : ' disabled') + '>Move down</button><button type="button" data-today-remove data-work-index="' + index + '">Remove from Today</button></div>' :
|
'<div class="today-actions" aria-label="Reorder Today"><button type="button" data-today-move="up" data-work-index="' + index + '"' + (todayPosition.can_up ? '' : ' disabled') + '>Move up</button><button type="button" data-today-move="down" data-work-index="' + index + '"' + (todayPosition.can_down ? '' : ' disabled') + '>Move down</button><button type="button" data-today-remove data-work-index="' + index + '">Remove from Today</button></div>' :
|
||||||
'<div class="today-actions"><button type="button" data-today-add data-work-index="' + index + '"' + (alreadyToday ? ' disabled' : planningDisabled) + '>' + (alreadyToday ? 'Added to Today' : 'Add to Today') + '</button></div>';
|
'<div class="today-actions"><button type="button" data-today-add data-work-index="' + index + '"' + (alreadyToday ? ' disabled' : planningDisabled) + '>' + (alreadyToday ? 'Added to Today' : 'Add to Today') + '</button></div>';
|
||||||
const planningActions = todayActions + laterActions;
|
const planningActions = '<details class="card-planning" data-card-planning><summary aria-expanded="false">Plan or defer</summary><div class="card-planning-actions">' + todayActions + laterActions + '</div></details>';
|
||||||
if (item.is_review) {
|
if (item.is_review) {
|
||||||
return '<article class="my-work-card"><a class="my-work-card-main review-trigger" href="' + escAttr(routeHref) + '" data-review-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + planningActions + '</article>';
|
return '<article class="my-work-card"><a class="my-work-card-main review-trigger" href="' + escAttr(routeHref) + '" data-review-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + planningActions + '</article>';
|
||||||
}
|
}
|
||||||
|
|
@ -1229,6 +1230,7 @@
|
||||||
}).join('') : '<div class="muted">' + (incomplete ?
|
}).join('') : '<div class="muted">' + (incomplete ?
|
||||||
'More work is available. Load the next page.' :
|
'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'))))) + '.') + '</div>';
|
'No ' + (selectedWorkFilter === 'attention' ? 'items need attention' : (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'later' ? 'deferred work' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))))) + '.') + '</div>';
|
||||||
|
cardPlanning.wire();
|
||||||
document.querySelectorAll('[data-review-index]').forEach(button => {
|
document.querySelectorAll('[data-review-index]').forEach(button => {
|
||||||
button.addEventListener('click', event => { event.preventDefault(); openRoutedWork(lastMyWork[Number(button.dataset.reviewIndex)], button); });
|
button.addEventListener('click', event => { event.preventDefault(); openRoutedWork(lastMyWork[Number(button.dataset.reviewIndex)], button); });
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -582,6 +582,7 @@
|
||||||
<script src="static/offline-work.js"></script>
|
<script src="static/offline-work.js"></script>
|
||||||
<script src="static/offline-today.js"></script>
|
<script src="static/offline-today.js"></script>
|
||||||
<script src="static/my-work.js"></script>
|
<script src="static/my-work.js"></script>
|
||||||
|
<script src="static/card-planning.js"></script>
|
||||||
<script src="static/today-work.js"></script>
|
<script src="static/today-work.js"></script>
|
||||||
<script src="static/plan-today.js"></script>
|
<script src="static/plan-today.js"></script>
|
||||||
<script src="static/today-sync.js"></script>
|
<script src="static/today-sync.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ const SHELL = [
|
||||||
BASE + 'static/offline-work.js',
|
BASE + 'static/offline-work.js',
|
||||||
BASE + 'static/offline-today.js',
|
BASE + 'static/offline-today.js',
|
||||||
BASE + 'static/my-work.js',
|
BASE + 'static/my-work.js',
|
||||||
|
BASE + 'static/card-planning.js',
|
||||||
BASE + 'static/today-work.js',
|
BASE + 'static/today-work.js',
|
||||||
BASE + 'static/plan-today.js',
|
BASE + 'static/plan-today.js',
|
||||||
BASE + 'static/today-sync.js',
|
BASE + 'static/today-sync.js',
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,87 @@ CONVERSATION = Path(__file__).parents[1] / "frontend" / "conversation.js"
|
||||||
PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js"
|
PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js"
|
||||||
WORK_ROUTE = Path(__file__).parents[1] / "frontend" / "work-route.js"
|
WORK_ROUTE = Path(__file__).parents[1] / "frontend" / "work-route.js"
|
||||||
UPDATE_OWNERSHIP = Path(__file__).parents[1] / "frontend" / "update-ownership.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 '<script src="static/card-planning.js"></script>' in html
|
||||||
|
assert "const cardPlanning = createCardPlanning(document);" in html
|
||||||
|
assert "cardPlanning.wire();" in html
|
||||||
|
assert (
|
||||||
|
'<details class="card-planning" data-card-planning>'
|
||||||
|
'<summary aria-expanded="false">Plan or defer</summary>'
|
||||||
|
'<div class="card-planning-actions">'
|
||||||
|
) 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():
|
def test_unassigned_issue_update_claims_once_and_becomes_today_ready():
|
||||||
|
|
|
||||||
|
|
@ -340,6 +340,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||||
"/dashboard/static/offline-work.js",
|
"/dashboard/static/offline-work.js",
|
||||||
"/dashboard/static/offline-today.js",
|
"/dashboard/static/offline-today.js",
|
||||||
"/dashboard/static/my-work.js",
|
"/dashboard/static/my-work.js",
|
||||||
|
"/dashboard/static/card-planning.js",
|
||||||
"/dashboard/static/today-work.js",
|
"/dashboard/static/today-work.js",
|
||||||
"/dashboard/static/plan-today.js",
|
"/dashboard/static/plan-today.js",
|
||||||
"/dashboard/static/today-sync.js",
|
"/dashboard/static/today-sync.js",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user