Public: anyone can read this; the crew can change it.

New forge picks its repositories from a searchable multiselect, not a long page of checkboxes #8931

Merged opened this in answer to

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

Merged

scout/forge:scout/8929-new-forge-picks-its-repositories-from-a → main 3 commits fork 4 threads · 4 unresolved

  • Merged. scout/8929-new-forge-picks-its-repositories-from-a was merged into main as 9039e3f.

Review

Sign in to review this change.
7 files changed +310 −54
docs/design/areas/pick.md added +11
@@ -0,0 +1,11 @@
1 # Pick list
2
3 A long list of options to choose many from (#8929), used first by `/forges/new` for the apps to fork and the crew.
4 `pickList()` in `src/web/pick.ts`; the script (`pickJs`) rides in `shellJs`, the styles in `pickCss`.
5
6 - `.pick` — the fieldset. Its options are plain checkboxes in `.pick-list`, a box that scrolls at about six rows, so the form submits and reads with no script.
7 - `.pick-bar` — a filter (`input[type=search]`) and the `.pick-chips` of what is ticked; hidden until the script runs.
8 - `.pick-chip` — a button naming one ticked option; pressing it unticks it. Its `aria-label` says "Remove …".
9 - `.pick-count` — "N of M noun picked", a `role=status` line rendered by the server and kept by the script.
10
11 Keys: type to filter; Enter ticks the first match; Down moves into the list, Up/Down walk the visible options, Up from the first returns to the filter; Escape clears the filter.
docs/features/pick-list.md added +13
@@ -0,0 +1,13 @@
1 # Pick list (#8929)
2
3 `/forges/new` chooses the apps to fork and the crew whose briefs go along from a pick list, not a page of checkboxes. The component is `pickList()` in `src/web/pick.ts`, for any long pick list; its styles and keys are in `docs/design/areas/pick.md`.
4
5 **Using it.** Type in the filter to narrow the list; tick options in the short list below it. What is ticked shows as chips above, each a button that unticks it, and a line counts "N of M repositories picked". The chip bar scrolls past a few rows, so the form stays on one screen on a phone.
6
7 **Keys.** Enter ticks the first match. Down moves from the filter into the list; Up and Down walk the visible options; Up from the first returns to the filter. Escape clears the filter. Space ticks the focused option, as any checkbox.
8
9 **Without JavaScript.** The options are plain checkboxes in a box that scrolls; the form submits as before. The filter and chips appear only when the script runs.
10
11 **Screen readers.** The options are native checkboxes in a fieldset with the legend as its name. A refusal is read with the fieldset (`aria-describedby`). The count is announced when a pick changes, not while filtering.
12
13 **The terminal.** `forge` TUI's new-forge screen takes `owner/name` apps as a comma-separated text field and has no checkboxes; it is unaffected.
src/web/client.ts +2 −1
@@ -11,6 +11,7 @@
1111 */
1212
1313 import { actNowJs } from "./act_now.ts";
14 import { pickJs } from "./pick.ts";
1415 import { imageViewJs } from "./render/image.ts";
1516 import { STAGE_BADGE_JS } from "./stage-row.ts";
1617 import { paletteHelpersJs } from "./palette.ts";
@@ -2043,7 +2044,7 @@ const shellCore = /* js */ `
20432044 `;
20442045
20452046 /** The shell and the one script that makes actions answer at once (`act_now.ts`). */
2046 export const shellJs = `${shellCore}\n${actNowJs}`;
2047 export const shellJs = `${shellCore}\n${actNowJs}\n${pickJs}`;
20472048
20482049 /**
20492050 * Live regions. A page marks the parts of itself that change while it is
src/web/pages/new-forge.ts +20 −53
@@ -30,6 +30,7 @@ import { notFoundPage, pageHead } from "../chrome.ts";
3030 import { field, readForm, type Form } from "../form.ts";
3131 import { html, type Html } from "../html.ts";
3232 import { page } from "../layout.ts";
33 import { pickList } from "../pick.ts";
3334 import { btn, card, field as formField, row, selectField } from "../ui.ts";
3435
3536 interface Values {
@@ -80,29 +81,6 @@ function errorsOf(door: Door): Errors {
8081 return { form: message };
8182 }
8283
83 function checks(
84 name: string,
85 items: readonly { value: string; label: string; note?: string }[],
86 chosen: readonly string[],
87 ): Html {
88 return html`${items.map(
89 (i) =>
90 html`<label class="check">
91 <input
92 type="checkbox"
93 name="${name}"
94 value="${i.value}"
95 ${chosen.includes(i.value) ? html`checked` : null}
96 />
97 <span
98 >${i.label}${i.note
99 ? html` <span class="muted small">${i.note}</span>`
100 : null}</span
101 >
102 </label>`,
103 )}`;
104 }
105
10684 /** What the form reads before it draws: room, then (when there is room) the repositories and the crew. */
10785 interface Reads {
10886 readonly full: string | undefined;
@@ -212,20 +190,17 @@ function formView(
212190 hint: "In your words: the crew's first direction. Optional.",
213191 ...(errors["direction"] ? { error: errors["direction"] } : {}),
214192 })}
215 <fieldset class="field" ${errors["apps"] ? html`data-invalid` : null}>
216 <legend>Apps to fork, with their history</legend>
217 ${checks(
218 "apps",
219 repos.map((r) => ({
220 value: `${r.ownerName}/${r.name}`,
221 label: `${r.ownerName}/${r.name}`,
222 })),
223 values.apps,
224 )}
225 ${errors["apps"]
226 ? html`<span class="error" id="f-apps-err">${errors["apps"]}</span>`
227 : null}
228 </fieldset>
193 ${pickList({
194 legend: "Apps to fork, with their history",
195 name: "apps",
196 noun: "repositories",
197 items: repos.map((r) => ({
198 value: `${r.ownerName}/${r.name}`,
199 label: `${r.ownerName}/${r.name}`,
200 })),
201 chosen: values.apps,
202 error: errors["apps"],
203 })}
229204 ${formField({
230205 label: "Or a fresh app (optional)",
231206 name: "fresh",
@@ -263,22 +238,14 @@ function formView(
263238 </fieldset>
264239 ${crew.length === 0
265240 ? null
266 : html`<fieldset class="field">
267 <legend>Crew whose briefs go along</legend>
268 ${checks(
269 "crew",
270 crew.map((a) => ({
271 value: a.user.username,
272 label: a.user.username,
273 })),
274 values.crew,
275 )}
276 ${errors["crew"]
277 ? html`<span class="error" id="f-crew-err"
278 >${errors["crew"]}</span
279 >`
280 : null}
281 </fieldset>`}
241 : pickList({
242 legend: "Crew whose briefs go along",
243 name: "crew",
244 noun: "crew",
245 items: crew.map((a) => ({ value: a.user.username, label: a.user.username })),
246 chosen: values.crew,
247 error: errors["crew"],
248 })}
282249 ${anon
283250 ? null
284251 : html`<fieldset class="field">
src/web/pick.ts added +154
@@ -0,0 +1,154 @@
1 /**
2 * A long pick list as one compact control (#8929): the options are plain
3 * checkboxes in a short scrolling box, so the form submits and reads with no
4 * script. `pickJs` adds a filter, removable chips for what is ticked, and a
5 * count; the server renders the count so the first paint does not move.
6 */
7
8 import { html, type Html } from "./html.ts";
9
10 export interface PickItem {
11 readonly value: string;
12 readonly label: string;
13 }
14
15 export interface PickOptions {
16 readonly legend: string;
17 readonly name: string;
18 readonly items: readonly PickItem[];
19 readonly chosen: readonly string[];
20 readonly error?: string | undefined;
21 /** The noun in "3 of 150 repositories picked". */
22 readonly noun?: string;
23 }
24
25 export const pickCount = (picked: number, total: number, noun: string): string =>
26 `${picked} of ${total} ${noun} picked`;
27
28 export function pickList(o: PickOptions): Html {
29 const noun = o.noun ?? "options";
30 const picked = o.items.filter((i) => o.chosen.includes(i.value)).length;
31 return html`<fieldset class="field pick" data-pick="${noun}" ${o.error ? html`data-invalid aria-describedby="f-${o.name}-err"` : null}>
32 <legend>${o.legend}</legend>
33 <div class="pick-bar" data-pick-bar hidden>
34 <span class="pick-chips" data-pick-chips></span>
35 <input
36 class="input"
37 type="search"
38 data-pick-filter
39 autocomplete="off"
40 placeholder="Type to filter ${noun}"
41 aria-label="Filter ${noun}"
42 />
43 </div>
44 <div class="pick-list">
45 ${o.items.map(
46 (i) =>
47 html`<label class="check" data-pick-item>
48 <input
49 type="checkbox"
50 name="${o.name}"
51 value="${i.value}"

role=status on a line rewritten per keystroke is noisy for screen readers; update it on pick changes, and keep the shown-count out of the live region.

52 ${o.chosen.includes(i.value) ? html`checked` : null}
53 />
54 <span>${i.label}</span>
55 </label>`,

Error span has no id and the fieldset no aria-describedby; restore id (e.g. f-<name>-err) and point aria-describedby at it.

56 )}
57 </div>
58 <p class="muted small pick-count" data-pick-count>${pickCount(picked, o.items.length, noun)}</p>
59 <span class="sr-only" role="status" data-pick-live></span>
60 ${o.error ? html`<span class="error" id="f-${o.name}-err">${o.error}</span>` : null}
61 </fieldset>`;
62 }
63
64 export const pickCss = /* css */ `
65 .pick-bar { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 0 0 8px; }
66 .pick-bar[hidden] { display: none; }
67 .pick-bar { max-height: 96px; overflow-y: auto; }
68 .pick-chips { display: contents; }
69 .pick-chip {
outdated

Chips are unbounded; give .pick-chips/.pick-bar a max-height with overflow-y so many picks do not push the form off one screen on a phone.

70 display: inline-flex; align-items: center; gap: 4px; max-width: 100%;
71 padding: 2px 8px; font: inherit; font-size: 12.5px; cursor: pointer;
72 color: var(--fg); background: var(--muted); border: 1px solid var(--border);
73 border-radius: var(--radius-sm);
74 }
75 .pick-chip:hover { border-color: var(--border-strong); }
76 .pick-bar .input { flex: 1; min-width: 160px; }
77 .pick-list {
78 max-height: 168px; overflow-y: auto; overscroll-behavior: contain;
79 border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 4px 8px;
80 }
81 .pick-list .check { display: flex; padding: 3px 0; }
82 .pick-list [hidden] { display: none; }
83 .pick-count { margin: 6px 0 0; }
84 `;
85
86 /** Progressive enhancement for `pickList`; sent as part of `shellJs`. */
87 export const pickJs = /* js */ `
88 (function () {
89 var D = document;
90 function all(l) { return Array.prototype.slice.call(l); }
91 all(D.querySelectorAll('[data-pick]')).forEach(function (root) {
92 var bar = root.querySelector('[data-pick-bar]');
93 var chips = root.querySelector('[data-pick-chips]');
94 var filter = root.querySelector('[data-pick-filter]');
95 var count = root.querySelector('[data-pick-count]');
96 var live = root.querySelector('[data-pick-live]');
97 var last = -1;
98 var noun = root.getAttribute('data-pick');
99 var items = all(root.querySelectorAll('[data-pick-item]'));
100 bar.hidden = false;
101 function box(it) { return it.querySelector('input'); }
102 function text(it) { return it.textContent.trim(); }
103 function visible() { return items.filter(function (it) { return !it.hidden; }); }
104 function render() {
105 var on = items.filter(function (it) { return box(it).checked; });
106 var said = on.length + ' of ' + items.length + ' ' + noun + ' picked';
107 if (on.length !== last) { if (last >= 0) live.textContent = said; last = on.length; }
108 count.textContent = said +
109 (filter.value.trim() === '' ? '' : '; ' + visible().length + ' shown');
110 chips.textContent = '';
111 on.forEach(function (it) {
112 var b = D.createElement('button');
113 b.type = 'button';
114 b.className = 'pick-chip';
115 b.textContent = text(it) + ' \\u00d7';
116 b.setAttribute('aria-label', 'Remove ' + text(it));
117 b.addEventListener('click', function () {
118 box(it).checked = false;
119 render();
120 filter.focus();
121 });
122 chips.appendChild(b);
123 });
124 }
125 function apply() {
126 var q = filter.value.trim().toLowerCase();
127 items.forEach(function (it) { it.hidden = q !== '' && text(it).toLowerCase().indexOf(q) < 0; });
128 render();
129 }
130 filter.addEventListener('input', apply);
131 filter.addEventListener('keydown', function (e) {
132 var v = visible();
133 if (e.key === 'Enter') {
134 e.preventDefault();
135 if (v[0]) { box(v[0]).checked = !box(v[0]).checked; render(); }
136 } else if (e.key === 'ArrowDown' && v[0]) {
137 e.preventDefault(); box(v[0]).focus();
138 } else if (e.key === 'Escape' && filter.value) {
139 e.preventDefault(); e.stopPropagation(); filter.value = ''; apply();
140 }
141 });
142 root.addEventListener('keydown', function (e) {
143 var t = e.target;
144 if (!t || t.type !== 'checkbox' || (e.key !== 'ArrowDown' && e.key !== 'ArrowUp')) return;
145 var v = visible().map(box), i = v.indexOf(t);
146 var next = v[i + (e.key === 'ArrowDown' ? 1 : -1)];
147 e.preventDefault();
148 if (next) next.focus(); else if (e.key === 'ArrowUp') filter.focus();
149 });
150 root.addEventListener('change', render);
151 render();
152 });
153 })();
154 `;
src/web/style.ts +2
@@ -27,6 +27,7 @@ import { boardCss } from "./pages/board_style.ts";
2727 import { base, layers, reset, utilities } from "./style/base.ts";
2828 import { components } from "./style/components.ts";
2929 import { content } from "./style/content.ts";
30 import { pickCss } from "./pick.ts";
3031 import { studioCss } from "./studio/style.ts";
3132
3233 export const css = [
@@ -40,4 +41,5 @@ export const css = [
4041 content,
4142 utilities,
4243 swatchCss,
44 pickCss,
4345 ].join("\n");
test/pick.test.ts added +108
@@ -0,0 +1,108 @@
1 /** The shared pick list (#8929), with 150 repositories on /forges/new. */
2
3 import { expect, test } from "bun:test";
4 import { mkdtemp, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { createRepo } from "../src/actions/repos.ts";
8 import { createApp, type App } from "../src/app.ts";
9 import { createUser } from "../src/core/users.ts";
10 import { serve, type Servers } from "../src/http/server.ts";
11 import { shellJs } from "../src/web/client.ts";
12 import { pickList, pickJs } from "../src/web/pick.ts";
13 import { css } from "../src/web/style.ts";
14 import { quietLogs } from "./helpers/globals.ts";
15
16 quietLogs();
17
18 test("pickList: plain checkboxes, a count, and escaped labels", () => {
19 const items = Array.from({ length: 150 }, (_, i) => ({ value: `o/r${i}`, label: `o/r${i}` }));
20 const out = String(pickList({ legend: "Apps", name: "apps", items, chosen: ["o/r3", "o/r9"], noun: "repositories" }));
21 expect(out.match(/type="checkbox"/g)?.length).toBe(150);
22 expect(out.match(/ checked/g)?.length).toBe(2);
23 expect(out).toContain("2 of 150 repositories picked");
24 expect(out).toContain("data-pick-bar");
25 const evil = String(pickList({ legend: "L", name: "n", items: [{ value: "<b>", label: "<script>x</script>" }], chosen: [] }));

This only checks the script parses. Add a test of filtering, chip removal and the count that fails without the behaviour.

26 expect(evil).not.toContain("<script>");
27 });
28
29 test("the script and styles ship with the shell", () => {
30 expect(shellJs).toContain(pickJs);
31 expect(css).toContain(".pick-chip");
32 expect(() => new Function(pickJs)).not.toThrow();
33 });
34
35 test("/forges/new with 150 repositories shows one bounded list", async () => {
36 const dir = await mkdtemp(join(tmpdir(), "forge-pick-"));
37 const app: App = createApp({ dataDir: dir, url: "https://forge.test", port: 0 });
38 let servers: Servers | undefined;
39 try {
40 const u = await createUser(app.db, {
41 origin: "seed", trusted: true, username: "alice", email: "[email protected]",
42 password: "a-long-pick-passphrase", isAdmin: true,
43 });
44 if (!u.ok) throw new Error("user");
45 for (let i = 0; i < 150; i++) {
46 const r = await createRepo(app, u.value, { name: `app${i}` });
47 if (!r.ok) throw new Error("repo");
48 }
49 servers = await serve(app);
50 const si = await fetch(`${servers.url}/signin`, {
51 method: "POST",
52 body: new URLSearchParams({ username: "alice", password: "a-long-pick-passphrase" }),
53 redirect: "manual",
54 });
55 const cookie = (si.headers.get("set-cookie") ?? "").split(";")[0]!;
56 const text = await (await fetch(`${servers.url}/forges/new`, { headers: { cookie } })).text();
57 expect(text.match(/name="apps"/g)?.length).toBe(150);
58 expect(text).toContain("0 of 150 repositories picked");
59 expect(text).toContain("data-pick-filter");
60 } finally {
61 servers?.stop();
62 app.close();
63 await rm(dir, { recursive: true, force: true });
64 }
65 }, 60_000);
66
67 async function mount(n: number, chosen: string[] = []) {
68 const { Window } = await import("happy-dom");
69 const doc = new Window().document as any;
70 const items = Array.from({ length: n }, (_, i) => ({ value: `o/r${i}`, label: `o/r${i}` }));
71 doc.body.innerHTML = String(pickList({ legend: "Apps", name: "apps", noun: "repositories", items, chosen, error: "bad" }));
72 new Function("document", pickJs)(doc);
73 const shown = () => [...doc.querySelectorAll("[data-pick-item]")].filter((e: any) => !e.hidden).length;
74 const type = (v: string) => {
75 const f = doc.querySelector("[data-pick-filter]");
76 f.value = v;
77 f.dispatchEvent(new (doc.defaultView.Event)("input", { bubbles: true }));
78 };
79 return { doc, shown, type };
80 }
81
82 test("pickJs: filter narrows, ticking makes chips and the count, a chip unticks", async () => {
83 const { doc, shown, type } = await mount(150, ["o/r1"]);
84 expect(doc.querySelector("[data-pick-bar]").hidden).toBe(false);
85 expect(doc.querySelectorAll(".pick-chip").length).toBe(1);
86 type("r14");
87 expect(shown()).toBe(11); // r14, r140..r149
88 expect(doc.querySelector("[data-pick-count]").textContent).toContain("11 shown");
89 expect(doc.querySelector("[data-pick-live]").textContent).toBe("");
90 const box = [...doc.querySelectorAll("[data-pick-item] input")].find((i: any) => i.value === "o/r145") as any;
91 box.checked = true;
92 box.dispatchEvent(new (doc.defaultView.Event)("change", { bubbles: true }));
93 expect(doc.querySelectorAll(".pick-chip").length).toBe(2);
94 expect(doc.querySelector("[data-pick-live]").textContent).toBe("2 of 150 repositories picked");
95 doc.querySelector(".pick-chip").click(); // o/r1
96 expect(doc.querySelectorAll(".pick-chip").length).toBe(1);
97 expect(doc.querySelector("[data-pick-live]").textContent).toBe("1 of 150 repositories picked");
98 type("");
99 expect(shown()).toBe(150);
100 expect(doc.querySelector("[data-pick-count]").textContent).toBe("1 of 150 repositories picked");
101 });
102
103 test("pickList: the error is described to the fieldset", async () => {
104 const { doc } = await mount(3);
105 expect(doc.querySelector("fieldset").getAttribute("aria-describedby")).toBe("f-apps-err");
106 expect(doc.getElementById("f-apps-err").textContent).toBe("bad");
107 expect(doc.querySelector("[role=group]")).toBeNull();
108 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs