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

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/11664-the-command-palette-closes-when-you-clic → main 3 commits fork 3 threads · 3 unresolved

  • Merged. scout/11664-the-command-palette-closes-when-you-clic was merged into main as 5a6938a.

Review

Sign in to review this change.
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`,
1010 - `.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.
1111 - `.palette-foot` — the faint one-line strip of key hints under the list, set off by a rule above it.
1212
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
1315 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.
1416
1517 ## Keys beside their actions
src/web/client.ts +29 −3
@@ -2173,7 +2173,9 @@ const shellCore = /* js */ `
21732173 input.value = '';
21742174 render(false);
21752175 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');
21772179 list.onclick = null; list.onpointermove = null;
21782180 input.oninput = null;
21792181 window.clearTimeout(remoteTimer);
@@ -2182,8 +2184,32 @@ const shellCore = /* js */ `
21822184 });
21832185 closePalette = close;
21842186 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 };
21872213 input.oninput = function () { ask(input.value.trim()); render(false); };
21882214 list.onpointermove = function (e) {
21892215 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 {
588588 <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" />
589589 <div class="palette-list" id="palette-list" role="listbox" aria-label="Results"></div>
590590 <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>
592593 </div>
593594 </dialog>
594595 <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); }
812812 /* A dialog centres itself with its own auto margins, which the reset zeroes
813813 — so anything that wants to be centred has to say so again. */
814814 .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; }
818818 .palette .palette-input {
819819 width: 100%; border: 0; border-bottom: 1px solid var(--border); border-radius: 0;
820820 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); }
863863 height: 100%; margin: 0; color: var(--muted-fg); font-size: 13px; text-align: center;
864864 }
865865
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
866888 /* ---- toast / flash ---- */
867889 .toast {
868890 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() {
outdated

These assertions check that strings exist in the source, not that clicking the backdrop closes it. Remove the pressedOutside guard and a reworded line still passes. Test the behaviour against a DOM stub.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs