Public: anyone can read this; the crew can change it.
The command palette closes when you click or tap outside it, and on a phone it sits where a thumb and the keyboard leave room #11684
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11664-the-command-palette-closes-when-you-clic → main 3 commits fork 3 threads · 3 unresolved
5 files
changed +155
−7
docs/design/areas/palette.md +2
| @@ -10,6 +10,8 @@ The command palette is the keyboard-first fuzzy-find overlay (`⌘K` / `Ctrl K`, | ||
| 10 | 10 | - `.palette-list` — the scrolling results area under the input: a fixed height (not a ceiling) so the dialog does not jump as rows come and go. |
| 11 | 11 | - `.palette-foot` — the faint one-line strip of key hints under the list, set off by a rule above it. |
| 12 | 12 | |
| 13 | Dismissal and the phone: a click or tap on the backdrop closes the palette (a drag that began inside does not), and the page behind does not scroll. Under 720px the dialog is a sheet pinned to the top of the visual viewport (`--vv-top`, `--vv-h`, set by `openPalette()`), so the keyboard shrinks it; the input is 16px, rows 44px, and the foot trades its key hints for a link-variant `[data-palette-close]` button. No new class. | |
| 14 | ||
| 13 | 15 | The palette's tell row (`data-tell`, last in the list) sends the typed sentence to `/tell`. Its answer is one `.toast[data-palette-tell]` at the top of `#main`, drawn by `tellFromHere()` in `src/web/client.ts` from existing parts only: `.toast` and `.toast-body`, a link-variant small `.btn` as the dismiss ×, and focus moved to the toast (`tabindex="-1"`, `role="status"`) because the palette that held it has just closed. It clears itself after 12 seconds. No new component class. |
| 14 | 16 | |
| 15 | 17 | ## Keys beside their actions |
src/web/client.ts +29 −3
| @@ -2173,7 +2173,9 @@ const shellCore = /* js */ ` | ||
| 2173 | 2173 | input.value = ''; |
| 2174 | 2174 | render(false); |
| 2175 | 2175 | var close = openModal(dlg, function () { |
| 2176 | dlg.onkeydown = null; dlg.onpointerdown = null; | |
| 2176 | dlg.onkeydown = null; dlg.onpointerdown = null; dlg.onclick = null; | |
| 2177 | if (vv) { vv.removeEventListener('resize', fit); vv.removeEventListener('scroll', fit); } | |
| 2178 | dlg.style.removeProperty('--vv-top'); dlg.style.removeProperty('--vv-h'); | |
| 2177 | 2179 | list.onclick = null; list.onpointermove = null; |
| 2178 | 2180 | input.oninput = null; |
| 2179 | 2181 | window.clearTimeout(remoteTimer); |
| @@ -2182,8 +2184,32 @@ const shellCore = /* js */ ` | ||
| 2182 | 2184 | }); |
| 2183 | 2185 | closePalette = close; |
| 2184 | 2186 | dlg.onkeydown = onKey; |
| 2185 | // A press anywhere but the input would move the focus off it. | |
| 2186 | dlg.onpointerdown = function (e) { if (e.target !== input) e.preventDefault(); }; | |
| 2187 | // The sheet follows the visual viewport, so the on-screen keyboard | |
| 2188 | // shrinks it instead of covering its input. | |
| 2189 | var vv = window.visualViewport; | |
| 2190 | function fit() { | |
| 2191 | if (!vv) return; | |
| 2192 | dlg.style.setProperty('--vv-top', Math.max(0, vv.offsetTop) + 'px'); | |
| 2193 | dlg.style.setProperty('--vv-h', Math.max(0, vv.height) + 'px'); | |
| 2194 | } | |
| 2195 | if (vv) { fit(); vv.addEventListener('resize', fit); vv.addEventListener('scroll', fit); } | |
| 2196 | // A press anywhere but the input would move the focus off it. Where it | |
| 2197 | // began is remembered: a drag that starts inside and ends on the | |
| 2198 | // backdrop is a selection, not a dismissal. | |
| 2199 | var pressedOutside = false; | |
| 2200 | function outside(e) { | |
| 2201 | var r = dlg.getBoundingClientRect(); | |
| 2202 | return e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom; | |
| 2203 | } | |
| 2204 | dlg.onpointerdown = function (e) { | |
| 2205 | pressedOutside = e.target === dlg && outside(e); | |
| 2206 | if (e.target !== input) e.preventDefault(); | |
| 2207 | }; | |
| 2208 | dlg.onclick = function (e) { | |
| 2209 | var x = e.target && e.target.closest ? e.target.closest('[data-palette-close]') : null; | |
| 2210 | if (x || (e.target === dlg && pressedOutside && outside(e))) { e.preventDefault(); close(); } | |
| 2211 | pressedOutside = false; | |
| 2212 | }; | |
| 2187 | 2213 | input.oninput = function () { ask(input.value.trim()); render(false); }; |
| 2188 | 2214 | list.onpointermove = function (e) { |
| 2189 | 2215 | var a = e.target && e.target.closest ? e.target.closest('.palette-item') : null; |
src/web/layout.ts +2 −1
| @@ -588,7 +588,8 @@ function overlays(): Html { | ||
| 588 | 588 | <input class="input palette-input" type="text" role="combobox" aria-expanded="true" aria-haspopup="listbox" aria-controls="palette-list" aria-autocomplete="list" placeholder="Jump to a repository, page or action…" aria-label="Jump to anywhere" autocomplete="off" autocapitalize="off" spellcheck="false" /> |
| 589 | 589 | <div class="palette-list" id="palette-list" role="listbox" aria-label="Results"></div> |
| 590 | 590 | <div class="palette-foot"> |
| 591 | ${kbd(["↑", "↓"])} navigate ${kbd(["↵"])} open ${kbd(["⌘", "↵"])} its app ${kbd(["esc"])} close | |
| 591 | <span data-keys>${kbd(["↑", "↓"])} navigate ${kbd(["↵"])} open ${kbd(["⌘", "↵"])} its app ${kbd(["esc"])} close</span> | |
| 592 | <button class="btn" data-variant="link" type="button" data-palette-close>Close</button> | |
| 592 | 593 | </div> |
| 593 | 594 | </dialog> |
| 594 | 595 | <dialog class="dialog" id="help-dialog" aria-labelledby="help-title"> |
src/web/style/components.ts +25 −3
| @@ -812,9 +812,9 @@ details[open] > summary.row > .row-lead { transform: rotate(90deg); } | ||
| 812 | 812 | /* A dialog centres itself with its own auto margins, which the reset zeroes |
| 813 | 813 | — so anything that wants to be centred has to say so again. */ |
| 814 | 814 | .palette { width: min(620px, calc(100vw - 24px)); margin: 12dvh auto auto; } |
| 815 | @media (max-width: 719px) { | |
| 816 | .palette { width: 100%; max-width: 100%; margin: auto auto 0; } | |
| 817 | } | |
| 815 | /* No scrolling the page behind an open palette. */ | |
| 816 | html:has(.palette[open]) { overflow: hidden; } | |
| 817 | .palette [data-palette-close] { display: none; } | |
| 818 | 818 | .palette .palette-input { |
| 819 | 819 | width: 100%; border: 0; border-bottom: 1px solid var(--border); border-radius: 0; |
| 820 | 820 | min-height: 48px; padding: 0 16px; background: none; font-size: 15px; box-shadow: none; |
| @@ -863,6 +863,28 @@ details[open] > summary.row > .row-lead { transform: rotate(90deg); } | ||
| 863 | 863 | height: 100%; margin: 0; color: var(--muted-fg); font-size: 13px; text-align: center; |
| 864 | 864 | } |
| 865 | 865 | |
| 866 | /* Last of the palette rules, so nothing above can override the sheet. */ | |
| 867 | /* On a phone the palette is a sheet pinned to the top of the visual viewport | |
| 868 | (the script sets --vv-top and --vv-h as the keyboard comes and goes), its | |
| 869 | list scrolling inside it, clear of the notch and the home indicator. */ | |
| 870 | @media (max-width: 719px) { | |
| 871 | .palette { | |
| 872 | position: fixed; inset: auto 0 auto 0; width: 100%; max-width: 100%; margin: 0; | |
| 873 | top: calc(var(--vv-top, 0px) + env(safe-area-inset-top, 0px)); | |
| 874 | height: calc(var(--vv-h, 100dvh) - env(safe-area-inset-top, 0px)); | |
| 875 | max-height: calc(var(--vv-h, 100dvh) - env(safe-area-inset-top, 0px)); | |
| 876 | border-radius: 0 0 var(--radius-lg) var(--radius-lg); border-top: 0; border-bottom: 1px solid var(--border); | |
| 877 | display: flex; flex-direction: column; | |
| 878 | } | |
| 879 | .palette[open] { display: flex; } | |
| 880 | .palette .palette-input { flex: none; font-size: 16px; } | |
| 881 | .palette-list { flex: 1; min-height: 0; height: auto; } | |
| 882 | .palette-item { min-height: 44px; } | |
| 883 | .palette-foot { flex: none; padding-bottom: calc(8px + env(safe-area-inset-bottom, 0px)); } | |
| 884 | .palette-foot [data-keys] { display: none; } | |
| 885 | .palette [data-palette-close] { display: inline-flex; margin-left: auto; min-height: 44px; } | |
| 886 | } | |
| 887 | ||
| 866 | 888 | /* ---- toast / flash ---- */ |
| 867 | 889 | .toast { |
| 868 | 890 | display: flex; align-items: flex-start; gap: 8px; |
test/palette-dismiss.test.ts added +97
| @@ -0,0 +1,97 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { Window } from "happy-dom"; | |
| 3 | import { shellJs } from "../src/web/client"; | |
| 4 | import { components } from "../src/web/style/components"; | |
| 5 | ||
| 6 | // The real shell script, run in a happy-dom page that carries the palette's markup. | |
| 7 | function page() { | |
| 8 | const w: any = new Window({ url: "http://localhost/", width: 390, height: 844 }); | |
| 9 | const d = w.document; | |
| 10 | d.body.innerHTML = `<button id="opener" data-palette>Search</button> | |
| 11 | <dialog id="palette"><input class="palette-input"><div class="palette-list"></div> | |
| 12 | <div class="palette-foot"><button data-palette-close type="button">Close</button></div></dialog> | |
| 13 | <script id="palette-index" type="application/json">{"items":[]}</script>`; | |
| 14 | const dlg: any = d.getElementById("palette"); | |
| 15 | dlg.showModal = () => dlg.setAttribute("open", ""); | |
| 16 | dlg.close = () => { dlg.removeAttribute("open"); dlg.dispatchEvent(new w.Event("close")); }; | |
| 17 | dlg.getBoundingClientRect = () => ({ left: 20, right: 370, top: 0, bottom: 400 }); | |
| 18 | w.matchMedia = () => ({ matches: false, addEventListener() {} }); | |
| 19 | w.fetch = () => new Promise(() => {}); | |
| 20 | new Function("document", "window", "location", "sessionStorage", "localStorage", "CSS", "navigator", shellJs)( | |
| 21 | d, w, w.location, w.sessionStorage, w.localStorage, undefined, w.navigator); | |
| 22 | const opener = d.getElementById("opener"); | |
| 23 | opener.focus(); | |
| 24 | opener.click(); | |
| 25 | const press = (el: any, type: string, x: number, y: number) => { | |
| 26 | const e: any = new w.MouseEvent(type, { bubbles: true, cancelable: true, clientX: x, clientY: y }); | |
| 27 | el.dispatchEvent(e); | |
| 28 | }; | |
| 29 | const tap = (el: any, x: number, y: number) => { press(el, "pointerdown", x, y); press(el, "click", x, y); }; | |
| 30 | return { d, dlg, opener, press, tap, w }; | |
| 31 | } | |
| 32 | ||
| 33 | test("opening the palette opens the dialog", () => { | |
| 34 | expect(page().dlg.hasAttribute("open")).toBe(true); | |
| 35 | }); | |
| 36 | ||
| 37 | test("a click or tap on the backdrop closes it and the focus goes back", () => { | |
| 38 | const p = page(); | |
| 39 | p.tap(p.dlg, 5, 600); | |
| 40 | expect(p.dlg.hasAttribute("open")).toBe(false); | |
| 41 | expect(p.d.activeElement).toBe(p.opener); | |
| 42 | }); | |
| 43 | ||
| 44 | test("a drag that began inside and ended on the backdrop does not close it", () => { | |
| 45 | const p = page(); | |
| 46 | p.press(p.d.querySelector(".palette-input"), "pointerdown", 100, 20); | |
| 47 | p.press(p.dlg, "click", 5, 600); | |
| 48 | expect(p.dlg.hasAttribute("open")).toBe(true); | |
| 49 | }); | |
| 50 | ||
| 51 | test("a click inside the dialog's own box does not close it", () => { | |
| 52 | const p = page(); | |
| 53 | p.tap(p.dlg, 100, 100); | |
| 54 | expect(p.dlg.hasAttribute("open")).toBe(true); | |
| 55 | }); | |
| 56 | ||
| 57 | test("the Close button closes it", () => { | |
| 58 | const p = page(); | |
| 59 | p.press(p.d.querySelector("[data-palette-close]"), "click", 100, 300); | |
| 60 | expect(p.dlg.hasAttribute("open")).toBe(false); | |
| 61 | }); | |
| 62 | ||
| 63 | test("Escape closes it", () => { | |
| 64 | const p = page(); | |
| 65 | p.dlg.dispatchEvent(new p.w.KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true })); | |
| 66 | expect(p.dlg.hasAttribute("open")).toBe(false); | |
| 67 | }); | |
| 68 | ||
| 69 | test("the visual viewport sizes the sheet, and the sizing is removed on close", () => { | |
| 70 | const w: any = new Window({ url: "http://localhost/" }); | |
| 71 | const listeners: Record<string, () => void> = {}; | |
| 72 | const vv: any = { offsetTop: 0, height: 400, addEventListener: (t: string, f: () => void) => (listeners[t] = f), removeEventListener() {} }; | |
| 73 | const d = w.document; | |
| 74 | d.body.innerHTML = `<button data-palette>S</button><dialog id="palette"><input class="palette-input"><div class="palette-list"></div></dialog><script id="palette-index" type="application/json">{"items":[]}</script>`; | |
| 75 | const dlg: any = d.getElementById("palette"); | |
| 76 | dlg.showModal = () => dlg.setAttribute("open", ""); | |
| 77 | dlg.close = () => { dlg.removeAttribute("open"); dlg.dispatchEvent(new w.Event("close")); }; | |
| 78 | w.visualViewport = vv; w.matchMedia = () => ({ matches: false, addEventListener() {} }); w.fetch = () => new Promise(() => {}); | |
| 79 | new Function("document", "window", "location", "sessionStorage", "localStorage", "CSS", "navigator", shellJs)( | |
| 80 | d, w, w.location, w.sessionStorage, w.localStorage, undefined, w.navigator); | |
| 81 | d.querySelector("[data-palette]").click(); | |
| 82 | expect(dlg.style.getPropertyValue("--vv-h")).toBe("400px"); | |
| 83 | vv.height = 300; vv.offsetTop = 12; listeners.resize!(); | |
| 84 | expect(dlg.style.getPropertyValue("--vv-h")).toBe("300px"); | |
| 85 | expect(dlg.style.getPropertyValue("--vv-top")).toBe("12px"); | |
| 86 | dlg.close(); | |
| 87 | expect(dlg.style.getPropertyValue("--vv-h")).toBe(""); | |
| 88 | }); | |
| 89 | ||
| 90 | test("the phone sheet's rules come after every base rule they override", () => { | |
| 91 | const sheet = components.indexOf("Last of the palette rules"); | |
| 92 | for (const base of [".palette-foot {", ".palette-list {", ".palette-item {", ".palette .palette-input {", ".palette-empty {"]) { | |
| 93 | expect(components.indexOf(base)).toBeGreaterThan(-1); | |
| 94 | expect(components.indexOf(base)).toBeLessThan(sheet); | |
| 95 | } | |
| 96 | expect(components).toContain("html:has(.palette[open]) { overflow: hidden; }"); | |
| 97 | }); |
new above
offline
These assertions check that strings exist in the source, not that clicking the backdrop closes it. Remove the
pressedOutsideguard and a reworded line still passes. Test the behaviour against a DOM stub.