oficina / sheets Public

Keep browser menus open through pointer activation

Paul Campbell pushed by paul@keymail.dev 3786dd6577595babd1f33437436128c9a8c4b680 parent 7a843c2
3 files changed, +162 −2
  • internal/sheets/browser_menu_test.go +41 −0
  • internal/sheets/static/menubar.js +19 −2
  • tools/drive/menu-actions.mjs +102 −0
diff --git a/internal/sheets/browser_menu_test.go b/internal/sheets/browser_menu_test.go
new file mode 100644
index 0000000..20d22ae
--- /dev/null
+++ b/internal/sheets/browser_menu_test.go
@@ -0,0 +1,41 @@
+package sheets
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "regexp"
+ "testing"
+)
+
+// Opt-in fixture for tools/drive/menu-actions.mjs: actual server-rendered menus,
+// with a synthetic owner and sheet. No session cookie is exported.
+func TestBrowserMenuFixture(t *testing.T) {
+ path := os.Getenv("SHEETS_MENU_FIXTURE")
+ if path == "" {
+ t.Skip("set SHEETS_MENU_FIXTURE to render the browser fixture")
+ }
+ app, cookies := signIn(t)
+ created := do(t, app, as(postForm("/sheets", url.Values{}), cookies))
+ page := do(t, app, as(httptest.NewRequest(http.MethodGet, created.Header().Get("Location"), nil), cookies))
+ if page.Code != http.StatusOK {
+ t.Fatalf("sheet page: %d", page.Code)
+ }
+ assets := map[string]string{}
+ for _, match := range regexp.MustCompile(`(?:src|href)="(/static/[^" ]+)"`).FindAllStringSubmatch(page.Body.String(), -1) {
+ asset := do(t, app, as(httptest.NewRequest(http.MethodGet, match[1], nil), cookies))
+ if asset.Code != http.StatusOK {
+ t.Fatalf("asset %s: %d", match[1], asset.Code)
+ }
+ assets[match[1]] = asset.Body.String()
+ }
+ data, err := json.Marshal(map[string]any{"html": page.Body.String(), "assets": assets})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, data, 0600); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/internal/sheets/static/menubar.js b/internal/sheets/static/menubar.js
index ba2d976..586e308 100644
--- a/internal/sheets/static/menubar.js
+++ b/internal/sheets/static/menubar.js
@@ -56,7 +56,7 @@
function walk(node) {
Array.prototype.forEach.call(node.children, function (el) {
- if (el.hidden) return;
+ if (el.hidden || el.disabled || el.type === 'hidden') return;
if (el.tagName === 'FORM') { walk(el); return; }
if (el.tagName === 'DETAILS') {
var s = summaryOf(el);
@@ -67,7 +67,7 @@
}
return;
}
- if (el.tagName === 'A' || el.tagName === 'BUTTON') out.push(el);
+ if (el.tagName === 'A' || el.tagName === 'BUTTON' || el.tagName === 'INPUT') out.push(el);
else if (el.children.length) walk(el);
});
}
@@ -117,6 +117,11 @@
var target = e.target;
var menu = ownerMenu(target);
if (!menu) return;
+ // Menu keys must not reach the grid, including native button activation.
+ e.stopPropagation();
+
+ // Resize fields own their cursor, number stepping, and form submission.
+ if (target.matches('input, textarea, select')) { e.stopPropagation(); return; }
var isTop = summaryOf(menu) === target;
var consumed = true;
@@ -194,10 +199,22 @@
* bubble and the focus may be leaving from any depth. The relatedTarget
* check is what distinguishes "moved to another item in this menu"
* from "left the menu entirely". */
+ // WebKit does not focus buttons on pointer activation. Its mousedown can
+ // blur the summary with no relatedTarget, before click/submit happens.
+ // Keep the menu visible through that activation; otherwise its item vanishes
+ // before the click and native form actions (including New sheet) never run.
+ var pointerMenu = null;
+ document.addEventListener('pointerdown', function (e) {
+ pointerMenu = ownerMenu(e.target);
+ }, true);
+ document.addEventListener('pointerup', function () { pointerMenu = null; }, true);
+ document.addEventListener('pointercancel', function () { pointerMenu = null; }, true);
+
bar.addEventListener('focusout', function (e) {
var menu = ownerMenu(e.target);
if (!menu || !menu.open) return;
var to = e.relatedTarget;
+ if (!to && pointerMenu === menu) return;
if (to && menu.contains(to)) return;
menu.open = false;
});
diff --git a/tools/drive/menu-actions.mjs b/tools/drive/menu-actions.mjs
new file mode 100644
index 0000000..953747c
--- /dev/null
+++ b/tools/drive/menu-actions.mjs
@@ -0,0 +1,102 @@
+// Real server-rendered menus and assets, mocked transport; no live user data.
+// SHEETS_MENU_FIXTURE=/tmp/menus.json go test ./internal/sheets -run TestBrowserMenuFixture
+// PLAYWRIGHT=/path/to/playwright/index.mjs SHEETS_MENU_FIXTURE=/tmp/menus.json node tools/drive/menu-actions.mjs
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+const pw = await import(process.env.PLAYWRIGHT || 'playwright');
+const fixture = JSON.parse(fs.readFileSync(process.env.SHEETS_MENU_FIXTURE));
+const origin = 'https://sheets.test';
+for (const name of ['chromium', 'webkit']) {
+ const options = name === 'webkit' && process.env.WEBKIT_EXECUTABLE_PATH ? {executablePath:process.env.WEBKIT_EXECUTABLE_PATH} : {};
+ const browser = await pw[name].launch(options);
+ try {
+ const page = await browser.newPage({viewport:{width:1440,height:1000}});
+ let requests = [], errors = [];
+ page.on('pageerror', e=>errors.push(e.message));
+ // Force the Clipboard API fallback so command dispatch is deterministic in
+ // both engines. Real clipboard behavior is covered by clipboard.mjs.
+ await page.addInitScript(()=>{
+ window.menuClipboard = '';
+ document.execCommand = ()=>false;
+ Object.defineProperty(navigator,'clipboard',{value:{
+ writeText:async text=>{window.menuClipboard=text;},
+ readText:async ()=>'pasted'
+ }});
+ });
+ await page.route(origin+'/**',async route=>{
+ const req=route.request(),path=new URL(req.url()).pathname;
+ if(path in fixture.assets) return route.fulfill({body:fixture.assets[path],contentType:
+ path.endsWith('.js')?'application/javascript':path.endsWith('.css')?'text/css':'image/svg+xml'});
+ if(path.endsWith('/stream'))return route.fulfill({body:'',contentType:'text/event-stream'});
+ requests.push({method:req.method(),path,body:req.postData()});
+ if(path.startsWith('/api/'))return route.fulfill({json:{version:1,cells:[
+ {r:0,c:0,raw:'10',display:'10'},{r:1,c:0,raw:'20',display:'20'},
+ {r:2,c:0,raw:'30',display:'30'}]}});
+ return route.fulfill({body:fixture.html,contentType:'text/html'});
+ });
+ async function load() {
+ await page.goto(origin+'/sheets/1');
+ await page.waitForFunction(()=>window.SheetsGrid?.worksheet());
+ await page.locator('td[data-x="0"][data-y="0"]').click();
+ await page.evaluate(()=>SheetsGrid.worksheet().updateSelectionFromCoords(0,0,1,1));
+ requests=[];
+ }
+ await load();
+ const inventory=await page.locator('[data-menubar] a, [data-menubar] button').evaluateAll(els=>els.map((el,index)=>{
+ const top=el.closest('[data-menubar] > details'), sub=el.closest('[rst-menu-group]');
+ return {index,label:el.textContent.trim(),cmd:el.dataset.cmd,href:el.getAttribute('href'),
+ action:el.form?.getAttribute('action'),axis:el.form?.dataset.axis,
+ menu:[...top.parentElement.children].indexOf(top),
+ sub:sub?[...top.querySelectorAll('[rst-menu-group]')].indexOf(sub):null};
+ }));
+ for(const item of inventory) {
+ await load();
+ if(item.cmd==='fn')await page.evaluate(()=>SheetsGrid.worksheet().updateSelectionFromCoords(0,3,0,3));
+ const menu=page.locator('[data-menubar] > details').nth(item.menu);
+ await menu.locator(':scope > summary').click();
+ if(item.sub!==null)await menu.locator('[rst-menu-group]').nth(item.sub).locator(':scope > summary').click();
+ if(item.axis)await menu.locator(`[data-axis="${item.axis}"] input`).fill('48');
+ const button=page.locator('[data-menubar] a, [data-menubar] button').nth(item.index);
+ if(item.href || item.action) {
+ const method=item.action?'POST':'GET',path=item.action||item.href;
+ const sent=page.waitForRequest(req=>req.method()===method&&new URL(req.url()).pathname===path,{timeout:3000});
+ await button.click();await sent;
+ } else {
+ const suffix=item.axis || item.cmd==='autofit-columns'?'/size':item.cmd==='paste'?'/cells/batch':
+ ['fn','clear','fill-down','fill-right'].includes(item.cmd)?'/cells':'/format';
+ const posted=['copy','cut','select-all'].includes(item.cmd)?null:
+ page.waitForRequest(req=>req.method()==='POST'&&new URL(req.url()).pathname.endsWith(suffix),{timeout:3000});
+ await button.click();
+ if(['copy','cut'].includes(item.cmd)) {
+ await page.waitForFunction(()=>window.menuClipboard!== '',{},{timeout:3000});
+ assert.equal(requests.filter(r=>r.method==='POST').length,0,item.label+' must not write cells');
+ } else if(item.cmd==='select-all') {
+ assert.equal(await page.evaluate(()=>SheetsGrid.selection().r2===SheetsGrid.rows-1&&SheetsGrid.selection().c2===SheetsGrid.cols-1),true);
+ } else {
+ await posted;
+ assert.ok(requests.some(r=>r.method==='POST'&&r.path.endsWith(suffix)),`${name}: ${item.label} must POST ${suffix}`);
+ }
+ }
+ console.log(`${name}: OK ${item.label.replace(/\s+/g,' ')}`);
+ }
+ // Keyboard field editing must not be swallowed by menu navigation.
+ await load();
+ const format=page.locator('[data-menubar] > details').filter({has:page.locator(':scope > summary', {hasText:'Format'})});
+ await format.locator(':scope > summary').click();
+ const width=page.locator('#colw');await width.fill('48');await width.press('ArrowUp');
+ assert.equal(await width.inputValue(),'49');
+ await width.press('Enter');
+ assert.ok(requests.some(r=>r.path.endsWith('/size')&&JSON.parse(r.body).size===49));
+ await load();
+ const file=page.locator('[data-menubar] > details').first();
+ await file.locator('summary').press('Enter');
+ const submitted=page.waitForRequest(req=>req.method()==='POST'&&new URL(req.url()).pathname==='/sheets');
+ await page.keyboard.press('Enter');await submitted;
+ await load();await file.locator('summary').click();await page.keyboard.press('Escape');
+ assert.equal(await file.getAttribute('open'),null);
+ await file.locator('summary').click();await page.locator('[data-formula-input]').click();
+ assert.equal(await file.getAttribute('open'),null);
+ assert.deepEqual(errors,[]);
+ console.log(`${name}: ${inventory.length} actions, keyboard submission, resize, Escape, and outside click passed`);
+ } finally {await browser.close();}
+}