Public: anyone can read this; the crew can change it.
Every page key is handled from the one table the `?` sheet lists, so the sheet cannot name a key that does nothing #11004
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10994-every-page-key-is-handled-from-the-one-t → main 5 commits fork
7 files
changed +122
−106
src/web/client.ts +43 −36
| @@ -1563,27 +1563,20 @@ const shellCore = /* js */ ` | ||
| 1563 | 1563 | var t = e.target; |
| 1564 | 1564 | // Inside an open dialog the dialog owns the keys; none of these apply. |
| 1565 | 1565 | if (t && t.closest && t.closest('dialog[open]')) return; |
| 1566 | // The palette is one chord away from anywhere, a field included. | |
| 1567 | if ((e.metaKey || e.ctrlKey) && !e.altKey && (e.key === 'k' || e.key === 'K')) { | |
| 1568 | e.preventDefault(); | |
| 1569 | openPalette(); | |
| 1570 | return; | |
| 1566 | // Chords answer from the same table, ahead of the field guard: the palette | |
| 1567 | // is one chord away from anywhere, a field included, and Cmd/Ctrl+Enter | |
| 1568 | // sends the form a box belongs to. | |
| 1569 | if ((e.metaKey || e.ctrlKey) && !e.altKey) { | |
| 1570 | var chord = rowForKey('Mod+' + (e.key === 'K' ? 'k' : e.key)); | |
| 1571 | if (chord) { chord[3](e); return; } | |
| 1571 | 1572 | } |
| 1572 | 1573 | if (t && t.matches && t.matches('input, textarea, select, [contenteditable]')) { |
| 1573 | 1574 | if (e.key === 'Escape') { e.preventDefault(); t.blur(); } |
| 1574 | // Cmd/Ctrl+Enter sends the form a box belongs to, and only a form that | |
| 1575 | // asked for it (\`data-submit-key\`): the button's own submit, with its | |
| 1576 | // validation. Plain Enter stays a newline. | |
| 1577 | else if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && t.form && t.form.hasAttribute('data-submit-key')) { | |
| 1578 | e.preventDefault(); | |
| 1579 | if (t.form.requestSubmit) t.form.requestSubmit(); else t.form.submit(); | |
| 1580 | } | |
| 1581 | 1575 | return; |
| 1582 | 1576 | } |
| 1583 | 1577 | if (e.metaKey || e.ctrlKey || e.altKey) return; |
| 1584 | if (e.key === 'Escape' && e.defaultPrevented) return; | |
| 1585 | if (e.key === 'Escape' && escClosedHints) { escClosedHints = false; e.preventDefault(); return; } | |
| 1586 | if (e.key === 'Escape' && leaveToList()) { e.preventDefault(); return; } | |
| 1578 | var row = rowForKey(e.key); | |
| 1579 | if (e.key === 'Escape') { if (row) row[3](e); return; } | |
| 1587 | 1580 | if (Date.now() - last > 1000) seq.length = 0; |
| 1588 | 1581 | last = Date.now(); |
| 1589 | 1582 | seq.push(e.key); |
| @@ -1603,39 +1596,53 @@ const shellCore = /* js */ ` | ||
| 1603 | 1596 | var gp = gotoPages().filter(function (p) { return p[3] !== '' && p[3] === e.key; })[0]; |
| 1604 | 1597 | if (gp) { seq.length = 0; e.preventDefault(); location.assign(gp[1]); return; } |
| 1605 | 1598 | } |
| 1606 | if (e.key === 'j') { seq.length = 0; e.preventDefault(); move(1); } | |
| 1607 | else if (e.key === 'k') { seq.length = 0; e.preventDefault(); move(-1); } | |
| 1608 | else if (e.key === 'o' || e.key === 'Enter') { seq.length = 0; openFocused(); } | |
| 1609 | else if (e.key === 'c') { | |
| 1610 | if (focusBox()) { seq.length = 0; e.preventDefault(); } else openNewIssue(e); | |
| 1611 | } else if (e.key === 'C') openNewIssue(e); | |
| 1612 | else if (e.key === '/') { | |
| 1613 | var box = doc.querySelector('[data-search-input]'); | |
| 1614 | if (box) { seq.length = 0; e.preventDefault(); box.focus(); box.select(); } | |
| 1615 | } else if (e.key === '?') { | |
| 1616 | seq.length = 0; e.preventDefault(); | |
| 1617 | // One press, one sheet: the help opens at once (Option still lays the keys over the page). | |
| 1618 | hints(false); openHelp(); | |
| 1619 | } | |
| 1620 | else if (e.key === ':') { seq.length = 0; e.preventDefault(); openGoto(); } | |
| 1599 | if (row) { seq.length = 0; row[3](e); } | |
| 1621 | 1600 | }); |
| 1622 | 1601 | |
| 1623 | 1602 | // ---- help overlay, generated from the shortcuts actually on the page ---- |
| 1603 | // One table: the sheet lists its rows and the keydown handler runs them. | |
| 1604 | // A row is [shown, what it does, the e.key values it answers, its action]; | |
| 1605 | // a chord is written 'Mod+<key>' (Cmd or Ctrl) and is read ahead of the field guard. | |
| 1606 | function submitBox(f) { if (f.requestSubmit) f.requestSubmit(); else f.submit(); } | |
| 1624 | 1607 | var STATIC_KEYS = [ |
| 1625 | ['j / k', 'Move between rows'], ['o or Enter', 'Open the focused row'], | |
| 1626 | ['/', 'Focus search'], ['Cmd/Ctrl K', 'Jump to anywhere'], [':', 'Go to a page by name'], ['?', 'This help'], | |
| 1627 | ['Esc', 'Leave a box, close a menu or dialog'], | |
| 1608 | ['j / k', 'Move between rows', ['j', 'k'], function (e) { e.preventDefault(); move(e.key === 'j' ? 1 : -1); }], | |
| 1609 | ['o or Enter', 'Open the focused row', ['o', 'Enter'], openFocused], | |
| 1610 | ['/', 'Focus search', ['/'], function (e) { | |
| 1611 | var box = doc.querySelector('[data-search-input]'); | |
| 1612 | if (box) { e.preventDefault(); box.focus(); box.select(); } | |
| 1613 | }], | |
| 1614 | ['Cmd/Ctrl K', 'Jump to anywhere', ['Mod+k'], function (e) { e.preventDefault(); openPalette(); }], | |
| 1615 | [':', 'Go to a page by name', [':'], function (e) { e.preventDefault(); openGoto(); }], | |
| 1616 | ['?', 'This help', ['?'], function (e) { e.preventDefault(); hints(false); openHelp(); }], | |
| 1617 | ['Esc', 'Leave a box, close a menu or dialog', ['Escape'], function (e) { | |
| 1618 | if (e.defaultPrevented) return; | |
| 1619 | if (escClosedHints) { escClosedHints = false; e.preventDefault(); return; } | |
| 1620 | if (leaveToList()) e.preventDefault(); | |
| 1621 | }], | |
| 1628 | 1622 | ]; |
| 1623 | // The keys a page row may answer: only these cost a look at the page. | |
| 1624 | var PAGE_KEYS = ['c', 'C', 'Mod+Enter']; | |
| 1625 | function answers(r, k) { return r[2].indexOf(k) >= 0; } | |
| 1626 | function rowForKey(k) { | |
| 1627 | var row = STATIC_KEYS.filter(function (r) { return answers(r, k); })[0]; | |
| 1628 | if (row || PAGE_KEYS.indexOf(k) < 0) return row; | |
| 1629 | return pageKeys().filter(function (r) { return answers(r, k); })[0]; | |
| 1630 | } | |
| 1629 | 1631 | // The rows a page earns by what it carries: a box to type in, a form |
| 1630 | 1632 | // that sends on Cmd/Ctrl+Enter. |
| 1631 | 1633 | function pageKeys() { |
| 1632 | 1634 | var out = []; |
| 1633 | if (pageBox() && !doc.querySelector('[data-shortcut="c"]')) out.push(['c', doc.querySelector('[data-box]').getAttribute('data-box') || 'Write here']); | |
| 1635 | if (pageBox() && !doc.querySelector('[data-shortcut="c"]')) out.push(['c', doc.querySelector('[data-box]').getAttribute('data-box') || 'Write here', ['c'], function (e) { if (focusBox()) e.preventDefault(); else openNewIssue(e); }]); | |
| 1634 | 1636 | // A page that keeps c for its own box shows Shift C alone; any other |
| 1635 | 1637 | // page opens the new issue on either. |
| 1636 | 1638 | var ownC = pageBox() || doc.querySelector('[data-shortcut="c"]'); |
| 1637 | out.push([ownC ? 'Shift C' : 'c / Shift C', 'New issue from this page']); | |
| 1638 | if (doc.querySelector('form[data-submit-key]')) out.push(['Cmd/Ctrl Enter', 'Submit the box you are typing in']); | |
| 1639 | out.push(ownC ? ['Shift C', 'New issue from this page', ['C'], openNewIssue] : ['c / Shift C', 'New issue from this page', ['c', 'C'], openNewIssue]); | |
| 1640 | // Only a form that asked for it (\`data-submit-key\`) is sent: the button's own | |
| 1641 | // submit, with its validation. Plain Enter stays a newline. | |
| 1642 | if (doc.querySelector('form[data-submit-key]')) out.push(['Cmd/Ctrl Enter', 'Submit the box you are typing in', ['Mod+Enter'], function (e) { | |
| 1643 | var f = e.target && e.target.form; | |
| 1644 | if (f && f.hasAttribute('data-submit-key')) { e.preventDefault(); submitBox(f); } | |
| 1645 | }]); | |
| 1639 | 1646 | return out; |
| 1640 | 1647 | } |
| 1641 | 1648 | // Built with DOM nodes, never innerHTML: every string below is page text, |
test/flow-system-keys.test.ts +1 −1
| @@ -26,7 +26,7 @@ for (const path of ["/flow", "/system"]) { | ||
| 26 | 26 | expect(shellSrc(await get("/boards"))).toBe(src); |
| 27 | 27 | expect(html).not.toContain("openHelp"); |
| 28 | 28 | const js = await (await fetch(`${f.base}${src}`)).text(); |
| 29 | expect(js).toContain("e.key === '?'"); | |
| 29 | expect(js).toContain("['?'], function (e)"); | |
| 30 | 30 | expect(js).toContain("help-dialog"); |
| 31 | 31 | }); |
| 32 | 32 | } |
test/help-keys.test.ts deleted −56
| @@ -1,56 +0,0 @@ | ||
| 1 | import { expect, test } from 'bun:test'; | |
| 2 | import { readFileSync } from 'node:fs'; | |
| 3 | ||
| 4 | const src = readFileSync(new URL('../src/web/client.ts', import.meta.url), 'utf8'); | |
| 5 | ||
| 6 | // The document keydown handler, up to the help overlay that follows it. | |
| 7 | const end = src.indexOf('// ---- help overlay'); | |
| 8 | const start = src.lastIndexOf("doc.addEventListener('keydown'", end); | |
| 9 | const handler = src.slice(start, end); | |
| 10 | ||
| 11 | const answered = (): Set<string> => { | |
| 12 | const keys = new Set<string>(); | |
| 13 | for (const m of handler.matchAll(/e\.key === '([^']+)'/g)) keys.add((m[1] ?? '').toLowerCase()); | |
| 14 | return keys; | |
| 15 | }; | |
| 16 | ||
| 17 | // A STATIC_KEYS label ("j / k", "o or Enter", "Cmd/Ctrl K", "Esc") as key names. | |
| 18 | const named = (label: string): string[] => { | |
| 19 | const words = label | |
| 20 | .replace('Cmd/Ctrl', ' ') | |
| 21 | .split(/\s+or\s+|\s+\/\s+|\s+/) | |
| 22 | .filter(Boolean) | |
| 23 | .map((w) => (w === 'Esc' ? 'escape' : w.toLowerCase())); | |
| 24 | return words; | |
| 25 | }; | |
| 26 | ||
| 27 | const listed = (): Set<string> => { | |
| 28 | const block = src.match(/var STATIC_KEYS = \[([\s\S]*?)\n \];/)?.[1] ?? ''; | |
| 29 | expect(block).not.toBe(''); | |
| 30 | const keys = new Set<string>(); | |
| 31 | for (const m of block.matchAll(/\[\s*'([^']+)'\s*,\s*'[^']*'\s*\]/g)) for (const k of named(m[1] ?? '')) keys.add(k); | |
| 32 | // `c` is a row pageKeys() adds on pages that carry a box. | |
| 33 | if (/out\.push\(\['c',/.test(src)) keys.add('c'); | |
| 34 | return keys; | |
| 35 | }; | |
| 36 | ||
| 37 | test('the handler and the help overlay are both found', () => { | |
| 38 | expect(start).toBeGreaterThan(0); | |
| 39 | expect(end).toBeGreaterThan(start); | |
| 40 | expect(answered().size).toBeGreaterThan(5); | |
| 41 | }); | |
| 42 | ||
| 43 | test('every key the handler answers has a row in STATIC_KEYS', () => { | |
| 44 | const rows = listed(); | |
| 45 | expect([...answered()].filter((k) => !rows.has(k))).toEqual([]); | |
| 46 | }); | |
| 47 | ||
| 48 | test('every key STATIC_KEYS names is answered by the handler', () => { | |
| 49 | const keys = answered(); | |
| 50 | expect([...listed()].filter((k) => !keys.has(k))).toEqual([]); | |
| 51 | }); | |
| 52 | ||
| 53 | test(': opens the palette and is listed', () => { | |
| 54 | expect(handler).toContain("e.key === ':'"); | |
| 55 | expect(src).toContain("[':', 'Go to a page by name']"); | |
| 56 | }); |
test/keyhelp.test.ts +70 −4
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { expect, test } from 'bun:test'; |
| 2 | 2 | import { readFileSync } from 'node:fs'; |
| 3 | import { shellJs } from '../src/web/client.ts'; | |
| 3 | 4 | |
| 4 | 5 | const src = readFileSync(new URL('../src/web/client.ts', import.meta.url), 'utf8'); |
| 5 | 6 | const layout = readFileSync(new URL('../src/web/layout.ts', import.meta.url), 'utf8'); |
| @@ -12,10 +13,10 @@ const help = src.slice(helpStart, src.indexOf('// ---- command palette')); | ||
| 12 | 13 | test('the field guard returns before any plain key is read', () => { |
| 13 | 14 | const guard = handler.indexOf("t.matches('input, textarea, select, [contenteditable]')"); |
| 14 | 15 | expect(guard).toBeGreaterThan(0); |
| 15 | for (const key of ['j', 'k', 'o', '?', '/', ':', 'c', 'C']) { | |
| 16 | const at = handler.lastIndexOf(`e.key === '${key}'`); | |
| 17 | expect(at).toBeGreaterThan(guard); | |
| 18 | } | |
| 16 | // The keys are looked up in the table, after the guard. | |
| 17 | expect(handler.indexOf('rowForKey(e.key)')).toBeGreaterThan(guard); | |
| 18 | // The chords are looked up ahead of it. | |
| 19 | expect(handler.indexOf("rowForKey('Mod+'")).toBeLessThan(guard); | |
| 19 | 20 | // The guard block ends in a bare return: nothing typed in a field falls through. |
| 20 | 21 | const block = handler.slice(guard, handler.indexOf('if (e.metaKey || e.ctrlKey || e.altKey) return;')); |
| 21 | 22 | expect(block).toMatch(/\n\s+return;\n\s+\}\n/); |
| @@ -43,3 +44,68 @@ test('the dialog is out of the flow and in the page chrome, built without innerH | ||
| 43 | 44 | expect(layout).toContain('id="help-dialog"'); |
| 44 | 45 | expect(help).not.toMatch(/\.innerHTML\s*[=+]/); |
| 45 | 46 | }); |
| 47 | ||
| 48 | // Evaluate the table itself: the rows between `function submitBox` and the sheet's builder, | |
| 49 | // with the handler's helpers stubbed, on a page that carries every page row. | |
| 50 | function table(page: { box?: boolean; submit?: boolean } = { box: true, submit: true }) { | |
| 51 | const code = shellJs.slice(shellJs.indexOf('function submitBox'), shellJs.indexOf('// Built with DOM nodes')); | |
| 52 | expect(code).toContain('var STATIC_KEYS'); | |
| 53 | const doc = { | |
| 54 | querySelector: (q: string) => (q === 'form[data-submit-key]' ? (page.submit ? {} : null) : q === '[data-box]' ? { getAttribute: () => 'Reply' } : null), | |
| 55 | }; | |
| 56 | const f = new Function( | |
| 57 | 'doc', 'pageBox', 'focusBox', 'openPalette', 'move', 'openFocused', 'openGoto', 'hints', 'openHelp', 'leaveToList', 'openNewIssue', 'escClosedHints', | |
| 58 | `${code}\nreturn { STATIC_KEYS: STATIC_KEYS, PAGE_KEYS: PAGE_KEYS, pageKeys: pageKeys, rowForKey: rowForKey };`, | |
| 59 | ); | |
| 60 | const noop = () => {}; | |
| 61 | return f(doc, () => (page.box ? {} : null), () => true, noop, noop, noop, noop, noop, noop, noop, () => false, noop, false) as { | |
| 62 | STATIC_KEYS: any[][]; PAGE_KEYS: string[]; pageKeys: () => any[][]; rowForKey: (k: string) => any[] | undefined; | |
| 63 | }; | |
| 64 | } | |
| 65 | ||
| 66 | test('every row of the table, and of a page that earns them all, has an action', () => { | |
| 67 | const t = table(); | |
| 68 | const rows = [...t.STATIC_KEYS, ...t.pageKeys()]; | |
| 69 | expect(rows.length).toBeGreaterThanOrEqual(10); | |
| 70 | for (const r of rows) { | |
| 71 | expect(typeof r[0]).toBe('string'); | |
| 72 | expect(Array.isArray(r[2]) && r[2].length > 0).toBe(true); | |
| 73 | expect(typeof r[3]).toBe('function'); | |
| 74 | } | |
| 75 | }); | |
| 76 | ||
| 77 | test('every key a row lists resolves to that row, chords included', () => { | |
| 78 | const t = table(); | |
| 79 | for (const r of [...t.STATIC_KEYS, ...t.pageKeys()]) for (const k of r[2] as string[]) expect(t.rowForKey(k)?.[0]).toBe(r[0]); | |
| 80 | expect(t.rowForKey('Mod+k')?.[0]).toBe('Cmd/Ctrl K'); | |
| 81 | expect(t.rowForKey('Mod+Enter')?.[0]).toBe('Cmd/Ctrl Enter'); | |
| 82 | expect(t.rowForKey('x')).toBeUndefined(); | |
| 83 | }); | |
| 84 | ||
| 85 | test('the keys a page row may answer are the ones the cheap lookup lets through', () => { | |
| 86 | const t = table(); | |
| 87 | const keys = t.pageKeys().flatMap((r) => r[2] as string[]); | |
| 88 | for (const k of keys) expect(t.PAGE_KEYS).toContain(k); | |
| 89 | // A page without a submit form lists no chord for it. | |
| 90 | expect(table({ box: false, submit: false }).rowForKey('Mod+Enter')).toBeUndefined(); | |
| 91 | }); | |
| 92 | ||
| 93 | test('the label of each row names the keys it answers', () => { | |
| 94 | const t = table(); | |
| 95 | for (const r of [...t.STATIC_KEYS, ...t.pageKeys()]) { | |
| 96 | const shown = (r[0] as string).toLowerCase().replace('esc', 'escape').replace('cmd/ctrl', 'mod').replace(/ /g, ''); | |
| 97 | for (const k of r[2] as string[]) { | |
| 98 | const key = k.toLowerCase().replace('mod+', 'mod'); | |
| 99 | expect(shown.includes(key.replace(/^mod/, '')) || shown.includes(key)).toBe(true); | |
| 100 | } | |
| 101 | } | |
| 102 | }); | |
| 103 | ||
| 104 | test('after the guards the handler reads no key literally but Escape; the table answers', () => { | |
| 105 | const after = handler.slice(handler.indexOf('if (e.metaKey || e.ctrlKey || e.altKey) return;')); | |
| 106 | expect([...after.matchAll(/e\.key === '([^']+)'/g)].map((m) => m[1])).toEqual(['Escape']); | |
| 107 | // Before the field guard the chords are looked up in the table too, not written out. | |
| 108 | const before = handler.slice(0, handler.indexOf("t.matches('input, textarea, select, [contenteditable]')")); | |
| 109 | expect(before).toContain("rowForKey('Mod+'"); | |
| 110 | expect(before).not.toContain("e.key === 'Enter'"); | |
| 111 | }); |
test/keys-palette.test.ts +1 −2
| @@ -76,8 +76,7 @@ test("the keys script: : opens the combobox prompt, announces the lead, says whe | ||
| 76 | 76 | const page = await (await fetch(`${f.base}/`)).text(); |
| 77 | 77 | const path = /src="([^"]*\.js[^"]*)"/.exec(page.slice(page.indexOf("<body")))![1]!; |
| 78 | 78 | const js = await (await fetch(`${f.base}${path}`)).text(); |
| 79 | expect(js).toContain("e.key === ':'"); | |
| 80 | expect(js).toMatch(/e\.key === ':'\) \{ seq\.length = 0; e\.preventDefault\(\); openGoto\(\)/); | |
| 79 | expect(js).toContain("[':'], function (e) { e.preventDefault(); openGoto(); }"); | |
| 81 | 80 | expect(js).toContain("function goMatch(pages, q)"); |
| 82 | 81 | for (const a of ["'combobox'", "'aria-controls', 'goto-list'", "aria-activedescendant", "'listbox'", "'option'"]) expect(js).toContain(a); |
| 83 | 82 | expect(js).not.toContain("data-goto"); |
test/new-issue-anywhere.test.ts +4 −3
| @@ -47,12 +47,13 @@ describe("a new issue from any page", () => { | ||
| 47 | 47 | }); |
| 48 | 48 | |
| 49 | 49 | test("c and Shift C open the door from the page, and the help names each case", () => { |
| 50 | expect(shellJs).toContain("e.key === 'C') openNewIssue(e)"); | |
| 51 | expect(shellJs).toContain("if (focusBox()) { seq.length = 0; e.preventDefault(); } else openNewIssue(e);"); | |
| 50 | expect(shellJs).toContain("['c', 'C'], openNewIssue]"); | |
| 51 | expect(shellJs).toContain("['C'], openNewIssue]"); | |
| 52 | expect(shellJs).toContain("['c'], function (e) { if (focusBox()) e.preventDefault(); else openNewIssue(e); }]"); | |
| 52 | 53 | expect(shellJs).toContain("'/new/issue?from=' + encodeURIComponent(p)"); |
| 53 | 54 | // A page with its own box (or its own c) shows Shift C alone; any other, both keys. |
| 54 | 55 | expect(shellJs).toContain("var ownC = pageBox() || doc.querySelector('[data-shortcut=\"c\"]');"); |
| 55 | expect(shellJs).toContain("ownC ? 'Shift C' : 'c / Shift C', 'New issue from this page'"); | |
| 56 | expect(shellJs).toContain("ownC ? ['Shift C', 'New issue from this page', ['C'], openNewIssue] : ['c / Shift C'"); | |
| 56 | 57 | expect(shellJs).toContain("out.push(['c', doc.querySelector('[data-box]')"); |
| 57 | 58 | }); |
| 58 | 59 |
test/web-ui.test.ts +3 −4
| @@ -988,7 +988,7 @@ describe("web ui", () => { | ||
| 988 | 988 | const seeded = openIssue(app, mel, proj, { title: "dismiss me" }); |
| 989 | 989 | if (!seeded.ok) throw new Error(seeded.error); |
| 990 | 990 | expect(assignIssue(app, mel, proj, seeded.value, mel).ok).toBe(true); |
| 991 | expect(shellJs).toContain(`e.key === '${NEW_ISSUE_KEY}'`); | |
| 991 | expect(shellJs).toContain(`['${NEW_ISSUE_KEY}'], openNewIssue]`); | |
| 992 | 992 | for (const k of SCRIPT_KEYS) { |
| 993 | 993 | const page = await (await fetch(`${base}${k.page}`, { headers: { cookie: `${owner}; forge_mode=studio` } })).text(); |
| 994 | 994 | // A menu row draws the key in its column; anything else carries it as data-hint (#8930). |
| @@ -1064,9 +1064,8 @@ describe("web ui", () => { | ||
| 1064 | 1064 | |
| 1065 | 1065 | // The shell sends a form on Enter only with a modifier held, only for a |
| 1066 | 1066 | // form that opted in, and nowhere else. |
| 1067 | expect(shellJs).toContain( | |
| 1068 | "(e.metaKey || e.ctrlKey) && e.key === 'Enter' && t.form && t.form.hasAttribute('data-submit-key')", | |
| 1069 | ); | |
| 1067 | expect(shellJs).toContain("['Mod+Enter'], function (e) {"); | |
| 1068 | expect(shellJs).toContain("if (f && f.hasAttribute('data-submit-key')) { e.preventDefault(); submitBox(f); }"); | |
| 1070 | 1069 | const sends = shellJs |
| 1071 | 1070 | .split("\n") |
| 1072 | 1071 | .filter((l) => /requestSubmit|\.submit\(/.test(l)); |