fix(web): show MR footer panels without scripts and style the tabs as buttons
6 files · +38 -19
M docs/merge-requests.md +2 -2
| @@ -112,8 +112,8 @@ collapsed by default. Clicking a tab opens its panel, clicking the open tab | |||
| 112 | 112 | folds it again, clicking the other tab switches. Arrow keys (and Home / End) | |
| 113 | 113 | move between the tabs. Without scripts the tabs are anchor links and the URL | |
| 114 | 114 | fragment (`#mr-commits`, `#mr-checks`) shows the panel. The checks summary above | |
| 115 | - | the diff opens the Checks panel and continues to update live. Commit totals use `N+` when the branch | |
| 116 | - | list is capped: the reader's existing count operation counts all reachable | |
| 115 | + | the diff opens the Checks panel and continues to update live. Commit totals use | |
| 116 | + | `N+` when the branch list is capped: the reader's existing count operation counts all reachable | |
| 117 | 117 | ancestors, not the source-minus-target range. | |
| 118 | 118 | ||
| 119 | 119 | The rail keeps Files (including its `g f` shortcut) and Verdicts. Merge settings | |
| @@ -112,8 +112,8 @@ collapsed by default. Clicking a tab opens its panel, clicking the open tab | |||||
| 112 | folds it again, clicking the other tab switches. Arrow keys (and Home / End) | 112 | folds it again, clicking the other tab switches. Arrow keys (and Home / End) | ||
| 113 | move between the tabs. Without scripts the tabs are anchor links and the URL | 113 | move between the tabs. Without scripts the tabs are anchor links and the URL | ||
| 114 | fragment (`#mr-commits`, `#mr-checks`) shows the panel. The checks summary above | 114 | fragment (`#mr-commits`, `#mr-checks`) shows the panel. The checks summary above | ||
| 115 | - | the diff opens the Checks panel and continues to update live. Commit totals use `N+` when the branch | 115 | + | the diff opens the Checks panel and continues to update live. Commit totals use |
| 116 | - | list is capped: the reader's existing count operation counts all reachable | 116 | + | `N+` when the branch list is capped: the reader's existing count operation counts all reachable |
| 117 | ancestors, not the source-minus-target range. | 117 | ancestors, not the source-minus-target range. | ||
| 118 | 118 | ||||
| 119 | The rail keeps Files (including its `g f` shortcut) and Verdicts. Merge settings | 119 | The rail keeps Files (including its `g f` shortcut) and Verdicts. Merge settings | ||
M internal/adapters/web/mr.templ +2 -2
| @@ -391,7 +391,7 @@ templ mrFooter(p MRPage) { | |||
| 391 | 391 | </span> | |
| 392 | 392 | </a> | |
| 393 | 393 | </nav> | |
| 394 | - | <div id="mr-commits" data-mr-panel hidden aria-labelledby="mr-tab-commits"> | |
| 394 | + | <div id="mr-commits" data-mr-panel role="region" aria-labelledby="mr-tab-commits"> | |
| 395 | 395 | <table class="mr-commits"> | |
| 396 | 396 | <thead><tr><th>Commit</th><th>Subject</th><th>Author</th><th>Date</th><th>Signature</th><th>Status</th></tr></thead> | |
| 397 | 397 | <tbody> | |
| @@ -426,7 +426,7 @@ templ mrFooter(p MRPage) { | |||
| 426 | 426 | <p class="note">{ p.CommitsNote }</p> | |
| 427 | 427 | } | |
| 428 | 428 | </div> | |
| 429 | - | <div id="mr-checks-panel" data-mr-panel hidden aria-labelledby="mr-tab-checks"> | |
| 429 | + | <div id="mr-checks-panel" data-mr-panel role="region" aria-labelledby="mr-tab-checks"> | |
| 430 | 430 | @MRChecks(p.Checks) | |
| 431 | 431 | </div> | |
| 432 | 432 | </footer> | |
| @@ -391,7 +391,7 @@ templ mrFooter(p MRPage) { | |||||
| 391 | </span> | 391 | </span> | ||
| 392 | </a> | 392 | </a> | ||
| 393 | </nav> | 393 | </nav> | ||
| 394 | - | <div id="mr-commits" data-mr-panel hidden aria-labelledby="mr-tab-commits"> | 394 | + | <div id="mr-commits" data-mr-panel role="region" aria-labelledby="mr-tab-commits"> |
| 395 | <table class="mr-commits"> | 395 | <table class="mr-commits"> | ||
| 396 | <thead><tr><th>Commit</th><th>Subject</th><th>Author</th><th>Date</th><th>Signature</th><th>Status</th></tr></thead> | 396 | <thead><tr><th>Commit</th><th>Subject</th><th>Author</th><th>Date</th><th>Signature</th><th>Status</th></tr></thead> | ||
| 397 | <tbody> | 397 | <tbody> | ||
| @@ -426,7 +426,7 @@ templ mrFooter(p MRPage) { | |||||
| 426 | <p class="note">{ p.CommitsNote }</p> | 426 | <p class="note">{ p.CommitsNote }</p> | ||
| 427 | } | 427 | } | ||
| 428 | </div> | 428 | </div> | ||
| 429 | - | <div id="mr-checks-panel" data-mr-panel hidden aria-labelledby="mr-tab-checks"> | 429 | + | <div id="mr-checks-panel" data-mr-panel role="region" aria-labelledby="mr-tab-checks"> |
| 430 | @MRChecks(p.Checks) | 430 | @MRChecks(p.Checks) | ||
| 431 | </div> | 431 | </div> | ||
| 432 | </footer> | 432 | </footer> | ||
M internal/adapters/web/mr_templ.go +2 -2
| @@ -2079,7 +2079,7 @@ func mrFooter(p MRPage) templ.Component { | |||
| 2079 | 2079 | return templ_7745c5c3_Err | |
| 2080 | 2080 | } | |
| 2081 | 2081 | } | |
| 2082 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, "</span></a></nav><div id=\"mr-commits\" data-mr-panel hidden aria-labelledby=\"mr-tab-commits\"><table class=\"mr-commits\"><thead><tr><th>Commit</th><th>Subject</th><th>Author</th><th>Date</th><th>Signature</th><th>Status</th></tr></thead> <tbody>") | |
| 2082 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, "</span></a></nav><div id=\"mr-commits\" data-mr-panel role=\"region\" aria-labelledby=\"mr-tab-commits\"><table class=\"mr-commits\"><thead><tr><th>Commit</th><th>Subject</th><th>Author</th><th>Date</th><th>Signature</th><th>Status</th></tr></thead> <tbody>") | |
| 2083 | 2083 | if templ_7745c5c3_Err != nil { | |
| 2084 | 2084 | return templ_7745c5c3_Err | |
| 2085 | 2085 | } | |
| @@ -2236,7 +2236,7 @@ func mrFooter(p MRPage) templ.Component { | |||
| 2236 | 2236 | return templ_7745c5c3_Err | |
| 2237 | 2237 | } | |
| 2238 | 2238 | } | |
| 2239 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 210, "</div><div id=\"mr-checks-panel\" data-mr-panel hidden aria-labelledby=\"mr-tab-checks\">") | |
| 2239 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 210, "</div><div id=\"mr-checks-panel\" data-mr-panel role=\"region\" aria-labelledby=\"mr-tab-checks\">") | |
| 2240 | 2240 | if templ_7745c5c3_Err != nil { | |
| 2241 | 2241 | return templ_7745c5c3_Err | |
| 2242 | 2242 | } | |
| @@ -2079,7 +2079,7 @@ func mrFooter(p MRPage) templ.Component { | |||||
| 2079 | return templ_7745c5c3_Err | 2079 | return templ_7745c5c3_Err | ||
| 2080 | } | 2080 | } | ||
| 2081 | } | 2081 | } | ||
| 2082 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, "</span></a></nav><div id=\"mr-commits\" data-mr-panel hidden aria-labelledby=\"mr-tab-commits\"><table class=\"mr-commits\"><thead><tr><th>Commit</th><th>Subject</th><th>Author</th><th>Date</th><th>Signature</th><th>Status</th></tr></thead> <tbody>") | 2082 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, "</span></a></nav><div id=\"mr-commits\" data-mr-panel role=\"region\" aria-labelledby=\"mr-tab-commits\"><table class=\"mr-commits\"><thead><tr><th>Commit</th><th>Subject</th><th>Author</th><th>Date</th><th>Signature</th><th>Status</th></tr></thead> <tbody>") |
| 2083 | if templ_7745c5c3_Err != nil { | 2083 | if templ_7745c5c3_Err != nil { | ||
| 2084 | return templ_7745c5c3_Err | 2084 | return templ_7745c5c3_Err | ||
| 2085 | } | 2085 | } | ||
| @@ -2236,7 +2236,7 @@ func mrFooter(p MRPage) templ.Component { | |||||
| 2236 | return templ_7745c5c3_Err | 2236 | return templ_7745c5c3_Err | ||
| 2237 | } | 2237 | } | ||
| 2238 | } | 2238 | } | ||
| 2239 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 210, "</div><div id=\"mr-checks-panel\" data-mr-panel hidden aria-labelledby=\"mr-tab-checks\">") | 2239 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 210, "</div><div id=\"mr-checks-panel\" data-mr-panel role=\"region\" aria-labelledby=\"mr-tab-checks\">") |
| 2240 | if templ_7745c5c3_Err != nil { | 2240 | if templ_7745c5c3_Err != nil { | ||
| 2241 | return templ_7745c5c3_Err | 2241 | return templ_7745c5c3_Err | ||
| 2242 | } | 2242 | } | ||
M internal/adapters/web/mr_test.go +5 -2
| @@ -137,8 +137,11 @@ func TestMRPanelsAndMenus(t *testing.T) { | |||
| 137 | 137 | assert.Contains(t, footer, `<footer class="mr-footer"><nav data-mr-tabs`) | |
| 138 | 138 | assert.NotContains(t, footer, `<details`) | |
| 139 | 139 | assert.NotContains(t, footer, `<summary`) | |
| 140 | - | assert.Contains(t, footer, `id="mr-commits" data-mr-panel hidden`) | |
| 141 | - | assert.Contains(t, footer, `id="mr-checks-panel" data-mr-panel hidden`) | |
| 140 | + | // No hidden attribute: the CSS shows the panel of the URL fragment until | |
| 141 | + | // mr.js takes over, so the tabs work as anchors without scripts. | |
| 142 | + | assert.Contains(t, footer, `id="mr-commits" data-mr-panel role="region"`) | |
| 143 | + | assert.Contains(t, footer, `id="mr-checks-panel" data-mr-panel role="region"`) | |
| 144 | + | assert.NotContains(t, footer, `data-mr-panel hidden`) | |
| 142 | 145 | assert.Contains(t, footer, `data-mr-tabs`) | |
| 143 | 146 | assert.Contains(t, footer, `id="mr-commits"`) | |
| 144 | 147 | assert.Contains(t, footer, `id="mr-checks"`) | |
| @@ -137,8 +137,11 @@ func TestMRPanelsAndMenus(t *testing.T) { | |||||
| 137 | assert.Contains(t, footer, `<footer class="mr-footer"><nav data-mr-tabs`) | 137 | assert.Contains(t, footer, `<footer class="mr-footer"><nav data-mr-tabs`) | ||
| 138 | assert.NotContains(t, footer, `<details`) | 138 | assert.NotContains(t, footer, `<details`) | ||
| 139 | assert.NotContains(t, footer, `<summary`) | 139 | assert.NotContains(t, footer, `<summary`) | ||
| 140 | - | assert.Contains(t, footer, `id="mr-commits" data-mr-panel hidden`) | 140 | + | // No hidden attribute: the CSS shows the panel of the URL fragment until |
| 141 | - | assert.Contains(t, footer, `id="mr-checks-panel" data-mr-panel hidden`) | 141 | + | // mr.js takes over, so the tabs work as anchors without scripts. |
| 142 | + | assert.Contains(t, footer, `id="mr-commits" data-mr-panel role="region"`) | |||
| 143 | + | assert.Contains(t, footer, `id="mr-checks-panel" data-mr-panel role="region"`) | |||
| 144 | + | assert.NotContains(t, footer, `data-mr-panel hidden`) | |||
| 142 | assert.Contains(t, footer, `data-mr-tabs`) | 145 | assert.Contains(t, footer, `data-mr-tabs`) | ||
| 143 | assert.Contains(t, footer, `id="mr-commits"`) | 146 | assert.Contains(t, footer, `id="mr-commits"`) | ||
| 144 | assert.Contains(t, footer, `id="mr-checks"`) | 147 | assert.Contains(t, footer, `id="mr-checks"`) | ||
M internal/adapters/web/static/css/60-mr.css +20 -9
| @@ -9,18 +9,29 @@ | |||
| 9 | 9 | } | |
| 10 | 10 | ||
| 11 | 11 | .mr-footer { | |
| 12 | - | border-top: 1px solid var(--border); padding: var(--s3) var(--s5); | |
| 13 | - | /* a touch darker than the card body; the card's overflow clips the corners */ | |
| 14 | - | background: var(--bg-canvas); | |
| 12 | + | border-top: 1px solid var(--border); padding: var(--s2) var(--s4); | |
| 13 | + | /* the shade of the card header; the card's overflow clips the corners */ | |
| 14 | + | background: var(--bg-raised); | |
| 15 | 15 | font-size: var(--fs-s); | |
| 16 | - | & nav { display: flex; gap: var(--s3); padding-block: var(--s3); } | |
| 17 | - | & [data-mr-tabs] a { padding: var(--s1) var(--s2); border-radius: var(--r-s); color: var(--ink-dim); cursor: pointer; } | |
| 18 | - | & [data-mr-tabs] a:hover { background: var(--bg-raised); color: var(--ink-hi); } | |
| 19 | - | & [data-mr-tabs] a[aria-expanded="true"] { background: var(--bg-strong); color: var(--ink-hi); } | |
| 16 | + | /* the tabs are buttons: a thin border, the count in a darker badge; | |
| 17 | + | the open one in the teal accent */ | |
| 18 | + | & nav { display: flex; flex-wrap: wrap; gap: var(--s2); } | |
| 19 | + | & [data-mr-tabs] a { | |
| 20 | + | display: inline-flex; align-items: center; gap: var(--s1); | |
| 21 | + | padding: 2px var(--s2); border: 1px solid var(--border-strong); border-radius: var(--r-s); | |
| 22 | + | color: var(--ink-dim); text-decoration: none; cursor: pointer; | |
| 23 | + | } | |
| 24 | + | & [data-checks-label] { color: var(--ink-dim); font-size: var(--fs-xs); } | |
| 25 | + | & [data-mr-tabs] .count { margin-left: 0; color: var(--ink-hi); background: var(--bg-canvas); box-shadow: inset 0 0 0 1px var(--border); } | |
| 26 | + | & [data-mr-tabs] a:hover { background: var(--bg-strong); color: var(--ink-hi); } | |
| 27 | + | & [data-mr-tabs] a[aria-expanded="true"] { border-color: var(--teal); color: var(--teal); background: var(--bg-surface); } | |
| 28 | + | & [data-mr-panel] { padding-block-start: var(--s2); } | |
| 20 | 29 | & [data-mr-panel] { overflow-x: auto; } | |
| 21 | 30 | & [hidden] { display: none; } | |
| 22 | - | /* Without scripts the panels are shown by the URL fragment alone. */ | |
| 23 | - | html:not(.js) & [data-mr-panel][hidden]:is(:target, :has(:target)) { display: block; } | |
| 31 | + | /* Until mr.js drives the panels (data-panels, then [hidden]), only the | |
| 32 | + | panel of the URL fragment shows: the tabs work as anchors without | |
| 33 | + | scripts, and no panel flashes open before the script runs. */ | |
| 34 | + | &:not([data-panels]) [data-mr-panel]:not(:target, :has(:target)) { display: none; } | |
| 24 | 35 | & section h2 { font-size: var(--fs-s); color: var(--ink-dim); } | |
| 25 | 36 | & table { width: 100%; border-collapse: collapse; } | |
| 26 | 37 | & th, & td { text-align: left; padding: var(--s2); border-bottom: 1px solid var(--border); } | |
| @@ -9,18 +9,29 @@ | |||||
| 9 | } | 9 | } | ||
| 10 | 10 | ||||
| 11 | .mr-footer { | 11 | .mr-footer { | ||
| 12 | - | border-top: 1px solid var(--border); padding: var(--s3) var(--s5); | 12 | + | border-top: 1px solid var(--border); padding: var(--s2) var(--s4); |
| 13 | - | /* a touch darker than the card body; the card's overflow clips the corners */ | 13 | + | /* the shade of the card header; the card's overflow clips the corners */ |
| 14 | - | background: var(--bg-canvas); | 14 | + | background: var(--bg-raised); |
| 15 | font-size: var(--fs-s); | 15 | font-size: var(--fs-s); | ||
| 16 | - | & nav { display: flex; gap: var(--s3); padding-block: var(--s3); } | 16 | + | /* the tabs are buttons: a thin border, the count in a darker badge; |
| 17 | - | & [data-mr-tabs] a { padding: var(--s1) var(--s2); border-radius: var(--r-s); color: var(--ink-dim); cursor: pointer; } | 17 | + | the open one in the teal accent */ |
| 18 | - | & [data-mr-tabs] a:hover { background: var(--bg-raised); color: var(--ink-hi); } | 18 | + | & nav { display: flex; flex-wrap: wrap; gap: var(--s2); } |
| 19 | - | & [data-mr-tabs] a[aria-expanded="true"] { background: var(--bg-strong); color: var(--ink-hi); } | 19 | + | & [data-mr-tabs] a { |
| 20 | + | display: inline-flex; align-items: center; gap: var(--s1); | |||
| 21 | + | padding: 2px var(--s2); border: 1px solid var(--border-strong); border-radius: var(--r-s); | |||
| 22 | + | color: var(--ink-dim); text-decoration: none; cursor: pointer; | |||
| 23 | + | } | |||
| 24 | + | & [data-checks-label] { color: var(--ink-dim); font-size: var(--fs-xs); } | |||
| 25 | + | & [data-mr-tabs] .count { margin-left: 0; color: var(--ink-hi); background: var(--bg-canvas); box-shadow: inset 0 0 0 1px var(--border); } | |||
| 26 | + | & [data-mr-tabs] a:hover { background: var(--bg-strong); color: var(--ink-hi); } | |||
| 27 | + | & [data-mr-tabs] a[aria-expanded="true"] { border-color: var(--teal); color: var(--teal); background: var(--bg-surface); } | |||
| 28 | + | & [data-mr-panel] { padding-block-start: var(--s2); } | |||
| 20 | & [data-mr-panel] { overflow-x: auto; } | 29 | & [data-mr-panel] { overflow-x: auto; } | ||
| 21 | & [hidden] { display: none; } | 30 | & [hidden] { display: none; } | ||
| 22 | - | /* Without scripts the panels are shown by the URL fragment alone. */ | 31 | + | /* Until mr.js drives the panels (data-panels, then [hidden]), only the |
| 23 | - | html:not(.js) & [data-mr-panel][hidden]:is(:target, :has(:target)) { display: block; } | 32 | + | panel of the URL fragment shows: the tabs work as anchors without |
| 33 | + | scripts, and no panel flashes open before the script runs. */ | |||
| 34 | + | &:not([data-panels]) [data-mr-panel]:not(:target, :has(:target)) { display: none; } | |||
| 24 | & section h2 { font-size: var(--fs-s); color: var(--ink-dim); } | 35 | & section h2 { font-size: var(--fs-s); color: var(--ink-dim); } | ||
| 25 | & table { width: 100%; border-collapse: collapse; } | 36 | & table { width: 100%; border-collapse: collapse; } | ||
| 26 | & th, & td { text-align: left; padding: var(--s2); border-bottom: 1px solid var(--border); } | 37 | & th, & td { text-align: left; padding: var(--s2); border-bottom: 1px solid var(--border); } | ||
M internal/adapters/web/static/js/mr.js +7 -2
| @@ -349,10 +349,13 @@ window.ermiteMR = (() => { | |||
| 349 | 349 | e.preventDefault(); tabs[next].focus(); | |
| 350 | 350 | }); | |
| 351 | 351 | }); | |
| 352 | + | // hash opens the panel of the URL fragment. Its target was hidden when the | |
| 353 | + | // browser scrolled, so the panel is brought in; never on a refresh, which | |
| 354 | + | // keeps the scroll. | |
| 352 | 355 | function hash() { | |
| 353 | 356 | const h = location.hash; | |
| 354 | 357 | const index = h === '#mr-checks' || h === '#mr-checks-panel' ? 1 : h === '#mr-commits' ? 0 : -1; | |
| 355 | - | if (index >= 0) show(index); | |
| 358 | + | if (index >= 0) { show(index); panels[index].scrollIntoView?.({ block: 'nearest' }); } | |
| 356 | 359 | } | |
| 357 | 360 | // The checks badge always opens Checks; it never folds it. | |
| 358 | 361 | const checksClick = (e) => { | |
| @@ -375,7 +378,9 @@ window.ermiteMR = (() => { | |||
| 375 | 378 | observer.observe(summary.parentElement, { childList: true }); | |
| 376 | 379 | sync(); | |
| 377 | 380 | } | |
| 378 | - | show(initial); hash(); | |
| 381 | + | show(initial); | |
| 382 | + | footer.setAttribute('data-panels', ''); | |
| 383 | + | if (initial < 0) hash(); | |
| 379 | 384 | addEventListener('hashchange', hash); | |
| 380 | 385 | disposePanels = () => { | |
| 381 | 386 | document.removeEventListener?.('click', checksClick); | |
| @@ -349,10 +349,13 @@ window.ermiteMR = (() => { | |||||
| 349 | e.preventDefault(); tabs[next].focus(); | 349 | e.preventDefault(); tabs[next].focus(); | ||
| 350 | }); | 350 | }); | ||
| 351 | }); | 351 | }); | ||
| 352 | + | // hash opens the panel of the URL fragment. Its target was hidden when the | |||
| 353 | + | // browser scrolled, so the panel is brought in; never on a refresh, which | |||
| 354 | + | // keeps the scroll. | |||
| 352 | function hash() { | 355 | function hash() { | ||
| 353 | const h = location.hash; | 356 | const h = location.hash; | ||
| 354 | const index = h === '#mr-checks' || h === '#mr-checks-panel' ? 1 : h === '#mr-commits' ? 0 : -1; | 357 | const index = h === '#mr-checks' || h === '#mr-checks-panel' ? 1 : h === '#mr-commits' ? 0 : -1; | ||
| 355 | - | if (index >= 0) show(index); | 358 | + | if (index >= 0) { show(index); panels[index].scrollIntoView?.({ block: 'nearest' }); } |
| 356 | } | 359 | } | ||
| 357 | // The checks badge always opens Checks; it never folds it. | 360 | // The checks badge always opens Checks; it never folds it. | ||
| 358 | const checksClick = (e) => { | 361 | const checksClick = (e) => { | ||
| @@ -375,7 +378,9 @@ window.ermiteMR = (() => { | |||||
| 375 | observer.observe(summary.parentElement, { childList: true }); | 378 | observer.observe(summary.parentElement, { childList: true }); | ||
| 376 | sync(); | 379 | sync(); | ||
| 377 | } | 380 | } | ||
| 378 | - | show(initial); hash(); | 381 | + | show(initial); |
| 382 | + | footer.setAttribute('data-panels', ''); | |||
| 383 | + | if (initial < 0) hash(); | |||
| 379 | addEventListener('hashchange', hash); | 384 | addEventListener('hashchange', hash); | ||
| 380 | disposePanels = () => { | 385 | disposePanels = () => { | ||
| 381 | document.removeEventListener?.('click', checksClick); | 386 | document.removeEventListener?.('click', checksClick); | ||