feat(list): the drop comes back, without the box
Taking the dashed drop zone off the sheet list took dropping a file on it away too. That was a real loss and not the point of the change: the point was that a list of sheets should be a list of sheets, not that dragging should stop working. So the drop target is back and the box is not. The list carries an overlay that is `hidden` at rest — no paint, no layout, no tab stop — and drop.js reveals it only while a drag actually carrying files is over the window. An affordance that appears exactly when it applies beats a permanent rectangle explaining that it exists. It is the same form /sheets/import serves, so a drop is the ordinary multipart POST: one route, one CSRF check, one code path on the server. With scripting off it never appears and the Import button in the top bar is the whole feature — which is what AGENTS.md's "every drag-only affordance needs a menu equivalent" asks for. The panel keeps a chooser and a submit for after a refusal, hidden while a drag is in progress because offering a file picker to somebody mid-drag is a second way to do the thing they are already doing. tools/drive/drop.mjs is the gate, and it earned its place on the first run. The overlay carries data-drop, because that is what drop.js looks for, so it also matched [data-drop][data-drop-over] — 0-2-0 against [rst-modal-overlay]'s 0-1-0. The scrim was painted accent-soft and the sheet list disappeared behind a flat blue screen. Every Go test passed. Both halves of that are now pinned: the browser checks the scrim is translucent, and a Go test reads sheets.css for the :not() exclusion and for the rule reinstating display:none under [hidden] — without which the overlay covers the list permanently, worst with scripting off, where nothing would ever come along to hide it again.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wx6hf3dY5vmSNmR9RCGkh2
5 files changed,
+528
−14
internal/sheets/handlers_list_chrome_test.go+92 −6internal/sheets/static/drop.js+56 −6internal/sheets/static/sheets.css+80 −2internal/sheets/templates/sheets.html+67 −0tools/drive/drop.mjs+233 −0
diff --git a/internal/sheets/handlers_list_chrome_test.go b/internal/sheets/handlers_list_chrome_test.go| index c779f18..8282dee 100644 |
| --- a/internal/sheets/handlers_list_chrome_test.go |
| +++ b/internal/sheets/handlers_list_chrome_test.go |
| @@ -5,6 +5,8 @@ import ( |
| "mime/multipart" |
| "net/http" |
| "net/http/httptest" |
| + "os" |
| + "path/filepath" |
| "strings" |
| "testing" |
| ) |
| @@ -27,24 +29,108 @@ func signedInListBody(t *testing.T) string { |
| return listBody(t, app, cookies) |
| } |
| +// dropOverlay returns the sheet list's body up to the drop overlay, and |
| +// the overlay's own start tag. Everything before the overlay is what |
| +// the page actually renders; the overlay itself is hidden until a file |
| +// is dragged over the window. |
| +func dropOverlay(t *testing.T, body string) (inFlow, tag string) { |
| + t.Helper() |
| + start := strings.Index(body, "<form data-drop") |
| + if start < 0 { |
| + t.Fatal("the sheet list has no drop overlay — dropping a file on this page is the affordance it traded its drop box for") |
| + } |
| + end := strings.Index(body[start:], ">") |
| + if end < 0 { |
| + t.Fatal("the drop overlay's start tag never closes") |
| + } |
| + return body[:start], body[start : start+end+1] |
| +} |
| + |
| func TestSheetListCarriesNoInlineForms(t *testing.T) { |
| body := signedInListBody(t) |
| - // The title field and the file input are the two controls that were |
| - // on this page and must not be, named individually so a failure |
| - // says which one came back. |
| + // The title field is simply gone. A sheet is named by renaming it, |
| + // on the sheet itself, so there is nothing left for this to be. |
| + if strings.Contains(body, `name="title"`) { |
| + t.Error(`the sheet list is carrying name="title" again — naming a sheet is a rename, in the top bar of the sheet`) |
| + } |
| + |
| + // The file input is back, but only inside the overlay. Anything of |
| + // it appearing BEFORE the overlay's start tag is a box in the page |
| + // again, which is the thing this change removed. |
| + inFlow, _ := dropOverlay(t, body) |
| for _, gone := range []string{ |
| - `name="title"`, |
| `type="file"`, |
| `enctype="multipart/form-data"`, |
| "data-drop", |
| } { |
| - if strings.Contains(body, gone) { |
| - t.Errorf("the sheet list is carrying %s again — that belongs in the top bar or on /sheets/import", gone) |
| + if strings.Contains(inFlow, gone) { |
| + t.Errorf("%s renders in the flow of the sheet list — the drop target is an overlay, not a box above the sheets", gone) |
| } |
| } |
| } |
| +// The overlay is a real import form that happens to be invisible until |
| +// a drag brings it up. Both halves of that matter: hidden at rest, so |
| +// it costs the list no layout and no tab stop, and a genuine multipart |
| +// POST to /sheets/import, so a drop is the ordinary upload and there is |
| +// no second code path on the server. |
| +func TestTheDropOverlayIsHiddenAtRest(t *testing.T) { |
| + _, tag := dropOverlay(t, signedInListBody(t)) |
| + |
| + for _, want := range []string{ |
| + "hidden", |
| + `method="post"`, |
| + `action="/sheets/import"`, |
| + `enctype="multipart/form-data"`, |
| + } { |
| + if !strings.Contains(tag, want) { |
| + t.Errorf("the drop overlay's start tag is missing %s: %s", want, tag) |
| + } |
| + } |
| +} |
| + |
| +// The overlay is nothing without drop.js: it is `hidden`, and only that |
| +// script ever takes the attribute off. A page that renders the markup |
| +// and does not load the script has an import affordance that can never |
| +// appear. |
| +func TestTheSheetListLoadsDropJS(t *testing.T) { |
| + // By prefix, not by filename: assets are served fingerprinted, so |
| + // the tag reads /static/drop.<hash>.js and matching the literal |
| + // name passes only until the file next changes. |
| + body := signedInListBody(t) |
| + if !strings.Contains(body, `src="/static/drop.`) { |
| + t.Error("the sheet list renders the drop overlay but never loads drop.js, so nothing will ever reveal it") |
| + } |
| +} |
| + |
| +// The paint that keeps the overlay hidden. |
| +// |
| +// [rst-modal-overlay] sets display:flex, and an author declaration beats |
| +// the user agent's [hidden] { display: none } however weak its selector |
| +// looks. Drop the reinstating rule and the overlay covers the sheet list |
| +// permanently — worst with scripting off, where nothing would ever come |
| +// along to hide it again. The markup test above passes either way, so |
| +// this reads the stylesheet. |
| +func TestTheDropOverlayStaysHiddenInCSS(t *testing.T) { |
| + css, err := os.ReadFile(filepath.Join("static", "sheets.css")) |
| + if err != nil { |
| + t.Fatalf("read sheets.css: %v", err) |
| + } |
| + if !strings.Contains(string(css), "[data-drop-page][hidden]") { |
| + t.Error("sheets.css no longer reinstates display:none for the hidden drop overlay — it will cover the sheet list") |
| + } |
| + |
| + // The drop BOX's paint must exclude the overlay, which carries |
| + // data-drop too. [data-drop][data-drop-over] is 0-2-0 against |
| + // [rst-modal-overlay]'s 0-1-0, so without the exclusion the scrim is |
| + // painted accent-soft and the sheet list disappears behind a flat |
| + // blue screen mid-drag. A browser found that; nothing here could. |
| + if !strings.Contains(string(css), "[data-drop]:not([data-drop-page])") { |
| + t.Error("the drop box's paint no longer excludes the overlay — it will out-specify the modal scrim and cover the list") |
| + } |
| +} |
| + |
| // Create is a real form posting to /sheets, in the top bar. A link |
| // would be wrong twice over: it makes a sheet, so it must not be a GET, |
| // and it has to work with scripting off. |
diff --git a/internal/sheets/static/drop.js b/internal/sheets/static/drop.js| index 30baa12..3a89703 100644 |
| --- a/internal/sheets/static/drop.js |
| +++ b/internal/sheets/static/drop.js |
| @@ -1,4 +1,11 @@ |
| -/* Drag and drop for the import form. |
| +/* Drag and drop for the import form, in both places it appears. |
| + * |
| + * On /sheets/import the zone is a box in the page. On the sheet list it |
| + * is an overlay that stays `hidden` until a drag carrying files is |
| + * actually over the window — the list carries no visible drop box, |
| + * because a list of sheets should be a list of sheets. Which shape it |
| + * is comes from data-drop-page on the zone, and it changes only where |
| + * the events are listened for and whether the zone is revealed. |
| * |
| * This file adds no capability. The form it decorates already imports a |
| * spreadsheet with scripting off, and every drop here ends as that same |
| @@ -32,8 +39,32 @@ |
| if (typeof window.DataTransfer !== 'function') return; |
| var hint = zone.querySelector('[data-drop-hint]'); |
| + var hintText = hint ? hint.textContent : ''; |
| var depth = 0; |
| + /* Two shapes, one behaviour. |
| + * |
| + * On /sheets/import the zone is a box in the page and the events |
| + * belong to it. On the sheet list there is no box — a list of sheets |
| + * should be a list of sheets — so the zone is an overlay that is |
| + * `hidden` until a drag carrying files enters the window, and the |
| + * events belong to the document because a hidden element receives |
| + * none. Everything after this line is the same either way. */ |
| + var page = zone.hasAttribute('data-drop-page'); |
| + var source = page ? document : zone; |
| + |
| + function reveal() { if (page) zone.hidden = false; } |
| + |
| + /* dismiss puts the hint back as well. Otherwise a refusal ("drop one |
| + * file at a time") is still sitting there the next time the overlay |
| + * comes up, answering a drag that has not happened yet. */ |
| + function dismiss() { |
| + if (!page) return; |
| + zone.hidden = true; |
| + zone.removeAttribute('data-drop-over'); |
| + say(hintText); |
| + } |
| + |
| /* dragenter/dragleave fire for every child element the pointer |
| * crosses, so a plain enter/leave pair flickers the whole time a |
| * file is moved across the box. Counting depth is what makes the |
| @@ -42,6 +73,7 @@ |
| if (!hasFiles(e)) return; |
| e.preventDefault(); |
| depth++; |
| + reveal(); |
| zone.setAttribute('data-drop-over', ''); |
| } |
| @@ -49,7 +81,10 @@ |
| if (!hasFiles(e)) return; |
| e.preventDefault(); |
| depth = Math.max(0, depth - 1); |
| - if (depth === 0) zone.removeAttribute('data-drop-over'); |
| + if (depth === 0) { |
| + zone.removeAttribute('data-drop-over'); |
| + dismiss(); |
| + } |
| } |
| /* dragover must preventDefault on every tick or the browser refuses |
| @@ -117,8 +152,23 @@ |
| if (hint) hint.textContent = text; |
| } |
| - zone.addEventListener('dragenter', enter); |
| - zone.addEventListener('dragleave', leave); |
| - zone.addEventListener('dragover', over); |
| - zone.addEventListener('drop', drop); |
| + source.addEventListener('dragenter', enter); |
| + source.addEventListener('dragleave', leave); |
| + source.addEventListener('dragover', over); |
| + source.addEventListener('drop', drop); |
| + |
| + /* A refused drop leaves the overlay up on purpose — the message is |
| + * the whole point of refusing in the client's own words, and the |
| + * chooser beside it is how the person recovers. So the overlay needs |
| + * a way out that is not the back button: Escape, or a click on the |
| + * backdrop outside the panel. Both are what every other dismissible |
| + * layer in this app answers to, so neither has to be taught. */ |
| + if (page) { |
| + document.addEventListener('keydown', function (e) { |
| + if (e.key === 'Escape' && !zone.hidden) dismiss(); |
| + }); |
| + zone.addEventListener('click', function (e) { |
| + if (e.target === zone) dismiss(); |
| + }); |
| + } |
| })(); |
diff --git a/internal/sheets/static/sheets.css b/internal/sheets/static/sheets.css| index 677b103..be9c11d 100644 |
| --- a/internal/sheets/static/sheets.css |
| +++ b/internal/sheets/static/sheets.css |
| @@ -1027,14 +1027,21 @@ form[data-doc-title]:not(:focus-within) button { |
| * page crosses this box repeatedly, and animating the outline is the |
| * difference between a box that acknowledges the drag and one that |
| * flashes. */ |
| -[data-drop] { |
| +/* :not([data-drop-page]) on both, because there are two zones now and |
| + only one of them is a box. The overlay on the sheet list carries |
| + data-drop too — it has to, it is what drop.js looks for — and these |
| + two selectors are 0-2-0 and 0-3-0, so without the exclusion they beat |
| + [rst-modal-overlay]'s 0-1-0 and paint the sheet of glass over the |
| + whole page in accent-soft. It renders as a flat blue screen with the |
| + list gone: a defect a browser found and no test could. */ |
| +[data-drop]:not([data-drop-page]) { |
| border: 1px dashed var(--rst-line); |
| border-radius: var(--rst-radius); |
| padding: var(--rst-sp-3); |
| transition: border-color 120ms ease, background-color 120ms ease; |
| } |
| -[data-drop][data-drop-over] { |
| +[data-drop][data-drop-over]:not([data-drop-page]) { |
| border-color: var(--rst-accent); |
| background: var(--rst-accent-soft); |
| } |
| @@ -1047,6 +1054,77 @@ form[data-doc-title]:not(:focus-within) button { |
| } |
| } |
| +/* ── The sheet list's drop overlay ─────────────────────────────────── |
| + * |
| + * The list has no drop box. It has a full-window target that does not |
| + * exist until a file is over the window, and drop.js is what reveals |
| + * it — see the comment on [data-drop-page] in sheets.html. |
| + * |
| + * It borrows rastrillo's modal paint rather than growing its own, so |
| + * the layer that appears over the list looks like every other layer in |
| + * the suite. Only the four rules below are ours. |
| + * |
| + * THIS FIRST RULE IS LOAD-BEARING. [rst-modal-overlay] sets |
| + * display: flex, and an author declaration beats the user agent's |
| + * [hidden] { display: none } no matter how weak its selector looks. |
| + * Without it the overlay covers the sheet list permanently and the page |
| + * is unusable — with scripting off, where nothing would ever come along |
| + * to hide it again. TestTheDropOverlayIsHiddenAtRest pins the markup; |
| + * this pins the paint. */ |
| +[data-drop-page][hidden] { |
| + display: none; |
| +} |
| + |
| +[data-drop-page] [rst-modal-panel] { |
| + max-width: 24rem; |
| + transition: border-color 120ms ease; |
| +} |
| + |
| +/* The panel answers the drag, the same way the box on /sheets/import |
| + does and with the same token, so the two read as one gesture. */ |
| +[data-drop-page][data-drop-over] [rst-modal-panel] { |
| + border-color: var(--rst-accent); |
| +} |
| + |
| +[data-drop-page-body] { |
| + display: grid; |
| + gap: var(--rst-sp-3); |
| + justify-items: center; |
| + padding: var(--rst-sp-5); |
| + text-align: center; |
| + width: 100%; |
| +} |
| + |
| +[data-drop-page-title] { |
| + font-size: var(--rst-fs-base); |
| + font-weight: 600; |
| + margin: 0; |
| +} |
| + |
| +/* The chooser is for afterwards. |
| + * |
| + * While a file is over the window the panel has one thing to say, and a |
| + * "Choose File" button beside "Drop it to import" offers a second way to |
| + * do the thing the person is already doing. It appears when the overlay |
| + * is up WITHOUT a drag over it, which is exactly the state a refused |
| + * drop leaves behind — and then it is the way out of that refusal. */ |
| +[data-drop-page-recover] { |
| + display: grid; |
| + gap: var(--rst-sp-3); |
| + justify-items: center; |
| + width: 100%; |
| +} |
| + |
| +[data-drop-page][data-drop-over] [data-drop-page-recover] { |
| + display: none; |
| +} |
| + |
| +@media (prefers-reduced-motion: reduce) { |
| + [data-drop-page] [rst-modal-panel] { |
| + transition: none; |
| + } |
| +} |
| + |
| /* ── Cell formatting ───────────────────────────────────────────────── |
| * |
| * The paint for a cell's stored Format: bold, italic, alignment, fill |
diff --git a/internal/sheets/templates/sheets.html b/internal/sheets/templates/sheets.html| index 4da71db..78534f1 100644 |
| --- a/internal/sheets/templates/sheets.html |
| +++ b/internal/sheets/templates/sheets.html |
| @@ -11,6 +11,19 @@ |
| handlers_rename.go is for. Import moved to a page of its own at |
| /sheets/import — see handleImportPage for why a page and not an |
| item in the File menu. */}} |
| +{{define "head"}} |
| +{{/* Drag and drop, and only that. The overlay at the foot of this file |
| + is hidden until a file is dragged over the window, and every drop |
| + ends as a submit of the same form /sheets/import serves — same |
| + route, same CSRF check, same job. |
| + |
| + With scripting off it never appears and the Import link in the top |
| + bar is the entire feature. That is what AGENTS.md's "every |
| + drag-only affordance needs a menu equivalent" asks for: the link |
| + is the floor, and the drag is a shortcut to it. */}} |
| +<script defer src="{{asset "static/drop.js"}}"></script> |
| +{{end}} |
| + |
| {{define "topbaractions"}} |
| {{if .SignedIn}} |
| {{/* The wrapper is what carries the auto margin that puts these |
| @@ -77,4 +90,58 @@ grid you can type into straight away, or |
| already have.</p> |
| </div> |
| {{end}} |
| + |
| +{{if .SignedIn}} |
| +{{/* The drop target for the whole page. |
| + |
| + This screen used to carry a dashed box you could drop a file on, |
| + and taking the box away took the drop with it — so the drop comes |
| + back without the box. It is `hidden` at rest, contributes nothing |
| + to the layout and nothing to the tab order, and drop.js reveals it |
| + only while a drag carrying files is actually over the window. An |
| + affordance that appears exactly when it applies beats a permanent |
| + rectangle explaining that it exists. |
| + |
| + It is the same form as the one on /sheets/import, deliberately: |
| + drop.js fills this file input and submits this form, so a drop |
| + from here is the ordinary multipart POST and there is no second |
| + code path on the server. The file field is visible inside the |
| + panel rather than hidden, because a refused drop ("drop one file |
| + at a time") needs something to recover with that is not the back |
| + button. |
| + |
| + `hidden` is load-bearing and is asserted in a test: rst-modal-overlay |
| + sets display:flex, an author rule beats the UA's [hidden], and |
| + without the reinstating rule in sheets.css this would cover the |
| + list permanently. */}} |
| +<form data-drop data-drop-page hidden rst-modal-overlay |
| + method="post" action="/sheets/import" enctype="multipart/form-data"> |
| +<div rst-modal-panel> |
| +<div data-drop-page-body> |
| +<p data-drop-page-title>Drop it to import</p> |
| +{{/* The same limits /sheets/import states, and stated before the |
| + import for the same reason: one worksheet is read, and a |
| + three-sheet workbook would otherwise arrive looking whole. This |
| + is also the line drop.js rewrites to refuse a drop, which is |
| + why it is a sentence and not a list. */}} |
| +<p rst-field-hint data-drop-hint>Excel .xlsx or .xlsm, up to 20 MB. The |
| +first worksheet is imported.</p> |
| +{{/* Chooser and submit, so a refused drop has something to recover |
| + with, and so the panel is a working form rather than a picture |
| + of one. They are hidden while a drag is actually over the window |
| + — see [data-drop-page-recover] in sheets.css — because offering |
| + a file picker to somebody mid-drag is a second way to do the |
| + thing they are already doing. |
| + |
| + tabindex="-1" keeps them out of the tab order while the overlay |
| + is up; `hidden` on the form handles it the rest of the time. */}} |
| +<div data-drop-page-recover> |
| +<input rst-input name="file" type="file" accept=".xlsx,.xlsm" tabindex="-1" |
| + aria-label="Spreadsheet to import"> |
| +<button rst-btn="primary" type="submit" tabindex="-1">Import</button> |
| +</div> |
| +</div> |
| +</div> |
| +</form> |
| +{{end}} |
| {{end}} |
diff --git a/tools/drive/drop.mjs b/tools/drive/drop.mjs| new file mode 100644 |
| index 0000000..542035e |
| --- /dev/null |
| +++ b/tools/drive/drop.mjs |
| @@ -0,0 +1,233 @@ |
| +/* Drive the sheet list's drop overlay in a real browser. |
| + * |
| + * The third of the drives, after drive.mjs (live sync across two tabs) |
| + * and chrome.mjs (the editor chrome). Like both it is NOT part of |
| + * `make test`: it needs a browser and a running instance, neither of |
| + * which CI has. |
| + * |
| + * It is here because the Go suite can assert the markup and read the |
| + * stylesheet, and neither tells you whether the thing works. The list |
| + * has no visible drop box any more — a list of sheets should be a list |
| + * of sheets — so the whole affordance is an overlay that must be |
| + * invisible at rest, appear when a file is over the window, and go away |
| + * again. "Invisible" and "appears" are not things a Go test can see. |
| + * |
| + * It earned its place on the first run. The overlay carries data-drop, |
| + * because that is what drop.js looks for, and so it also matched |
| + * [data-drop][data-drop-over] — 0-2-0 against [rst-modal-overlay]'s |
| + * 0-1-0. The scrim was painted accent-soft and the sheet list vanished |
| + * behind a flat blue screen. Every Go test passed. |
| + * |
| + * Usage, from the repository root — see drive.mjs for the full recipe: |
| + * |
| + * SHEETS_SOCK=$D/s.sock SHEETS_LOG=$D/s.log OUT=$D \ |
| + * SHEETS_FIXTURE=testdata/corpus/xlsx/numbers.xlsx \ |
| + * node tools/drive/drop.mjs |
| + * |
| + * The drag events are synthesised rather than performed: Playwright |
| + * cannot drag a file in from the desktop, and a DragEvent carrying a |
| + * real DataTransfer of real File objects is what the browser delivers |
| + * when it does. The final drop is a genuine multipart upload — the |
| + * import at the end really imports. |
| + */ |
| +import net from 'node:net'; |
| +import fs from 'node:fs'; |
| +const PLAYWRIGHT = process.env.PLAYWRIGHT || 'playwright'; |
| +const pw = await import(PLAYWRIGHT); |
| +/* A CJS build imported from ESM puts its exports on .default. */ |
| +const chromium = pw.chromium || (pw.default && pw.default.chromium); |
| +if (!chromium) { console.error('playwright: no chromium export'); process.exit(1); } |
| + |
| +const SOCK = process.env.SHEETS_SOCK; |
| +const LOG = process.env.SHEETS_LOG; |
| +const PORT = parseInt(process.env.SHEETS_PORT || '8799', 10); |
| +const OUT = process.env.OUT || '.'; |
| +const FIXTURE = process.env.SHEETS_FIXTURE || 'testdata/corpus/xlsx/numbers.xlsx'; |
| + |
| +const proxy = net.createServer((c) => { |
| + const u = net.connect(SOCK); |
| + c.pipe(u); u.pipe(c); |
| + c.on('error', () => u.destroy()); |
| + u.on('error', () => c.destroy()); |
| +}); |
| +await new Promise((r) => proxy.listen(PORT, '127.0.0.1', r)); |
| + |
| +const base = `http://127.0.0.1:${PORT}`; |
| +const browser = await chromium.launch(); |
| +const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } }); |
| +const page = await ctx.newPage(); |
| + |
| +const errors = []; |
| +page.on('console', (m) => { if (m.type() === 'error') errors.push('console: ' + m.text()); }); |
| +page.on('pageerror', (e) => errors.push('pageerror: ' + e.message)); |
| + |
| +const failed = []; |
| +function check(name, ok, detail) { |
| + console.log((ok ? ' ok ' : ' FAIL ') + name + (detail === undefined ? '' : ' ' + JSON.stringify(detail))); |
| + if (!ok) failed.push(name); |
| +} |
| + |
| +/* Sign in. The magic link is read out of the log, which only works on a |
| + * loopback origin with no SMTP — see internal/sheets/auth.go. */ |
| +const before = fs.readFileSync(LOG, 'utf8').length; |
| +await page.goto(base + '/signin'); |
| +await page.fill('#address', 'paul@example.com'); |
| +await page.click('form[action="/signin"] button[type=submit]'); |
| +await page.waitForURL(/signin\?sent=1/, { timeout: 15000 }); |
| +const link = fs.readFileSync(LOG, 'utf8').slice(before).match(/auth\/verify\?token=([A-Za-z0-9_-]+)/); |
| +if (!link) { console.error('no magic link in the log'); process.exit(1); } |
| +await page.goto(`${base}/auth/verify?token=${link[1]}`); |
| +await page.goto(base + '/sheets'); |
| + |
| +console.log('\n— at rest'); |
| + |
| +const rest = await page.evaluate(() => { |
| + const z = document.querySelector('[data-drop-page]'); |
| + if (!z) return null; |
| + const r = z.getBoundingClientRect(); |
| + const input = z.querySelector('input[type=file]'); |
| + return { |
| + hidden: z.hidden, |
| + display: getComputedStyle(z).display, |
| + area: r.width * r.height, |
| + reachable: input ? input.offsetParent !== null : null, |
| + }; |
| +}); |
| +check('the overlay is in the page', rest !== null); |
| +check('...hidden', rest && rest.hidden === true && rest.display === 'none', rest); |
| +check('...costing no layout', rest && rest.area === 0, rest && rest.area); |
| +check('...and no tab stop', rest && rest.reachable === false, rest && rest.reachable); |
| +await page.screenshot({ path: OUT + '/list-at-rest.png' }); |
| + |
| +/* Synthesised drags. __mkdt builds a DataTransfer of n copies of the |
| + * fixture; __fire dispatches at the centre of the viewport, on whatever |
| + * element is actually there, so the event bubbles the way a real one |
| + * does rather than being handed straight to the listener. */ |
| +const helper = ` |
| + window.__mkdt = (b64, n) => { |
| + const bin = atob(b64); |
| + const bytes = new Uint8Array(bin.length); |
| + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); |
| + const dt = new DataTransfer(); |
| + for (let i = 0; i < n; i++) { |
| + dt.items.add(new File([bytes], (i ? i + '-' : '') + 'drop.xlsx', |
| + { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })); |
| + } |
| + return dt; |
| + }; |
| + window.__fire = (type, dt) => { |
| + const e = new DragEvent(type, { bubbles: true, cancelable: true, dataTransfer: dt }); |
| + (document.elementFromPoint(640, 400) || document.body).dispatchEvent(e); |
| + }; |
| +`; |
| +const b64 = fs.readFileSync(FIXTURE).toString('base64'); |
| +const drag = (n) => page.evaluate(([b64, n]) => { |
| + window.__dt = window.__mkdt(b64, n); |
| + window.__fire('dragenter', window.__dt); |
| + window.__fire('dragover', window.__dt); |
| +}, [b64, n]); |
| + |
| +console.log('\n— a file over the window'); |
| + |
| +await page.evaluate(helper); |
| +await drag(1); |
| +const dragging = await page.evaluate(() => { |
| + const z = document.querySelector('[data-drop-page]'); |
| + const r = z.getBoundingClientRect(); |
| + return { |
| + hidden: z.hidden, |
| + over: z.hasAttribute('data-drop-over'), |
| + w: Math.round(r.width), h: Math.round(r.height), |
| + scrim: getComputedStyle(z).backgroundColor, |
| + recover: getComputedStyle(z.querySelector('[data-drop-page-recover]')).display, |
| + }; |
| +}); |
| +check('the overlay appears', dragging.hidden === false && dragging.over === true, dragging); |
| +check('...covering the window', dragging.w >= 1200 && dragging.h >= 700, dragging); |
| +/* The list must still be visible behind it. This is the check that |
| + * caught the specificity bug in the header comment: an opaque backdrop |
| + * reads as the page having gone away. */ |
| +check('...over a translucent scrim, with the list still there', |
| + /^rgba\(/.test(dragging.scrim) && parseFloat(dragging.scrim.split(',')[3]) < 0.8, dragging.scrim); |
| +check('...and the chooser out of the way', dragging.recover === 'none', dragging.recover); |
| +await page.screenshot({ path: OUT + '/list-dragging-light.png' }); |
| + |
| +await page.evaluate(() => window.__fire('dragleave', window.__dt)); |
| +check('the overlay goes when the drag leaves', |
| + (await page.evaluate(() => document.querySelector('[data-drop-page]').hidden)) === true); |
| + |
| +console.log('\n— a drop it cannot take'); |
| + |
| +await page.evaluate(([b64]) => { |
| + const dt = window.__mkdt(b64, 2); |
| + window.__fire('dragenter', dt); |
| + window.__fire('drop', dt); |
| +}, [b64]); |
| +const refused = await page.evaluate(() => { |
| + const z = document.querySelector('[data-drop-page]'); |
| + return { |
| + hidden: z.hidden, |
| + hint: z.querySelector('[data-drop-hint]').textContent.trim(), |
| + recover: getComputedStyle(z.querySelector('[data-drop-page-recover]')).display, |
| + path: location.pathname, |
| + }; |
| +}); |
| +check('two files are refused in words', refused.hint === 'Drop one file at a time.', refused); |
| +check('...with the overlay left up to say so', refused.hidden === false, refused); |
| +check('...and the chooser back, to recover with', refused.recover !== 'none', refused.recover); |
| +check('...having submitted nothing', refused.path === '/sheets', refused.path); |
| +await page.screenshot({ path: OUT + '/list-refused.png' }); |
| + |
| +await page.keyboard.press('Escape'); |
| +const dismissed = await page.evaluate(() => { |
| + const z = document.querySelector('[data-drop-page]'); |
| + return { hidden: z.hidden, hint: z.querySelector('[data-drop-hint]').textContent.trim() }; |
| +}); |
| +check('Escape dismisses it', dismissed.hidden === true, dismissed); |
| +check('...and the refusal does not outlive the dismissal', |
| + dismissed.hint.startsWith('Excel'), dismissed.hint); |
| + |
| +console.log('\n— dark'); |
| + |
| +await ctx.addCookies([{ name: 'sheets_mode', value: 'dark', url: base }]); |
| +await page.goto(base + '/sheets'); |
| +await page.evaluate(helper); |
| +await drag(1); |
| +/* The cookie is sheets_mode. Naming it "mode" renders the light page |
| + * and the screenshot proves nothing — which it once did, and the only |
| + * tell was that the two images were pixel-identical. */ |
| +check('dark really is dark', (await page.getAttribute('html', 'data-theme')) === 'dark'); |
| +await page.screenshot({ path: OUT + '/list-dragging-dark.png' }); |
| + |
| +console.log('\n— the drop imports'); |
| + |
| +await ctx.addCookies([{ name: 'sheets_mode', value: 'light', url: base }]); |
| +await page.goto(base + '/sheets'); |
| +await page.evaluate(helper); |
| +await page.evaluate(([b64]) => { |
| + const dt = window.__mkdt(b64, 1); |
| + window.__fire('dragenter', dt); |
| + window.__fire('drop', dt); |
| +}, [b64]); |
| +let landed = 'never left /sheets'; |
| +try { |
| + await page.waitForURL((u) => !u.pathname.endsWith('/sheets'), { timeout: 20000 }); |
| + landed = new URL(page.url()).pathname; |
| +} catch (e) { /* landed stays as it was */ } |
| +check('a drop starts a real import', /^\/(jobs|sheets)\//.test(landed), landed); |
| + |
| +let arrived = false; |
| +for (let i = 0; i < 60; i++) { |
| + await page.goto(base + '/sheets'); |
| + if (await page.locator('[data-sheet-list] li').count() > 0) { arrived = true; break; } |
| + await page.waitForTimeout(500); |
| +} |
| +check('...and the sheet lands in the list', arrived); |
| +await page.screenshot({ path: OUT + '/list-after-import.png' }); |
| + |
| +check('no console errors', errors.length === 0, errors.slice(0, 8)); |
| + |
| +console.log(failed.length ? '\nFAILED: ' + failed.join(', ') : '\nall checks passed'); |
| +await browser.close(); |
| +proxy.close(); |
| +process.exit(failed.length ? 1 : 0); |