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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8929-new-forge-picks-its-repositories-from-a → main 3 commits fork 4 threads · 4 unresolved
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 @@ | ||
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | 13 | import { actNowJs } from "./act_now.ts"; |
| 14 | import { pickJs } from "./pick.ts"; | |
| 14 | 15 | import { imageViewJs } from "./render/image.ts"; |
| 15 | 16 | import { STAGE_BADGE_JS } from "./stage-row.ts"; |
| 16 | 17 | import { paletteHelpersJs } from "./palette.ts"; |
| @@ -2043,7 +2044,7 @@ const shellCore = /* js */ ` | ||
| 2043 | 2044 | `; |
| 2044 | 2045 | |
| 2045 | 2046 | /** 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}`; | |
| 2047 | 2048 | |
| 2048 | 2049 | /** |
| 2049 | 2050 | * 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"; | ||
| 30 | 30 | import { field, readForm, type Form } from "../form.ts"; |
| 31 | 31 | import { html, type Html } from "../html.ts"; |
| 32 | 32 | import { page } from "../layout.ts"; |
| 33 | import { pickList } from "../pick.ts"; | |
| 33 | 34 | import { btn, card, field as formField, row, selectField } from "../ui.ts"; |
| 34 | 35 | |
| 35 | 36 | interface Values { |
| @@ -80,29 +81,6 @@ function errorsOf(door: Door): Errors { | ||
| 80 | 81 | return { form: message }; |
| 81 | 82 | } |
| 82 | 83 | |
| 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 | ||
| 106 | 84 | /** What the form reads before it draws: room, then (when there is room) the repositories and the crew. */ |
| 107 | 85 | interface Reads { |
| 108 | 86 | readonly full: string | undefined; |
| @@ -212,20 +190,17 @@ function formView( | ||
| 212 | 190 | hint: "In your words: the crew's first direction. Optional.", |
| 213 | 191 | ...(errors["direction"] ? { error: errors["direction"] } : {}), |
| 214 | 192 | })} |
| 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 | })} | |
| 229 | 204 | ${formField({ |
| 230 | 205 | label: "Or a fresh app (optional)", |
| 231 | 206 | name: "fresh", |
| @@ -263,22 +238,14 @@ function formView( | ||
| 263 | 238 | </fieldset> |
| 264 | 239 | ${crew.length === 0 |
| 265 | 240 | ? 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 | })} | |
| 282 | 249 | ${anon |
| 283 | 250 | ? null |
| 284 | 251 | : 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}" | |
| 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"; | ||
| 27 | 27 | import { base, layers, reset, utilities } from "./style/base.ts"; |
| 28 | 28 | import { components } from "./style/components.ts"; |
| 29 | 29 | import { content } from "./style/content.ts"; |
| 30 | import { pickCss } from "./pick.ts"; | |
| 30 | 31 | import { studioCss } from "./studio/style.ts"; |
| 31 | 32 | |
| 32 | 33 | export const css = [ |
| @@ -40,4 +41,5 @@ export const css = [ | ||
| 40 | 41 | content, |
| 41 | 42 | utilities, |
| 42 | 43 | swatchCss, |
| 44 | pickCss, | |
| 43 | 45 | ].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 | }); |
new above
offline
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.