feat(web): turn the MR footer into toggle tabs on a darker background
- commit
- f525ac63ebda2eef85f9d3a5de537ac635fb7cac
- author
- brice · · 2026-10-20 08:50 UTC
- parent
- 89e7d82
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +649 -657
M docs/merge-requests.md +7 -5
| @@ -107,10 +107,12 @@ because it owns the merge-git port. | |||
| 107 | 107 | ||
| 108 | 108 | ### Review page panels | |
| 109 | 109 | ||
| 110 | - | The main request card ends with a collapsed Commits / Checks footer. With | |
| 111 | - | scripts, arrow keys (and Home / End) switch its tabs; without scripts, opening | |
| 112 | - | the footer shows both sections. The checks summary above the diff opens the | |
| 113 | - | Checks tab and continues to update live. Commit totals use `N+` when the branch | |
| 110 | + | The main request card ends with a footer of two tabs, Commits and Checks, both | |
| 111 | + | 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) | |
| 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 | |
| 115 | + | the diff opens the Checks panel and continues to update live. Commit totals use `N+` when the branch | |
| 114 | 116 | list is capped: the reader's existing count operation counts all reachable | |
| 115 | 117 | ancestors, not the source-minus-target range. | |
| 116 | 118 | ||
| @@ -123,7 +125,7 @@ merge actions remain restricted to their existing permissions. | |||
| 123 | 125 | ||
| 124 | 126 | A push to the source branch refreshes the web review in place: the diff, files | |
| 125 | 127 | rail, commits/checks footer, diff range and merge controls follow the new head. | |
| 126 | - | The review keeps its visible file/line, folded files and selected footer tab. | |
| 128 | + | The review keeps its visible file/line, folded files and open footer panel. | |
| 127 | 129 | The page request still reads only one 25-file window of hunks; the visible | |
| 128 | 130 | anchor's window is fetched too if needed, and other windows remain lazy. | |
| 129 | 131 | ||
| @@ -107,10 +107,12 @@ because it owns the merge-git port. | |||||
| 107 | 107 | ||||
| 108 | ### Review page panels | 108 | ### Review page panels | ||
| 109 | 109 | ||||
| 110 | - | The main request card ends with a collapsed Commits / Checks footer. With | 110 | + | The main request card ends with a footer of two tabs, Commits and Checks, both |
| 111 | - | scripts, arrow keys (and Home / End) switch its tabs; without scripts, opening | 111 | + | collapsed by default. Clicking a tab opens its panel, clicking the open tab |
| 112 | - | the footer shows both sections. The checks summary above the diff opens the | 112 | + | folds it again, clicking the other tab switches. Arrow keys (and Home / End) |
| 113 | - | Checks tab and continues to update live. Commit totals use `N+` when the branch | 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 | |||
| 115 | + | the diff opens the Checks panel and continues to update live. Commit totals use `N+` when the branch | |||
| 114 | list is capped: the reader's existing count operation counts all reachable | 116 | list is capped: the reader's existing count operation counts all reachable | ||
| 115 | ancestors, not the source-minus-target range. | 117 | ancestors, not the source-minus-target range. | ||
| 116 | 118 | ||||
| @@ -123,7 +125,7 @@ merge actions remain restricted to their existing permissions. | |||||
| 123 | 125 | ||||
| 124 | A push to the source branch refreshes the web review in place: the diff, files | 126 | A push to the source branch refreshes the web review in place: the diff, files | ||
| 125 | rail, commits/checks footer, diff range and merge controls follow the new head. | 127 | rail, commits/checks footer, diff range and merge controls follow the new head. | ||
| 126 | - | The review keeps its visible file/line, folded files and selected footer tab. | 128 | + | The review keeps its visible file/line, folded files and open footer panel. |
| 127 | The page request still reads only one 25-file window of hunks; the visible | 129 | The page request still reads only one 25-file window of hunks; the visible | ||
| 128 | anchor's window is fetched too if needed, and other windows remain lazy. | 130 | anchor's window is fetched too if needed, and other windows remain lazy. | ||
| 129 | 131 | ||||
M internal/adapters/web/jstest/mr.test.mjs +72 -72
| @@ -93,98 +93,98 @@ for (const dataset of [{diff:'split'}, {view:'split'}]) test(`reveal selects the | |||
| 93 | 93 | } | |
| 94 | 94 | }); | |
| 95 | 95 | ||
| 96 | - | test('footer tabs switch panels, support arrows, and open on checks hash', () => { | |
| 97 | - | const oldDocument = globalThis.document; | |
| 98 | - | const oldLocation = globalThis.location; | |
| 99 | - | const oldAdd = globalThis.addEventListener; | |
| 100 | - | const oldHistory = globalThis.history; | |
| 101 | - | const listeners = {}; | |
| 102 | - | const replaced = []; | |
| 103 | - | globalThis.history = { replaceState: (...args) => replaced.push(args) }; | |
| 96 | + | function footerHarness(extra = {}) { | |
| 97 | + | const saved = Object.fromEntries(['document', 'location', 'addEventListener', 'history', 'MutationObserver'].map((k) => [k, globalThis[k]])); | |
| 104 | 98 | const node = (id) => ({ id, attrs: {}, events: {}, hidden: false, | |
| 105 | - | setAttribute(k,v) { this.attrs[k]=v; }, | |
| 106 | - | addEventListener(k,v) { this.events[k]=v; }, focus() { this.focused=true; } }); | |
| 99 | + | setAttribute(k, v) { this.attrs[k] = v; }, getAttribute(k) { return this.attrs[k] ?? null; }, | |
| 100 | + | addEventListener(k, v) { this.events[k] = v; }, focus() { this.focused = true; } }); | |
| 107 | 101 | const tabs = [node('mr-tab-commits'), node('mr-tab-checks')]; | |
| 108 | 102 | const panels = [node('mr-commits'), node('mr-checks-panel')]; | |
| 109 | - | const footer = node('mr-panels'); footer.open=false; | |
| 110 | - | const nav = node('tabs'); nav.querySelectorAll=()=>tabs; | |
| 111 | - | footer.querySelectorAll=()=>panels; | |
| 112 | - | globalThis.document = {querySelector: (s) => s === '#mr-panels' ? footer : s === '[data-mr-tabs]' ? nav : null, | |
| 113 | - | addEventListener(k,v) {listeners[k]=v;} }; | |
| 114 | - | globalThis.location = {hash:''}; | |
| 115 | - | globalThis.addEventListener = (k,v) => {listeners[k]=v;}; | |
| 103 | + | const footer = node('footer'); footer.querySelectorAll = () => panels; | |
| 104 | + | const nav = node('tabs'); nav.querySelectorAll = () => tabs; | |
| 105 | + | const listeners = {}, replaced = []; | |
| 106 | + | globalThis.document = { querySelector: (s) => s === '.mr-footer' ? footer : s === '[data-mr-tabs]' ? nav : s === '#mr-ck' ? extra.summary ?? null : null, | |
| 107 | + | querySelectorAll: (s) => s === '[data-mr-tabs] a' ? tabs : [], addEventListener(k, v) { listeners[k] = v; } }; | |
| 108 | + | globalThis.location = { hash: '', pathname: '/demo/merge-requests/1', search: '?x=1' }; | |
| 109 | + | globalThis.addEventListener = (k, v) => { listeners[k] = v; }; | |
| 110 | + | globalThis.history = { replaceState: (...args) => replaced.push(args) }; | |
| 111 | + | const restore = () => { for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete globalThis[k]; else globalThis[k] = v; } }; | |
| 112 | + | const state = () => panels.map((p) => !p.hidden); | |
| 113 | + | return { tabs, panels, listeners, replaced, restore, state }; | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | test('footer tabs: closed by default, toggle, switch, arrows, hash', () => { | |
| 117 | + | const h = footerHarness(); | |
| 116 | 118 | try { | |
| 117 | 119 | window.ermiteMR.panels(); | |
| 118 | - | assert.equal(footer.open,false); | |
| 119 | - | assert.equal(panels[0].hidden,false); | |
| 120 | - | assert.equal(panels[1].hidden,true); | |
| 121 | - | assert.deepEqual(replaced, [], 'initial selection leaves the URL alone'); | |
| 122 | - | tabs[1].events.click({preventDefault(){}}); | |
| 123 | - | assert.deepEqual(replaced, [[null, '', '#mr-checks-panel']]); | |
| 124 | - | assert.equal(panels[0].hidden,true); | |
| 125 | - | assert.equal(tabs[1].attrs['aria-selected'],'true'); | |
| 126 | - | tabs[1].events.keydown({key:'ArrowRight',preventDefault(){}}); | |
| 127 | - | assert.equal(tabs[0].focused,true); | |
| 128 | - | assert.equal(panels[0].hidden,false); | |
| 129 | - | assert.deepEqual(replaced.at(-1), [null, '', '#mr-commits']); | |
| 130 | - | location.hash='#mr-checks'; replaced.length = 0; listeners.hashchange(); | |
| 131 | - | assert.deepEqual(replaced, [], 'hash navigation does not rewrite the URL'); | |
| 132 | - | assert.equal(footer.open,true); | |
| 133 | - | assert.equal(panels[1].hidden,false); | |
| 134 | - | footer.open=false; | |
| 135 | - | listeners.click({target:{closest:()=>true}}); | |
| 136 | - | assert.equal(footer.open,true); | |
| 137 | - | assert.equal(C.tabIndex('Home',1,2),0); | |
| 138 | - | assert.equal(C.tabIndex('End',0,2),1); | |
| 139 | - | assert.equal(C.tabIndex('ArrowLeft',0,2),1); | |
| 140 | - | assert.equal(C.tabIndex('Escape',0,2),null); | |
| 141 | - | } finally { | |
| 142 | - | globalThis.document=oldDocument; globalThis.location=oldLocation; globalThis.addEventListener=oldAdd; | |
| 143 | - | if (oldHistory === undefined) delete globalThis.history; else globalThis.history = oldHistory; | |
| 144 | - | } | |
| 120 | + | assert.deepEqual(h.state(), [false, false], 'initially closed'); | |
| 121 | + | assert.equal(h.tabs[0].attrs['aria-expanded'], 'false'); | |
| 122 | + | assert.deepEqual(h.replaced, [], 'initial state leaves the URL alone'); | |
| 123 | + | h.tabs[1].events.click({ preventDefault() {} }); | |
| 124 | + | assert.deepEqual(h.state(), [false, true]); | |
| 125 | + | assert.equal(h.tabs[1].attrs['aria-expanded'], 'true'); | |
| 126 | + | assert.deepEqual(h.replaced, [[null, '', '#mr-checks-panel']]); | |
| 127 | + | location.hash = '#mr-checks-panel'; | |
| 128 | + | h.tabs[1].events.click({ preventDefault() {} }); | |
| 129 | + | assert.deepEqual(h.state(), [false, false], 'a second click closes'); | |
| 130 | + | assert.deepEqual(h.replaced.at(-1), [null, '', '/demo/merge-requests/1?x=1'], 'closing clears the fragment'); | |
| 131 | + | location.hash = ''; | |
| 132 | + | h.tabs[1].events.click({ preventDefault() {} }); | |
| 133 | + | h.tabs[0].events.click({ preventDefault() {} }); | |
| 134 | + | assert.deepEqual(h.state(), [true, false], 'the other tab switches'); | |
| 135 | + | h.tabs[0].events.keydown({ key: 'ArrowRight', preventDefault() {} }); | |
| 136 | + | assert.equal(h.tabs[1].focused, true, 'arrows move focus only'); | |
| 137 | + | assert.deepEqual(h.state(), [true, false]); | |
| 138 | + | location.hash = '#mr-checks'; h.replaced.length = 0; h.listeners.hashchange(); | |
| 139 | + | assert.deepEqual(h.state(), [false, true], 'hash opens checks'); | |
| 140 | + | assert.deepEqual(h.replaced, [], 'hash navigation does not rewrite the URL'); | |
| 141 | + | location.hash = '#mr-commits'; h.listeners.hashchange(); | |
| 142 | + | assert.deepEqual(h.state(), [true, false]); | |
| 143 | + | assert.equal(C.tabIndex('Home', 1, 2), 0); | |
| 144 | + | assert.equal(C.tabIndex('End', 0, 2), 1); | |
| 145 | + | assert.equal(C.tabIndex('ArrowLeft', 0, 2), 1); | |
| 146 | + | assert.equal(C.tabIndex('Escape', 0, 2), null); | |
| 147 | + | } finally { h.restore(); } | |
| 145 | 148 | }); | |
| 146 | 149 | ||
| 147 | - | test('footer: #mr-ck syncs the checks label and a click opens the footer on Checks', () => { | |
| 148 | - | const saved = Object.fromEntries(['document', 'location', 'addEventListener', 'history', 'MutationObserver'].map((k) => [k, globalThis[k]])); | |
| 149 | - | const node = (id) => ({ id, attrs: {}, events: {}, hidden: false, | |
| 150 | - | setAttribute(k, v) { this.attrs[k] = v; }, addEventListener(k, v) { this.events[k] = v; }, focus() {} }); | |
| 151 | - | const tabs = [node('t0'), node('t1')]; | |
| 152 | - | const panels = [node('mr-commits'), node('mr-checks-panel')]; | |
| 153 | - | const footer = node('mr-panels'); footer.open = false; footer.querySelectorAll = () => panels; | |
| 154 | - | let writes = 0, text = ''; | |
| 155 | - | const label = { get textContent() { return text; }, set textContent(v) { writes++; text = v; } }; | |
| 156 | - | const nav = node('tabs'); nav.querySelectorAll = () => tabs; | |
| 157 | - | nav.querySelector = (s) => s === '[data-checks-label]' ? label : null; | |
| 150 | + | test('footer: #mr-ck syncs the checks label; its click opens Checks without toggling', () => { | |
| 151 | + | const text0 = { v: '' }; let writes = 0; | |
| 152 | + | const label = { get textContent() { return text0.v; }, set textContent(v) { writes++; text0.v = v; } }; | |
| 158 | 153 | const summary = { title: '1/2 failed', textContent: ' 1/2 ', parentElement: {} }; | |
| 159 | - | const listeners = {}; | |
| 154 | + | const h = footerHarness({ summary }); | |
| 160 | 155 | let observed = null, callback = null; | |
| 161 | 156 | globalThis.MutationObserver = class { constructor(fn) { callback = fn; } observe(target, opts) { observed = [target, opts]; } }; | |
| 162 | - | globalThis.document = { querySelector: (s) => s === '#mr-panels' ? footer : s === '[data-mr-tabs]' ? nav : s === '#mr-ck' ? summary : null, | |
| 163 | - | addEventListener(k, v) { listeners[k] = v; } }; | |
| 164 | - | globalThis.location = { hash: '' }; | |
| 165 | - | globalThis.addEventListener = () => {}; | |
| 166 | - | globalThis.history = { replaceState() {} }; | |
| 157 | + | const nav = document.querySelector('[data-mr-tabs]'); | |
| 158 | + | nav.querySelector = (s) => s === '[data-checks-label]' ? label : null; | |
| 167 | 159 | try { | |
| 168 | 160 | window.ermiteMR.panels(); | |
| 169 | 161 | assert.equal(label.textContent, '1/2 failed', 'initial label sync'); | |
| 170 | 162 | assert.equal(observed[0], summary.parentElement); | |
| 171 | - | // A mutation elsewhere in the bar (hx-live rewriting a label) must not | |
| 172 | - | // write the label again, or hx-live wakes up on its own write forever. | |
| 173 | 163 | assert.deepEqual(observed[1], { childList: true }, 'only #mr-ck replacements wake the observer'); | |
| 174 | 164 | const before = writes; callback(); callback(); | |
| 175 | 165 | assert.equal(writes, before, 'an unchanged label is not written'); | |
| 176 | 166 | summary.title = ''; callback(); | |
| 177 | - | assert.equal(label.textContent, '1/2', 'observer re-syncs from the text'); | |
| 167 | + | assert.equal(label.textContent, '1/2'); | |
| 178 | 168 | summary.textContent = ' '; callback(); | |
| 179 | 169 | assert.equal(label.textContent, 'no checks'); | |
| 180 | - | listeners.click({ target: { closest: (s) => s === '#mr-ck' ? summary : null } }); | |
| 181 | - | assert.equal(footer.open, true); | |
| 182 | - | assert.equal(panels[1].hidden, false); | |
| 183 | - | assert.equal(panels[0].hidden, true); | |
| 184 | - | assert.equal(tabs[1].attrs['aria-selected'], 'true'); | |
| 185 | - | } finally { | |
| 186 | - | for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete globalThis[k]; else globalThis[k] = v; } | |
| 187 | - | } | |
| 170 | + | const click = { target: { closest: (s) => s === '#mr-ck' ? summary : null } }; | |
| 171 | + | h.listeners.click(click); | |
| 172 | + | assert.deepEqual(h.state(), [false, true]); | |
| 173 | + | h.listeners.click(click); | |
| 174 | + | assert.deepEqual(h.state(), [false, true], 'a second badge click keeps it open'); | |
| 175 | + | } finally { h.restore(); } | |
| 176 | + | }); | |
| 177 | + | ||
| 178 | + | test('footer: panels(index) restores the open panel without touching the URL', () => { | |
| 179 | + | const h = footerHarness(); | |
| 180 | + | try { | |
| 181 | + | window.ermiteMR.panels(1); | |
| 182 | + | assert.deepEqual(h.state(), [false, true]); | |
| 183 | + | assert.equal(h.tabs[1].attrs['aria-expanded'], 'true'); | |
| 184 | + | assert.deepEqual(h.replaced, []); | |
| 185 | + | window.ermiteMR.panels(-1); | |
| 186 | + | assert.deepEqual(h.state(), [false, false]); | |
| 187 | + | } finally { h.restore(); } | |
| 188 | 188 | }); | |
| 189 | 189 | ||
| 190 | 190 | test('touch range: first tap anchors, second tap opens the sorted range', () => { | |
| @@ -93,98 +93,98 @@ for (const dataset of [{diff:'split'}, {view:'split'}]) test(`reveal selects the | |||||
| 93 | } | 93 | } | ||
| 94 | }); | 94 | }); | ||
| 95 | 95 | ||||
| 96 | - | test('footer tabs switch panels, support arrows, and open on checks hash', () => { | 96 | + | function footerHarness(extra = {}) { |
| 97 | - | const oldDocument = globalThis.document; | 97 | + | const saved = Object.fromEntries(['document', 'location', 'addEventListener', 'history', 'MutationObserver'].map((k) => [k, globalThis[k]])); |
| 98 | - | const oldLocation = globalThis.location; | |||
| 99 | - | const oldAdd = globalThis.addEventListener; | |||
| 100 | - | const oldHistory = globalThis.history; | |||
| 101 | - | const listeners = {}; | |||
| 102 | - | const replaced = []; | |||
| 103 | - | globalThis.history = { replaceState: (...args) => replaced.push(args) }; | |||
| 104 | const node = (id) => ({ id, attrs: {}, events: {}, hidden: false, | 98 | const node = (id) => ({ id, attrs: {}, events: {}, hidden: false, | ||
| 105 | - | setAttribute(k,v) { this.attrs[k]=v; }, | 99 | + | setAttribute(k, v) { this.attrs[k] = v; }, getAttribute(k) { return this.attrs[k] ?? null; }, |
| 106 | - | addEventListener(k,v) { this.events[k]=v; }, focus() { this.focused=true; } }); | 100 | + | addEventListener(k, v) { this.events[k] = v; }, focus() { this.focused = true; } }); |
| 107 | const tabs = [node('mr-tab-commits'), node('mr-tab-checks')]; | 101 | const tabs = [node('mr-tab-commits'), node('mr-tab-checks')]; | ||
| 108 | const panels = [node('mr-commits'), node('mr-checks-panel')]; | 102 | const panels = [node('mr-commits'), node('mr-checks-panel')]; | ||
| 109 | - | const footer = node('mr-panels'); footer.open=false; | 103 | + | const footer = node('footer'); footer.querySelectorAll = () => panels; |
| 110 | - | const nav = node('tabs'); nav.querySelectorAll=()=>tabs; | 104 | + | const nav = node('tabs'); nav.querySelectorAll = () => tabs; |
| 111 | - | footer.querySelectorAll=()=>panels; | 105 | + | const listeners = {}, replaced = []; |
| 112 | - | globalThis.document = {querySelector: (s) => s === '#mr-panels' ? footer : s === '[data-mr-tabs]' ? nav : null, | 106 | + | globalThis.document = { querySelector: (s) => s === '.mr-footer' ? footer : s === '[data-mr-tabs]' ? nav : s === '#mr-ck' ? extra.summary ?? null : null, |
| 113 | - | addEventListener(k,v) {listeners[k]=v;} }; | 107 | + | querySelectorAll: (s) => s === '[data-mr-tabs] a' ? tabs : [], addEventListener(k, v) { listeners[k] = v; } }; |
| 114 | - | globalThis.location = {hash:''}; | 108 | + | globalThis.location = { hash: '', pathname: '/demo/merge-requests/1', search: '?x=1' }; |
| 115 | - | globalThis.addEventListener = (k,v) => {listeners[k]=v;}; | 109 | + | globalThis.addEventListener = (k, v) => { listeners[k] = v; }; |
| 110 | + | globalThis.history = { replaceState: (...args) => replaced.push(args) }; | |||
| 111 | + | const restore = () => { for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete globalThis[k]; else globalThis[k] = v; } }; | |||
| 112 | + | const state = () => panels.map((p) => !p.hidden); | |||
| 113 | + | return { tabs, panels, listeners, replaced, restore, state }; | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | test('footer tabs: closed by default, toggle, switch, arrows, hash', () => { | |||
| 117 | + | const h = footerHarness(); | |||
| 116 | try { | 118 | try { | ||
| 117 | window.ermiteMR.panels(); | 119 | window.ermiteMR.panels(); | ||
| 118 | - | assert.equal(footer.open,false); | 120 | + | assert.deepEqual(h.state(), [false, false], 'initially closed'); |
| 119 | - | assert.equal(panels[0].hidden,false); | 121 | + | assert.equal(h.tabs[0].attrs['aria-expanded'], 'false'); |
| 120 | - | assert.equal(panels[1].hidden,true); | 122 | + | assert.deepEqual(h.replaced, [], 'initial state leaves the URL alone'); |
| 121 | - | assert.deepEqual(replaced, [], 'initial selection leaves the URL alone'); | 123 | + | h.tabs[1].events.click({ preventDefault() {} }); |
| 122 | - | tabs[1].events.click({preventDefault(){}}); | 124 | + | assert.deepEqual(h.state(), [false, true]); |
| 123 | - | assert.deepEqual(replaced, [[null, '', '#mr-checks-panel']]); | 125 | + | assert.equal(h.tabs[1].attrs['aria-expanded'], 'true'); |
| 124 | - | assert.equal(panels[0].hidden,true); | 126 | + | assert.deepEqual(h.replaced, [[null, '', '#mr-checks-panel']]); |
| 125 | - | assert.equal(tabs[1].attrs['aria-selected'],'true'); | 127 | + | location.hash = '#mr-checks-panel'; |
| 126 | - | tabs[1].events.keydown({key:'ArrowRight',preventDefault(){}}); | 128 | + | h.tabs[1].events.click({ preventDefault() {} }); |
| 127 | - | assert.equal(tabs[0].focused,true); | 129 | + | assert.deepEqual(h.state(), [false, false], 'a second click closes'); |
| 128 | - | assert.equal(panels[0].hidden,false); | 130 | + | assert.deepEqual(h.replaced.at(-1), [null, '', '/demo/merge-requests/1?x=1'], 'closing clears the fragment'); |
| 129 | - | assert.deepEqual(replaced.at(-1), [null, '', '#mr-commits']); | 131 | + | location.hash = ''; |
| 130 | - | location.hash='#mr-checks'; replaced.length = 0; listeners.hashchange(); | 132 | + | h.tabs[1].events.click({ preventDefault() {} }); |
| 131 | - | assert.deepEqual(replaced, [], 'hash navigation does not rewrite the URL'); | 133 | + | h.tabs[0].events.click({ preventDefault() {} }); |
| 132 | - | assert.equal(footer.open,true); | 134 | + | assert.deepEqual(h.state(), [true, false], 'the other tab switches'); |
| 133 | - | assert.equal(panels[1].hidden,false); | 135 | + | h.tabs[0].events.keydown({ key: 'ArrowRight', preventDefault() {} }); |
| 134 | - | footer.open=false; | 136 | + | assert.equal(h.tabs[1].focused, true, 'arrows move focus only'); |
| 135 | - | listeners.click({target:{closest:()=>true}}); | 137 | + | assert.deepEqual(h.state(), [true, false]); |
| 136 | - | assert.equal(footer.open,true); | 138 | + | location.hash = '#mr-checks'; h.replaced.length = 0; h.listeners.hashchange(); |
| 137 | - | assert.equal(C.tabIndex('Home',1,2),0); | 139 | + | assert.deepEqual(h.state(), [false, true], 'hash opens checks'); |
| 138 | - | assert.equal(C.tabIndex('End',0,2),1); | 140 | + | assert.deepEqual(h.replaced, [], 'hash navigation does not rewrite the URL'); |
| 139 | - | assert.equal(C.tabIndex('ArrowLeft',0,2),1); | 141 | + | location.hash = '#mr-commits'; h.listeners.hashchange(); |
| 140 | - | assert.equal(C.tabIndex('Escape',0,2),null); | 142 | + | assert.deepEqual(h.state(), [true, false]); |
| 141 | - | } finally { | 143 | + | assert.equal(C.tabIndex('Home', 1, 2), 0); |
| 142 | - | globalThis.document=oldDocument; globalThis.location=oldLocation; globalThis.addEventListener=oldAdd; | 144 | + | assert.equal(C.tabIndex('End', 0, 2), 1); |
| 143 | - | if (oldHistory === undefined) delete globalThis.history; else globalThis.history = oldHistory; | 145 | + | assert.equal(C.tabIndex('ArrowLeft', 0, 2), 1); |
| 144 | - | } | 146 | + | assert.equal(C.tabIndex('Escape', 0, 2), null); |
| 147 | + | } finally { h.restore(); } | |||
| 145 | }); | 148 | }); | ||
| 146 | 149 | ||||
| 147 | - | test('footer: #mr-ck syncs the checks label and a click opens the footer on Checks', () => { | 150 | + | test('footer: #mr-ck syncs the checks label; its click opens Checks without toggling', () => { |
| 148 | - | const saved = Object.fromEntries(['document', 'location', 'addEventListener', 'history', 'MutationObserver'].map((k) => [k, globalThis[k]])); | 151 | + | const text0 = { v: '' }; let writes = 0; |
| 149 | - | const node = (id) => ({ id, attrs: {}, events: {}, hidden: false, | 152 | + | const label = { get textContent() { return text0.v; }, set textContent(v) { writes++; text0.v = v; } }; |
| 150 | - | setAttribute(k, v) { this.attrs[k] = v; }, addEventListener(k, v) { this.events[k] = v; }, focus() {} }); | |||
| 151 | - | const tabs = [node('t0'), node('t1')]; | |||
| 152 | - | const panels = [node('mr-commits'), node('mr-checks-panel')]; | |||
| 153 | - | const footer = node('mr-panels'); footer.open = false; footer.querySelectorAll = () => panels; | |||
| 154 | - | let writes = 0, text = ''; | |||
| 155 | - | const label = { get textContent() { return text; }, set textContent(v) { writes++; text = v; } }; | |||
| 156 | - | const nav = node('tabs'); nav.querySelectorAll = () => tabs; | |||
| 157 | - | nav.querySelector = (s) => s === '[data-checks-label]' ? label : null; | |||
| 158 | const summary = { title: '1/2 failed', textContent: ' 1/2 ', parentElement: {} }; | 153 | const summary = { title: '1/2 failed', textContent: ' 1/2 ', parentElement: {} }; | ||
| 159 | - | const listeners = {}; | 154 | + | const h = footerHarness({ summary }); |
| 160 | let observed = null, callback = null; | 155 | let observed = null, callback = null; | ||
| 161 | globalThis.MutationObserver = class { constructor(fn) { callback = fn; } observe(target, opts) { observed = [target, opts]; } }; | 156 | globalThis.MutationObserver = class { constructor(fn) { callback = fn; } observe(target, opts) { observed = [target, opts]; } }; | ||
| 162 | - | globalThis.document = { querySelector: (s) => s === '#mr-panels' ? footer : s === '[data-mr-tabs]' ? nav : s === '#mr-ck' ? summary : null, | 157 | + | const nav = document.querySelector('[data-mr-tabs]'); |
| 163 | - | addEventListener(k, v) { listeners[k] = v; } }; | 158 | + | nav.querySelector = (s) => s === '[data-checks-label]' ? label : null; |
| 164 | - | globalThis.location = { hash: '' }; | |||
| 165 | - | globalThis.addEventListener = () => {}; | |||
| 166 | - | globalThis.history = { replaceState() {} }; | |||
| 167 | try { | 159 | try { | ||
| 168 | window.ermiteMR.panels(); | 160 | window.ermiteMR.panels(); | ||
| 169 | assert.equal(label.textContent, '1/2 failed', 'initial label sync'); | 161 | assert.equal(label.textContent, '1/2 failed', 'initial label sync'); | ||
| 170 | assert.equal(observed[0], summary.parentElement); | 162 | assert.equal(observed[0], summary.parentElement); | ||
| 171 | - | // A mutation elsewhere in the bar (hx-live rewriting a label) must not | |||
| 172 | - | // write the label again, or hx-live wakes up on its own write forever. | |||
| 173 | assert.deepEqual(observed[1], { childList: true }, 'only #mr-ck replacements wake the observer'); | 163 | assert.deepEqual(observed[1], { childList: true }, 'only #mr-ck replacements wake the observer'); | ||
| 174 | const before = writes; callback(); callback(); | 164 | const before = writes; callback(); callback(); | ||
| 175 | assert.equal(writes, before, 'an unchanged label is not written'); | 165 | assert.equal(writes, before, 'an unchanged label is not written'); | ||
| 176 | summary.title = ''; callback(); | 166 | summary.title = ''; callback(); | ||
| 177 | - | assert.equal(label.textContent, '1/2', 'observer re-syncs from the text'); | 167 | + | assert.equal(label.textContent, '1/2'); |
| 178 | summary.textContent = ' '; callback(); | 168 | summary.textContent = ' '; callback(); | ||
| 179 | assert.equal(label.textContent, 'no checks'); | 169 | assert.equal(label.textContent, 'no checks'); | ||
| 180 | - | listeners.click({ target: { closest: (s) => s === '#mr-ck' ? summary : null } }); | 170 | + | const click = { target: { closest: (s) => s === '#mr-ck' ? summary : null } }; |
| 181 | - | assert.equal(footer.open, true); | 171 | + | h.listeners.click(click); |
| 182 | - | assert.equal(panels[1].hidden, false); | 172 | + | assert.deepEqual(h.state(), [false, true]); |
| 183 | - | assert.equal(panels[0].hidden, true); | 173 | + | h.listeners.click(click); |
| 184 | - | assert.equal(tabs[1].attrs['aria-selected'], 'true'); | 174 | + | assert.deepEqual(h.state(), [false, true], 'a second badge click keeps it open'); |
| 185 | - | } finally { | 175 | + | } finally { h.restore(); } |
| 186 | - | for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete globalThis[k]; else globalThis[k] = v; } | 176 | + | }); |
| 187 | - | } | 177 | + | |
| 178 | + | test('footer: panels(index) restores the open panel without touching the URL', () => { | |||
| 179 | + | const h = footerHarness(); | |||
| 180 | + | try { | |||
| 181 | + | window.ermiteMR.panels(1); | |||
| 182 | + | assert.deepEqual(h.state(), [false, true]); | |||
| 183 | + | assert.equal(h.tabs[1].attrs['aria-expanded'], 'true'); | |||
| 184 | + | assert.deepEqual(h.replaced, []); | |||
| 185 | + | window.ermiteMR.panels(-1); | |||
| 186 | + | assert.deepEqual(h.state(), [false, false]); | |||
| 187 | + | } finally { h.restore(); } | |||
| 188 | }); | 188 | }); | ||
| 189 | 189 | ||||
| 190 | test('touch range: first tap anchors, second tap opens the sorted range', () => { | 190 | test('touch range: first tap anchors, second tap opens the sorted range', () => { | ||
M internal/adapters/web/mr.templ +51 -54
| @@ -378,60 +378,57 @@ templ commentForm(f CommentForm, suffix string) { | |||
| 378 | 378 | ||
| 379 | 379 | templ mrFooter(p MRPage) { | |
| 380 | 380 | <footer class="mr-footer"> | |
| 381 | - | <details id="mr-panels"> | |
| 382 | - | <summary>Commits <span class="count">{ p.CommitCount }</span> · Checks</summary> | |
| 383 | - | <nav data-mr-tabs aria-label="Branch details"> | |
| 384 | - | <a id="mr-tab-commits" href="#mr-commits">Commits <span class="count">{ p.CommitCount }</span></a> | |
| 385 | - | <a id="mr-tab-checks" href="#mr-checks"> | |
| 386 | - | { "Checks " } | |
| 387 | - | <span data-checks-label> | |
| 388 | - | if p.ChecksSum.Total > 0 { | |
| 389 | - | { p.ChecksSum.Title } | |
| 390 | - | } else { | |
| 391 | - | no checks | |
| 392 | - | } | |
| 393 | - | </span> | |
| 394 | - | </a> | |
| 395 | - | </nav> | |
| 396 | - | <div id="mr-commits" data-mr-panel aria-labelledby="mr-tab-commits"> | |
| 397 | - | <table class="mr-commits"> | |
| 398 | - | <thead><tr><th>Commit</th><th>Subject</th><th>Author</th><th>Date</th><th>Signature</th><th>Status</th></tr></thead> | |
| 399 | - | <tbody> | |
| 400 | - | for _, c := range p.Commits { | |
| 401 | - | <tr data-new?={ c.New }> | |
| 402 | - | <td><a href={ templ.SafeURL(c.Href) }>{ c.Short }</a></td> | |
| 403 | - | <td>{ c.Subject }</td> | |
| 404 | - | <td>{ c.Author }</td> | |
| 405 | - | <td> | |
| 406 | - | if c.WhenAt != "" { | |
| 407 | - | <time datetime={ c.WhenAt }>{ c.When }</time> | |
| 408 | - | } | |
| 409 | - | </td> | |
| 410 | - | <td> | |
| 411 | - | if c.Sig != nil { | |
| 412 | - | @signaturePill(c.Sig) | |
| 413 | - | } | |
| 414 | - | </td> | |
| 415 | - | <td> | |
| 416 | - | if c.New { | |
| 417 | - | <span>new</span> | |
| 418 | - | } | |
| 419 | - | </td> | |
| 420 | - | </tr> | |
| 421 | - | } | |
| 422 | - | </tbody> | |
| 423 | - | </table> | |
| 424 | - | if len(p.Commits) == 0 { | |
| 425 | - | <p class="dim">no commit to merge.</p> | |
| 426 | - | } | |
| 427 | - | if p.CommitsNote != "" { | |
| 428 | - | <p class="note">{ p.CommitsNote }</p> | |
| 429 | - | } | |
| 430 | - | </div> | |
| 431 | - | <div id="mr-checks-panel" data-mr-panel aria-labelledby="mr-tab-checks"> | |
| 432 | - | @MRChecks(p.Checks) | |
| 433 | - | </div> | |
| 434 | - | </details> | |
| 381 | + | <nav data-mr-tabs aria-label="Branch details"> | |
| 382 | + | <a id="mr-tab-commits" href="#mr-commits">Commits <span class="count">{ p.CommitCount }</span></a> | |
| 383 | + | <a id="mr-tab-checks" href="#mr-checks"> | |
| 384 | + | { "Checks " } | |
| 385 | + | <span data-checks-label> | |
| 386 | + | if p.ChecksSum.Total > 0 { | |
| 387 | + | { p.ChecksSum.Title } | |
| 388 | + | } else { | |
| 389 | + | no checks | |
| 390 | + | } | |
| 391 | + | </span> | |
| 392 | + | </a> | |
| 393 | + | </nav> | |
| 394 | + | <div id="mr-commits" data-mr-panel hidden aria-labelledby="mr-tab-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> | |
| 397 | + | <tbody> | |
| 398 | + | for _, c := range p.Commits { | |
| 399 | + | <tr data-new?={ c.New }> | |
| 400 | + | <td><a href={ templ.SafeURL(c.Href) }>{ c.Short }</a></td> | |
| 401 | + | <td>{ c.Subject }</td> | |
| 402 | + | <td>{ c.Author }</td> | |
| 403 | + | <td> | |
| 404 | + | if c.WhenAt != "" { | |
| 405 | + | <time datetime={ c.WhenAt }>{ c.When }</time> | |
| 406 | + | } | |
| 407 | + | </td> | |
| 408 | + | <td> | |
| 409 | + | if c.Sig != nil { | |
| 410 | + | @signaturePill(c.Sig) | |
| 411 | + | } | |
| 412 | + | </td> | |
| 413 | + | <td> | |
| 414 | + | if c.New { | |
| 415 | + | <span>new</span> | |
| 416 | + | } | |
| 417 | + | </td> | |
| 418 | + | </tr> | |
| 419 | + | } | |
| 420 | + | </tbody> | |
| 421 | + | </table> | |
| 422 | + | if len(p.Commits) == 0 { | |
| 423 | + | <p class="dim">no commit to merge.</p> | |
| 424 | + | } | |
| 425 | + | if p.CommitsNote != "" { | |
| 426 | + | <p class="note">{ p.CommitsNote }</p> | |
| 427 | + | } | |
| 428 | + | </div> | |
| 429 | + | <div id="mr-checks-panel" data-mr-panel hidden aria-labelledby="mr-tab-checks"> | |
| 430 | + | @MRChecks(p.Checks) | |
| 431 | + | </div> | |
| 435 | 432 | </footer> | |
| 436 | 433 | } | |
| 437 | 434 | ||
| @@ -378,60 +378,57 @@ templ commentForm(f CommentForm, suffix string) { | |||||
| 378 | 378 | ||||
| 379 | templ mrFooter(p MRPage) { | 379 | templ mrFooter(p MRPage) { | ||
| 380 | <footer class="mr-footer"> | 380 | <footer class="mr-footer"> | ||
| 381 | - | <details id="mr-panels"> | 381 | + | <nav data-mr-tabs aria-label="Branch details"> |
| 382 | - | <summary>Commits <span class="count">{ p.CommitCount }</span> · Checks</summary> | 382 | + | <a id="mr-tab-commits" href="#mr-commits">Commits <span class="count">{ p.CommitCount }</span></a> |
| 383 | - | <nav data-mr-tabs aria-label="Branch details"> | 383 | + | <a id="mr-tab-checks" href="#mr-checks"> |
| 384 | - | <a id="mr-tab-commits" href="#mr-commits">Commits <span class="count">{ p.CommitCount }</span></a> | 384 | + | { "Checks " } |
| 385 | - | <a id="mr-tab-checks" href="#mr-checks"> | 385 | + | <span data-checks-label> |
| 386 | - | { "Checks " } | 386 | + | if p.ChecksSum.Total > 0 { |
| 387 | - | <span data-checks-label> | 387 | + | { p.ChecksSum.Title } |
| 388 | - | if p.ChecksSum.Total > 0 { | 388 | + | } else { |
| 389 | - | { p.ChecksSum.Title } | 389 | + | no checks |
| 390 | - | } else { | 390 | + | } |
| 391 | - | no checks | 391 | + | </span> |
| 392 | - | } | 392 | + | </a> |
| 393 | - | </span> | 393 | + | </nav> |
| 394 | - | </a> | 394 | + | <div id="mr-commits" data-mr-panel hidden aria-labelledby="mr-tab-commits"> |
| 395 | - | </nav> | 395 | + | <table class="mr-commits"> |
| 396 | - | <div id="mr-commits" data-mr-panel aria-labelledby="mr-tab-commits"> | 396 | + | <thead><tr><th>Commit</th><th>Subject</th><th>Author</th><th>Date</th><th>Signature</th><th>Status</th></tr></thead> |
| 397 | - | <table class="mr-commits"> | 397 | + | <tbody> |
| 398 | - | <thead><tr><th>Commit</th><th>Subject</th><th>Author</th><th>Date</th><th>Signature</th><th>Status</th></tr></thead> | 398 | + | for _, c := range p.Commits { |
| 399 | - | <tbody> | 399 | + | <tr data-new?={ c.New }> |
| 400 | - | for _, c := range p.Commits { | 400 | + | <td><a href={ templ.SafeURL(c.Href) }>{ c.Short }</a></td> |
| 401 | - | <tr data-new?={ c.New }> | 401 | + | <td>{ c.Subject }</td> |
| 402 | - | <td><a href={ templ.SafeURL(c.Href) }>{ c.Short }</a></td> | 402 | + | <td>{ c.Author }</td> |
| 403 | - | <td>{ c.Subject }</td> | 403 | + | <td> |
| 404 | - | <td>{ c.Author }</td> | 404 | + | if c.WhenAt != "" { |
| 405 | - | <td> | 405 | + | <time datetime={ c.WhenAt }>{ c.When }</time> |
| 406 | - | if c.WhenAt != "" { | 406 | + | } |
| 407 | - | <time datetime={ c.WhenAt }>{ c.When }</time> | 407 | + | </td> |
| 408 | - | } | 408 | + | <td> |
| 409 | - | </td> | 409 | + | if c.Sig != nil { |
| 410 | - | <td> | 410 | + | @signaturePill(c.Sig) |
| 411 | - | if c.Sig != nil { | 411 | + | } |
| 412 | - | @signaturePill(c.Sig) | 412 | + | </td> |
| 413 | - | } | 413 | + | <td> |
| 414 | - | </td> | 414 | + | if c.New { |
| 415 | - | <td> | 415 | + | <span>new</span> |
| 416 | - | if c.New { | 416 | + | } |
| 417 | - | <span>new</span> | 417 | + | </td> |
| 418 | - | } | 418 | + | </tr> |
| 419 | - | </td> | 419 | + | } |
| 420 | - | </tr> | 420 | + | </tbody> |
| 421 | - | } | 421 | + | </table> |
| 422 | - | </tbody> | 422 | + | if len(p.Commits) == 0 { |
| 423 | - | </table> | 423 | + | <p class="dim">no commit to merge.</p> |
| 424 | - | if len(p.Commits) == 0 { | 424 | + | } |
| 425 | - | <p class="dim">no commit to merge.</p> | 425 | + | if p.CommitsNote != "" { |
| 426 | - | } | 426 | + | <p class="note">{ p.CommitsNote }</p> |
| 427 | - | if p.CommitsNote != "" { | 427 | + | } |
| 428 | - | <p class="note">{ p.CommitsNote }</p> | 428 | + | </div> |
| 429 | - | } | 429 | + | <div id="mr-checks-panel" data-mr-panel hidden aria-labelledby="mr-tab-checks"> |
| 430 | - | </div> | 430 | + | @MRChecks(p.Checks) |
| 431 | - | <div id="mr-checks-panel" data-mr-panel aria-labelledby="mr-tab-checks"> | 431 | + | </div> |
| 432 | - | @MRChecks(p.Checks) | |||
| 433 | - | </div> | |||
| 434 | - | </details> | |||
| 435 | </footer> | 432 | </footer> | ||
| 436 | } | 433 | } | ||
| 437 | 434 | ||||
M internal/adapters/web/mr_templ.go +480 -493 generated file: collapsed
M internal/adapters/web/mr_test.go +5 -1
| @@ -134,7 +134,11 @@ func TestMRPanelsAndMenus(t *testing.T) { | |||
| 134 | 134 | p := MRPage{CommitCount: "500+", CommitsNote: "more commits", Modes: []string{"squash", "merge"}, ChecksBlock: true, | |
| 135 | 135 | ChecksSum: ChecksSummary{Total: 2, Label: "1/2", Title: "1/2 failed"}, Box: &MergeBox{Signed: true, Fingerprint: "fingerprint", HeadGone: true}} | |
| 136 | 136 | footer := renderToString(t, mrFooter(p)) | |
| 137 | - | assert.Contains(t, footer, `<footer class="mr-footer"><details id="mr-panels">`) | |
| 137 | + | assert.Contains(t, footer, `<footer class="mr-footer"><nav data-mr-tabs`) | |
| 138 | + | assert.NotContains(t, footer, `<details`) | |
| 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`) | |
| 138 | 142 | assert.Contains(t, footer, `data-mr-tabs`) | |
| 139 | 143 | assert.Contains(t, footer, `id="mr-commits"`) | |
| 140 | 144 | assert.Contains(t, footer, `id="mr-checks"`) | |
| @@ -134,7 +134,11 @@ func TestMRPanelsAndMenus(t *testing.T) { | |||||
| 134 | p := MRPage{CommitCount: "500+", CommitsNote: "more commits", Modes: []string{"squash", "merge"}, ChecksBlock: true, | 134 | p := MRPage{CommitCount: "500+", CommitsNote: "more commits", Modes: []string{"squash", "merge"}, ChecksBlock: true, | ||
| 135 | ChecksSum: ChecksSummary{Total: 2, Label: "1/2", Title: "1/2 failed"}, Box: &MergeBox{Signed: true, Fingerprint: "fingerprint", HeadGone: true}} | 135 | ChecksSum: ChecksSummary{Total: 2, Label: "1/2", Title: "1/2 failed"}, Box: &MergeBox{Signed: true, Fingerprint: "fingerprint", HeadGone: true}} | ||
| 136 | footer := renderToString(t, mrFooter(p)) | 136 | footer := renderToString(t, mrFooter(p)) | ||
| 137 | - | assert.Contains(t, footer, `<footer class="mr-footer"><details id="mr-panels">`) | 137 | + | assert.Contains(t, footer, `<footer class="mr-footer"><nav data-mr-tabs`) |
| 138 | + | assert.NotContains(t, footer, `<details`) | |||
| 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`) | |||
| 138 | assert.Contains(t, footer, `data-mr-tabs`) | 142 | assert.Contains(t, footer, `data-mr-tabs`) | ||
| 139 | assert.Contains(t, footer, `id="mr-commits"`) | 143 | assert.Contains(t, footer, `id="mr-commits"`) | ||
| 140 | assert.Contains(t, footer, `id="mr-checks"`) | 144 | assert.Contains(t, footer, `id="mr-checks"`) | ||
M internal/adapters/web/static/css/60-mr.css +8 -3
| @@ -10,17 +10,22 @@ | |||
| 10 | 10 | ||
| 11 | 11 | .mr-footer { | |
| 12 | 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); | |
| 13 | 15 | font-size: var(--fs-s); | |
| 14 | - | & summary { cursor: pointer; color: var(--ink-dim); } | |
| 15 | 16 | & nav { display: flex; gap: var(--s3); padding-block: var(--s3); } | |
| 16 | - | & [role="tab"] { padding: var(--s1) var(--s2); border-radius: var(--r-s); } | |
| 17 | - | & [aria-selected="true"] { background: var(--bg-raised); color: var(--ink-hi); } | |
| 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); } | |
| 18 | 20 | & [data-mr-panel] { overflow-x: auto; } | |
| 19 | 21 | & [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; } | |
| 20 | 24 | & section h2 { font-size: var(--fs-s); color: var(--ink-dim); } | |
| 21 | 25 | & table { width: 100%; border-collapse: collapse; } | |
| 22 | 26 | & th, & td { text-align: left; padding: var(--s2); border-bottom: 1px solid var(--border); } | |
| 23 | 27 | & th { color: var(--ink-dim); font-weight: 500; } | |
| 28 | + | & tbody tr:last-child > :is(td, th) { border-bottom: 0; } | |
| 24 | 29 | } | |
| 25 | 30 | .merge-settings { font-size: var(--fs-s); color: var(--ink-dim); } | |
| 26 | 31 | .merge-settings h2 { font-size: var(--fs-s); } | |
| @@ -10,17 +10,22 @@ | |||||
| 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(--s3) var(--s5); | ||
| 13 | + | /* a touch darker than the card body; the card's overflow clips the corners */ | |||
| 14 | + | background: var(--bg-canvas); | |||
| 13 | font-size: var(--fs-s); | 15 | font-size: var(--fs-s); | ||
| 14 | - | & summary { cursor: pointer; color: var(--ink-dim); } | |||
| 15 | & nav { display: flex; gap: var(--s3); padding-block: var(--s3); } | 16 | & nav { display: flex; gap: var(--s3); padding-block: var(--s3); } | ||
| 16 | - | & [role="tab"] { padding: var(--s1) var(--s2); border-radius: var(--r-s); } | 17 | + | & [data-mr-tabs] a { padding: var(--s1) var(--s2); border-radius: var(--r-s); color: var(--ink-dim); cursor: pointer; } |
| 17 | - | & [aria-selected="true"] { background: var(--bg-raised); color: var(--ink-hi); } | 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); } | |||
| 18 | & [data-mr-panel] { overflow-x: auto; } | 20 | & [data-mr-panel] { overflow-x: auto; } | ||
| 19 | & [hidden] { display: none; } | 21 | & [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; } | |||
| 20 | & section h2 { font-size: var(--fs-s); color: var(--ink-dim); } | 24 | & section h2 { font-size: var(--fs-s); color: var(--ink-dim); } | ||
| 21 | & table { width: 100%; border-collapse: collapse; } | 25 | & table { width: 100%; border-collapse: collapse; } | ||
| 22 | & th, & td { text-align: left; padding: var(--s2); border-bottom: 1px solid var(--border); } | 26 | & th, & td { text-align: left; padding: var(--s2); border-bottom: 1px solid var(--border); } | ||
| 23 | & th { color: var(--ink-dim); font-weight: 500; } | 27 | & th { color: var(--ink-dim); font-weight: 500; } | ||
| 28 | + | & tbody tr:last-child > :is(td, th) { border-bottom: 0; } | |||
| 24 | } | 29 | } | ||
| 25 | .merge-settings { font-size: var(--fs-s); color: var(--ink-dim); } | 30 | .merge-settings { font-size: var(--fs-s); color: var(--ink-dim); } | ||
| 26 | .merge-settings h2 { font-size: var(--fs-s); } | 31 | .merge-settings h2 { font-size: var(--fs-s); } | ||
M internal/adapters/web/static/css/85-mobile.css +0 -2
| @@ -49,7 +49,5 @@ | |||
| 49 | 49 | body:has(#diffs[data-mr]) :is(.cform .btn, .menu .btn, .mr-touch-range .btn, .c-act button) { min-height: 40px; height: auto; padding-block: var(--s2); } | |
| 50 | 50 | #diffs[data-mr] .cform { scroll-margin-block: calc(var(--hdr-h) + 12px); } | |
| 51 | 51 | .mr-footer [data-mr-tabs] a { display: inline-flex; align-items: center; min-height: 40px; } | |
| 52 | - | /* No display change: the summary stays a list-item and keeps its marker. */ | |
| 53 | - | .mr-footer summary { min-height: 40px; line-height: 40px; } | |
| 54 | 52 | } | |
| 55 | 53 | } | |
| @@ -49,7 +49,5 @@ | |||||
| 49 | body:has(#diffs[data-mr]) :is(.cform .btn, .menu .btn, .mr-touch-range .btn, .c-act button) { min-height: 40px; height: auto; padding-block: var(--s2); } | 49 | body:has(#diffs[data-mr]) :is(.cform .btn, .menu .btn, .mr-touch-range .btn, .c-act button) { min-height: 40px; height: auto; padding-block: var(--s2); } | ||
| 50 | #diffs[data-mr] .cform { scroll-margin-block: calc(var(--hdr-h) + 12px); } | 50 | #diffs[data-mr] .cform { scroll-margin-block: calc(var(--hdr-h) + 12px); } | ||
| 51 | .mr-footer [data-mr-tabs] a { display: inline-flex; align-items: center; min-height: 40px; } | 51 | .mr-footer [data-mr-tabs] a { display: inline-flex; align-items: center; min-height: 40px; } | ||
| 52 | - | /* No display change: the summary stays a list-item and keeps its marker. */ | |||
| 53 | - | .mr-footer summary { min-height: 40px; line-height: 40px; } | |||
| 54 | } | 52 | } | ||
| 55 | } | 53 | } | ||
M internal/adapters/web/static/js/mr.js +26 -27
| @@ -315,42 +315,48 @@ window.ermiteMR = (() => { | |||
| 315 | 315 | ||
| 316 | 316 | // Enhance the ordinary anchor navigation only when scripts are available. | |
| 317 | 317 | let disposePanels = () => {}; | |
| 318 | - | function panels() { | |
| 318 | + | // The tabs are disclosure buttons (aria-expanded + aria-controls), not a | |
| 319 | + | // tablist: a tablist always has a selected tab, and here every panel can be | |
| 320 | + | // folded. State: the index of the open panel, or -1 for none. | |
| 321 | + | function panels(initial = -1) { | |
| 319 | 322 | disposePanels(); | |
| 320 | - | const footer = $('#mr-panels'), nav = $('[data-mr-tabs]'); | |
| 323 | + | const footer = $('.mr-footer'), nav = $('[data-mr-tabs]'); | |
| 321 | 324 | if (!footer || !nav) return; | |
| 322 | 325 | const tabs = $$('a', nav), panels = $$('[data-mr-panel]', footer); | |
| 323 | - | nav.setAttribute('role', 'tablist'); | |
| 324 | - | function select(index, focus = false, fromUser = false) { | |
| 326 | + | const ids = new Set(['#mr-commits', '#mr-checks', '#mr-checks-panel']); | |
| 327 | + | let current = -1; | |
| 328 | + | // show applies the state; fromUser also keeps the URL fragment in step. | |
| 329 | + | function show(index, fromUser = false) { | |
| 330 | + | current = index; | |
| 325 | 331 | tabs.forEach((tab, i) => { | |
| 326 | - | tab.setAttribute('role', 'tab'); | |
| 327 | 332 | tab.setAttribute('aria-controls', panels[i].id); | |
| 328 | - | tab.setAttribute('aria-selected', String(i === index)); | |
| 329 | - | tab.tabIndex = i === index ? 0 : -1; | |
| 330 | - | panels[i].setAttribute('role', 'tabpanel'); | |
| 331 | - | panels[i].tabIndex = 0; | |
| 333 | + | tab.setAttribute('aria-expanded', String(i === index)); | |
| 332 | 334 | panels[i].hidden = i !== index; | |
| 333 | 335 | }); | |
| 334 | - | if (focus) tabs[index].focus(); | |
| 335 | - | if (fromUser && location.hash !== '#' + panels[index].id) history.replaceState(null, '', '#' + panels[index].id); | |
| 336 | + | if (!fromUser) return; | |
| 337 | + | if (index >= 0) { | |
| 338 | + | if (location.hash !== '#' + panels[index].id) history.replaceState(null, '', '#' + panels[index].id); | |
| 339 | + | } else if (ids.has(location.hash)) { | |
| 340 | + | history.replaceState(null, '', location.pathname + location.search); | |
| 341 | + | } | |
| 336 | 342 | } | |
| 343 | + | const toggle = (i) => show(i === current ? -1 : i, true); | |
| 337 | 344 | tabs.forEach((tab, i) => { | |
| 338 | - | tab.addEventListener('click', (e) => { e.preventDefault(); select(i, false, true); }); | |
| 345 | + | tab.addEventListener('click', (e) => { e.preventDefault(); toggle(i); }); | |
| 339 | 346 | tab.addEventListener('keydown', (e) => { | |
| 340 | 347 | const next = core.tabIndex(e.key, i, tabs.length); | |
| 341 | 348 | if (next === null) return; | |
| 342 | - | e.preventDefault(); select(next, true, true); | |
| 349 | + | e.preventDefault(); tabs[next].focus(); | |
| 343 | 350 | }); | |
| 344 | 351 | }); | |
| 345 | 352 | function hash() { | |
| 346 | 353 | const h = location.hash; | |
| 347 | 354 | const index = h === '#mr-checks' || h === '#mr-checks-panel' ? 1 : h === '#mr-commits' ? 0 : -1; | |
| 348 | - | if (index < 0) return; | |
| 349 | - | footer.open = true; select(index); | |
| 355 | + | if (index >= 0) show(index); | |
| 350 | 356 | } | |
| 351 | - | // A repeated click on the same hash must also reopen a folded footer. | |
| 357 | + | // The checks badge always opens Checks; it never folds it. | |
| 352 | 358 | const checksClick = (e) => { | |
| 353 | - | if (e.target.closest?.('#mr-ck')) { footer.open = true; select(1); } | |
| 359 | + | if (e.target.closest?.('#mr-ck')) show(1); | |
| 354 | 360 | }; | |
| 355 | 361 | document.addEventListener('click', checksClick); | |
| 356 | 362 | let observer; | |
| @@ -369,7 +375,7 @@ window.ermiteMR = (() => { | |||
| 369 | 375 | observer.observe(summary.parentElement, { childList: true }); | |
| 370 | 376 | sync(); | |
| 371 | 377 | } | |
| 372 | - | select(0); hash(); | |
| 378 | + | show(initial); hash(); | |
| 373 | 379 | addEventListener('hashchange', hash); | |
| 374 | 380 | disposePanels = () => { | |
| 375 | 381 | document.removeEventListener?.('click', checksClick); | |
| @@ -583,8 +589,7 @@ window.ermiteMR = (() => { | |||
| 583 | 589 | const files = new Map($$('#diffs details.dfile').map((f) => [f.dataset.file, f.open])); | |
| 584 | 590 | const folded = $('.r-files')?.hasAttribute('data-folded'); | |
| 585 | 591 | const railY = $('.rail')?.scrollTop, filesY = $('.r-files .files')?.scrollTop; | |
| 586 | - | const footerOpen = $('#mr-panels')?.open; | |
| 587 | - | const tab = $('[data-mr-tabs] [aria-selected="true"]')?.id; | |
| 592 | + | const openPanel = $$('[data-mr-tabs] a').findIndex((t) => t.getAttribute('aria-expanded') === 'true'); | |
| 588 | 593 | const active = document.activeElement; | |
| 589 | 594 | const focus = active && { file: active.closest?.('.files a')?.getAttribute('title'), region: regions.find((sel) => $(sel)?.contains(active)), menu: active.closest?.('details.menu')?.id, id: active.id, tag: active.tagName, name: active.getAttribute('name'), href: active.getAttribute('href') }; | |
| 590 | 595 | for (const f of $$('#diffs details.dfile', page)) if (files.has(f.dataset.file)) f.open = files.get(f.dataset.file); | |
| @@ -600,13 +605,7 @@ window.ermiteMR = (() => { | |||
| 600 | 605 | const oldSHA = $('.hdr .ref .sha'), newSHA = $('.hdr .ref .sha', page); | |
| 601 | 606 | if (oldSHA && newSHA) oldSHA.textContent = newSHA.textContent; | |
| 602 | 607 | page = null; | |
| 603 | - | panels(); | |
| 604 | - | if (tab) { | |
| 605 | - | const oldURL = location.href; | |
| 606 | - | document.getElementById(tab)?.click(); | |
| 607 | - | if (location.href !== oldURL) history.replaceState(history.state, '', oldURL); | |
| 608 | - | } | |
| 609 | - | if ($('#mr-panels')) $('#mr-panels').open = footerOpen; | |
| 608 | + | panels(openPanel); | |
| 610 | 609 | foldFiles(!!folded); | |
| 611 | 610 | if ($('.rail')) $('.rail').scrollTop = railY; | |
| 612 | 611 | if ($('.r-files .files')) $('.r-files .files').scrollTop = filesY; | |
| @@ -315,42 +315,48 @@ window.ermiteMR = (() => { | |||||
| 315 | 315 | ||||
| 316 | // Enhance the ordinary anchor navigation only when scripts are available. | 316 | // Enhance the ordinary anchor navigation only when scripts are available. | ||
| 317 | let disposePanels = () => {}; | 317 | let disposePanels = () => {}; | ||
| 318 | - | function panels() { | 318 | + | // The tabs are disclosure buttons (aria-expanded + aria-controls), not a |
| 319 | + | // tablist: a tablist always has a selected tab, and here every panel can be | |||
| 320 | + | // folded. State: the index of the open panel, or -1 for none. | |||
| 321 | + | function panels(initial = -1) { | |||
| 319 | disposePanels(); | 322 | disposePanels(); | ||
| 320 | - | const footer = $('#mr-panels'), nav = $('[data-mr-tabs]'); | 323 | + | const footer = $('.mr-footer'), nav = $('[data-mr-tabs]'); |
| 321 | if (!footer || !nav) return; | 324 | if (!footer || !nav) return; | ||
| 322 | const tabs = $$('a', nav), panels = $$('[data-mr-panel]', footer); | 325 | const tabs = $$('a', nav), panels = $$('[data-mr-panel]', footer); | ||
| 323 | - | nav.setAttribute('role', 'tablist'); | 326 | + | const ids = new Set(['#mr-commits', '#mr-checks', '#mr-checks-panel']); |
| 324 | - | function select(index, focus = false, fromUser = false) { | 327 | + | let current = -1; |
| 328 | + | // show applies the state; fromUser also keeps the URL fragment in step. | |||
| 329 | + | function show(index, fromUser = false) { | |||
| 330 | + | current = index; | |||
| 325 | tabs.forEach((tab, i) => { | 331 | tabs.forEach((tab, i) => { | ||
| 326 | - | tab.setAttribute('role', 'tab'); | |||
| 327 | tab.setAttribute('aria-controls', panels[i].id); | 332 | tab.setAttribute('aria-controls', panels[i].id); | ||
| 328 | - | tab.setAttribute('aria-selected', String(i === index)); | 333 | + | tab.setAttribute('aria-expanded', String(i === index)); |
| 329 | - | tab.tabIndex = i === index ? 0 : -1; | |||
| 330 | - | panels[i].setAttribute('role', 'tabpanel'); | |||
| 331 | - | panels[i].tabIndex = 0; | |||
| 332 | panels[i].hidden = i !== index; | 334 | panels[i].hidden = i !== index; | ||
| 333 | }); | 335 | }); | ||
| 334 | - | if (focus) tabs[index].focus(); | 336 | + | if (!fromUser) return; |
| 335 | - | if (fromUser && location.hash !== '#' + panels[index].id) history.replaceState(null, '', '#' + panels[index].id); | 337 | + | if (index >= 0) { |
| 338 | + | if (location.hash !== '#' + panels[index].id) history.replaceState(null, '', '#' + panels[index].id); | |||
| 339 | + | } else if (ids.has(location.hash)) { | |||
| 340 | + | history.replaceState(null, '', location.pathname + location.search); | |||
| 341 | + | } | |||
| 336 | } | 342 | } | ||
| 343 | + | const toggle = (i) => show(i === current ? -1 : i, true); | |||
| 337 | tabs.forEach((tab, i) => { | 344 | tabs.forEach((tab, i) => { | ||
| 338 | - | tab.addEventListener('click', (e) => { e.preventDefault(); select(i, false, true); }); | 345 | + | tab.addEventListener('click', (e) => { e.preventDefault(); toggle(i); }); |
| 339 | tab.addEventListener('keydown', (e) => { | 346 | tab.addEventListener('keydown', (e) => { | ||
| 340 | const next = core.tabIndex(e.key, i, tabs.length); | 347 | const next = core.tabIndex(e.key, i, tabs.length); | ||
| 341 | if (next === null) return; | 348 | if (next === null) return; | ||
| 342 | - | e.preventDefault(); select(next, true, true); | 349 | + | e.preventDefault(); tabs[next].focus(); |
| 343 | }); | 350 | }); | ||
| 344 | }); | 351 | }); | ||
| 345 | function hash() { | 352 | function hash() { | ||
| 346 | const h = location.hash; | 353 | const h = location.hash; | ||
| 347 | const index = h === '#mr-checks' || h === '#mr-checks-panel' ? 1 : h === '#mr-commits' ? 0 : -1; | 354 | const index = h === '#mr-checks' || h === '#mr-checks-panel' ? 1 : h === '#mr-commits' ? 0 : -1; | ||
| 348 | - | if (index < 0) return; | 355 | + | if (index >= 0) show(index); |
| 349 | - | footer.open = true; select(index); | |||
| 350 | } | 356 | } | ||
| 351 | - | // A repeated click on the same hash must also reopen a folded footer. | 357 | + | // The checks badge always opens Checks; it never folds it. |
| 352 | const checksClick = (e) => { | 358 | const checksClick = (e) => { | ||
| 353 | - | if (e.target.closest?.('#mr-ck')) { footer.open = true; select(1); } | 359 | + | if (e.target.closest?.('#mr-ck')) show(1); |
| 354 | }; | 360 | }; | ||
| 355 | document.addEventListener('click', checksClick); | 361 | document.addEventListener('click', checksClick); | ||
| 356 | let observer; | 362 | let observer; | ||
| @@ -369,7 +375,7 @@ window.ermiteMR = (() => { | |||||
| 369 | observer.observe(summary.parentElement, { childList: true }); | 375 | observer.observe(summary.parentElement, { childList: true }); | ||
| 370 | sync(); | 376 | sync(); | ||
| 371 | } | 377 | } | ||
| 372 | - | select(0); hash(); | 378 | + | show(initial); hash(); |
| 373 | addEventListener('hashchange', hash); | 379 | addEventListener('hashchange', hash); | ||
| 374 | disposePanels = () => { | 380 | disposePanels = () => { | ||
| 375 | document.removeEventListener?.('click', checksClick); | 381 | document.removeEventListener?.('click', checksClick); | ||
| @@ -583,8 +589,7 @@ window.ermiteMR = (() => { | |||||
| 583 | const files = new Map($$('#diffs details.dfile').map((f) => [f.dataset.file, f.open])); | 589 | const files = new Map($$('#diffs details.dfile').map((f) => [f.dataset.file, f.open])); | ||
| 584 | const folded = $('.r-files')?.hasAttribute('data-folded'); | 590 | const folded = $('.r-files')?.hasAttribute('data-folded'); | ||
| 585 | const railY = $('.rail')?.scrollTop, filesY = $('.r-files .files')?.scrollTop; | 591 | const railY = $('.rail')?.scrollTop, filesY = $('.r-files .files')?.scrollTop; | ||
| 586 | - | const footerOpen = $('#mr-panels')?.open; | 592 | + | const openPanel = $$('[data-mr-tabs] a').findIndex((t) => t.getAttribute('aria-expanded') === 'true'); |
| 587 | - | const tab = $('[data-mr-tabs] [aria-selected="true"]')?.id; | |||
| 588 | const active = document.activeElement; | 593 | const active = document.activeElement; | ||
| 589 | const focus = active && { file: active.closest?.('.files a')?.getAttribute('title'), region: regions.find((sel) => $(sel)?.contains(active)), menu: active.closest?.('details.menu')?.id, id: active.id, tag: active.tagName, name: active.getAttribute('name'), href: active.getAttribute('href') }; | 594 | const focus = active && { file: active.closest?.('.files a')?.getAttribute('title'), region: regions.find((sel) => $(sel)?.contains(active)), menu: active.closest?.('details.menu')?.id, id: active.id, tag: active.tagName, name: active.getAttribute('name'), href: active.getAttribute('href') }; | ||
| 590 | for (const f of $$('#diffs details.dfile', page)) if (files.has(f.dataset.file)) f.open = files.get(f.dataset.file); | 595 | for (const f of $$('#diffs details.dfile', page)) if (files.has(f.dataset.file)) f.open = files.get(f.dataset.file); | ||
| @@ -600,13 +605,7 @@ window.ermiteMR = (() => { | |||||
| 600 | const oldSHA = $('.hdr .ref .sha'), newSHA = $('.hdr .ref .sha', page); | 605 | const oldSHA = $('.hdr .ref .sha'), newSHA = $('.hdr .ref .sha', page); | ||
| 601 | if (oldSHA && newSHA) oldSHA.textContent = newSHA.textContent; | 606 | if (oldSHA && newSHA) oldSHA.textContent = newSHA.textContent; | ||
| 602 | page = null; | 607 | page = null; | ||
| 603 | - | panels(); | 608 | + | panels(openPanel); |
| 604 | - | if (tab) { | |||
| 605 | - | const oldURL = location.href; | |||
| 606 | - | document.getElementById(tab)?.click(); | |||
| 607 | - | if (location.href !== oldURL) history.replaceState(history.state, '', oldURL); | |||
| 608 | - | } | |||
| 609 | - | if ($('#mr-panels')) $('#mr-panels').open = footerOpen; | |||
| 610 | foldFiles(!!folded); | 609 | foldFiles(!!folded); | ||
| 611 | if ($('.rail')) $('.rail').scrollTop = railY; | 610 | if ($('.rail')) $('.rail').scrollTop = railY; | ||
| 612 | if ($('.r-files .files')) $('.r-files .files').scrollTop = filesY; | 611 | if ($('.r-files .files')) $('.r-files .files').scrollTop = filesY; | ||