@ashley-shrok/viewmodel-shell 5.1.2 โ†’ 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/browser.js CHANGED
@@ -78,6 +78,94 @@ export class BrowserAdapter {
78
78
  // second render still applies once the import resolves. Instances are
79
79
  // mark-swept (destroy()'d + deleted) in render() when the new tree drops them.
80
80
  chartInstances = new Map();
81
+ // Phase 21 (LOOK-05) โ€” the lookup's aria-live status regions, keyed by
82
+ // FieldNode.name. DELIBERATELY PERSISTENT across renders (NOT reset like the
83
+ // per-render fields below), for exactly the reason chartInstances is: these
84
+ // NODES must SURVIVE render()'s innerHTML wipe. They are re-appended each
85
+ // render, never rebuilt.
86
+ //
87
+ // ๐Ÿšจ IF YOU RESET THIS MAP, EVERY ANNOUNCEMENT SILENTLY STOPS AND NOTHING
88
+ // LOOKS WRONG. A screen reader only announces changes to an element it has
89
+ // ALREADY REGISTERED for. A region re-created each render is registered,
90
+ // wiped, and re-created โ€” so it is never heard from again, while the DOM
91
+ // still contains a perfect-looking role="status" div and every structural
92
+ // test still passes. This is the one a11y failure that is INVISIBLE rather
93
+ // than merely unverified, and on Safari/VoiceOver the live region is the ONLY
94
+ // thing that works at all (the ARIA plumbing conveys nothing there โ€” verified
95
+ // against the APG's own reference example). The comment on chartInstances
96
+ // below exists for the same reason: the next person's instinct is to "tidy"
97
+ // these into the per-render group.
98
+ //
99
+ // Two regions, not one (ยง7 item 12): writing IDENTICAL text into one live
100
+ // region twice is not a change and is NOT re-announced โ€” re-highlight the
101
+ // same option, hear silence. `next` alternates them, which is why the value
102
+ // is an object rather than a bare element (same shape reason as the chart's
103
+ // {canvas, chart, latest} triple).
104
+ // `hintShown` tracks ยง7 item 13's assistive hint, which is dropped after the
105
+ // first input so it is not a per-keystroke tax.
106
+ //
107
+ // ๐Ÿšจ NO TIMER HERE, AND THAT IS THE POINT (21-11). This map used to carry a
108
+ // ~1400ms status debounce (GOV.UK's `statusDebounceMillis`), which existed for
109
+ // ONE reason: search fired on a ~300ms type-as-you-go cadence, so the region
110
+ // faced a per-keystroke firehose and had to wait for the user to pause or the
111
+ // typing echo would eat the announcement. `searchAction` now fires on ENTER โ€”
112
+ // ONE Enter, ONE announcement โ€” so the firehose is gone and with it every
113
+ // reason to make an AT user wait 1.4s to hear their own answer. If you are
114
+ // here to re-add a debounce, first re-add the cadence that justified it; you
115
+ // will find you cannot (D4, reversed).
116
+ //
117
+ // Keyed by n.name โ€” a deliberate, documented DIVERGENCE from the chart's
118
+ // title+ordinal scheme (and NO ordinal counter: do not cargo-cult
119
+ // chartKeyCounter). FieldNode.name is already unique-ish per field, stable
120
+ // across renders, and is already the id basis for the control itself
121
+ // (`inp.id = vms-${n.name}`).
122
+ //
123
+ // Mark-swept in render() against lookupKeysSeen, exactly as chartInstances is:
124
+ // a lookup dropped from the tree must drop its regions rather than leak them
125
+ // across a long session.
126
+ liveRegions = new Map();
127
+ // Phase 21 (21-11) โ€” document-level "click outside closes the popup" handlers,
128
+ // registered by field()'s lookup arm. PER-RENDER, exactly like fitsObservers
129
+ // above: every entry is removed from `document` at the TOP of render(), before
130
+ // the innerHTML wipe, so a handler closing over a destroyed popup can never
131
+ // fire. The listener MUST live on `document` (an outside click is by
132
+ // definition not on our own subtree), and `document` outlives the wipe โ€” so
133
+ // without this reset the adapter would leak one dead listener per lookup per
134
+ // render for the life of the page.
135
+ lookupOutsideHandlers = [];
136
+ // Phase 21 (LOOK-02) โ€” popup-open snapshot for the lookup combobox, keyed by
137
+ // FieldNode.name. Captured by render() BEFORE this.container.innerHTML = ""
138
+ // by walking [data-vms-lookup-key] popups; consumed IN field()'s lookup arm
139
+ // (not by a post-render DOM pass, unlike the [data-section-key] details
140
+ // restore below) because the arm's `open` CLOSURE VARIABLE must agree with
141
+ // the DOM: setting popup.hidden from outside would leave the closure thinking
142
+ // it is closed, and Escape would then take its popup-already-closed branch
143
+ // and CLEAR the user's selection. Cleared at the bottom of every render().
144
+ //
145
+ // ๐Ÿšจ OPEN is preserved. ACTIVE IS NOT โ€” see the arm.
146
+ //
147
+ // `querying` rides along: it marks "this render is the answer to a search the
148
+ // user just asked for" โ€” set when Enter dispatches `searchAction` (21-11; it
149
+ // used to be set by TYPING, when typing is what searched). It is what lets
150
+ // results arriving from the server open the popup (a first search has no
151
+ // prior options, so the input listener's own open cannot fire) and what gates
152
+ // the live region's result announcements, so a lookup that merely re-renders
153
+ // for unrelated reasons never narrates its candidate count at an AT user out
154
+ // of nowhere.
155
+ // Phase 21 (LOOK-03) โ€” `roving`/`armed` join this snapshot rather than growing
156
+ // a fourth mechanism, exactly as 21-04's executor asked. Both are chip state,
157
+ // and both are DOM-local: the roving tabindex POSITION and the "last chip is
158
+ // highlighted, press again to remove" arm die in render()'s innerHTML wipe.
159
+ // A search re-render lands mid-interaction, so neither can be left to chance.
160
+ //
161
+ // ๐Ÿšจ `armed` is a VALUE, not a boolean, and that is load-bearing. Restoring an
162
+ // armed FLAG by position would confirm the user's second Backspace against
163
+ // whatever the server happens to have put last โ€” a DIFFERENT item than the one
164
+ // announced. That is precisely the silent, unannounced deletion of the wrong
165
+ // record that ยง7 item 31's two-step exists to prevent, reintroduced by the
166
+ // preservation pass meant to make it work. Keyed by value, a changed last chip
167
+ // simply fails to match and the arm is dropped (fail-safe: the user re-arms).
168
+ lookupOpenSnapshot = new Map();
81
169
  // Per-render disambiguator for chart keys (title-derived or anonymous). Reset
82
170
  // at the TOP of every render() (like sectionKeyCounter) so snapshot keys and
83
171
  // rebuild keys compute identically across a render pass.
@@ -86,6 +174,11 @@ export class BrowserAdapter {
86
174
  // every render(); render() mark-sweeps any chartInstances key NOT in this set
87
175
  // (a ChartNode removed from the new tree โ†’ its Chart instance is destroyed).
88
176
  chartKeysSeen = new Set();
177
+ // Per-render set of every lookup key rendered this pass. Reset at the TOP of
178
+ // every render(); render() mark-sweeps the persistent liveRegions map against
179
+ // it, so a lookup removed from the tree drops its live regions rather than
180
+ // leaking them across a long session. Same idiom as chartKeysSeen above.
181
+ lookupKeysSeen = new Set();
89
182
  constructor(container) {
90
183
  this.container = container;
91
184
  }
@@ -136,11 +229,53 @@ export class BrowserAdapter {
136
229
  });
137
230
  this.detailsOpenSnapshot = openMap;
138
231
  this.sectionKeyCounter = new Map();
232
+ // Phase 21 (LOOK-02) โ€” snapshot lookup popup-open state by field name.
233
+ // Same capture-before-the-wipe contract as the details snapshot above, but
234
+ // RESTORED INSIDE field()'s lookup arm rather than by a post-render walk
235
+ // (see the field declaration for why: the closure and the DOM must not be
236
+ // allowed to disagree).
237
+ //
238
+ // Why this pass exists at all: the popup is DOM-local state, and the ~300ms
239
+ // debounced search means a re-render lands MID-TYPING on every search. With
240
+ // no preservation the popup the user is typing into snaps shut every time
241
+ // and the control is unusable. This was invisible before the search cadence
242
+ // existed โ€” nothing re-rendered a lookup mid-interaction.
243
+ //
244
+ // Phase 21 (LOOK-03) โ€” the popup element is this lookup's DOM-local state
245
+ // CARRIER, not just its popup: `roving` and `armed` are chip facts parked on
246
+ // the same node so ONE snapshot pass covers every piece of DOM-local lookup
247
+ // state. A fourth mechanism per fact is the drift hazard chartInstances
248
+ // warns about.
249
+ const lookupOpenMap = new Map();
250
+ this.container.querySelectorAll("[data-vms-lookup-key]").forEach(el => {
251
+ const key = el.dataset.vmsLookupKey;
252
+ if (key != null) {
253
+ const roving = Number(el.dataset.vmsLookupRoving);
254
+ const armed = el.dataset.vmsLookupArmed;
255
+ lookupOpenMap.set(key, {
256
+ open: !el.hidden,
257
+ querying: el.dataset.vmsLookupQuerying === "true",
258
+ roving: Number.isFinite(roving) ? roving : 0,
259
+ // "" is the no-arm sentinel: a dataset value is always a string, and a
260
+ // legitimate chip value is never empty (commitCustom trims and rejects
261
+ // empty; a server candidate with an empty id is meaningless).
262
+ armed: armed != null && armed !== "" ? armed : null,
263
+ });
264
+ }
265
+ });
266
+ this.lookupOpenSnapshot = lookupOpenMap;
267
+ this.lookupKeysSeen = new Set();
139
268
  // Phase 10 (FITS-01) โ€” disconnect every ResizeObserver registered by the
140
269
  // prior render's fits() calls before the tree is rebuilt (leak prevention).
141
270
  // Same per-render reset model as the focus/scroll/details snapshots above.
142
271
  this.fitsObservers.forEach(o => o.disconnect());
143
272
  this.fitsObservers = [];
273
+ // Phase 21 (21-11) โ€” drop the prior render's click-outside handlers before
274
+ // the tree is rebuilt, for exactly the fits reason above: they live on
275
+ // `document`, which the innerHTML wipe does not touch, so each would
276
+ // otherwise outlive the popup it closes over and accumulate forever.
277
+ this.lookupOutsideHandlers.forEach(h => document.removeEventListener("mousedown", h));
278
+ this.lookupOutsideHandlers = [];
144
279
  // Phase 12 (CHART-01/03) โ€” reset the per-render chart bookkeeping (NOT
145
280
  // chartInstances, which is deliberately persistent). Same per-render reset
146
281
  // model as sectionKeyCounter: keys must compute identically across the
@@ -160,6 +295,16 @@ export class BrowserAdapter {
160
295
  this.chartInstances.delete(key);
161
296
  }
162
297
  }
298
+ // Phase 21 (LOOK-05) โ€” mark-sweep the live regions against the keys rendered
299
+ // this pass, exactly as the chart sweep above does: a lookup removed from
300
+ // the new tree drops its two region nodes rather than leaking them for the
301
+ // life of the session. Swept POST-rebuild, like the charts and for the same
302
+ // reason โ€” a PERSISTING lookup's regions must survive the innerHTML wipe to
303
+ // be re-appended by field().
304
+ for (const key of this.liveRegions.keys()) {
305
+ if (!this.lookupKeysSeen.has(key))
306
+ this.liveRegions.delete(key);
307
+ }
163
308
  if (focusId) {
164
309
  const el = this.container.querySelector(`#${CSS.escape(focusId)}`);
165
310
  if (el) {
@@ -212,6 +357,7 @@ export class BrowserAdapter {
212
357
  });
213
358
  this.detailsOpenSnapshot.clear();
214
359
  this.sectionKeyCounter.clear();
360
+ this.lookupOpenSnapshot.clear();
215
361
  }
216
362
  navigate(url) {
217
363
  window.location.href = url;
@@ -1053,6 +1199,1311 @@ export class BrowserAdapter {
1053
1199
  });
1054
1200
  wrapper.appendChild(sel);
1055
1201
  }
1202
+ else if (n.inputType === "lookup" || n.inputType === "lookup-multiple") {
1203
+ // Phase 21 (LOOK-01) โ€” the lookup / reference picker: an editable
1204
+ // combobox over a candidate set the SERVER resolves. A `select` says
1205
+ // "here are all the values, pick one"; a lookup says "the values are a
1206
+ // database table โ€” describe which row you mean". Built to the ARIA 1.2
1207
+ // combobox contract (design ยง7 items 1-7; keyboard items 14-22).
1208
+ //
1209
+ // Cardinality is a local bool, exactly as the select arm above does it:
1210
+ // D2 splits the WIRE tokens (lookup / lookup-multiple are separate
1211
+ // inputTypes because the chips layer is a second widget grafted on, not
1212
+ // an orthogonal flag), but the renderer may still share one code path.
1213
+ const isMulti = n.inputType === "lookup-multiple";
1214
+ wrapper.classList.add("vms-field--lookup");
1215
+ if (isMulti)
1216
+ wrapper.classList.add("vms-field--lookup-multiple");
1217
+ // Phase 21 (LOOK-02) โ€” mark this key rendered so render()'s mark-sweep
1218
+ // keeps this lookup's pending debounce timer and live regions alive.
1219
+ this.lookupKeysSeen.add(n.name);
1220
+ // [vms:lookup-no-searchbind] โ€” a searchAction with no searchBind
1221
+ // dispatches the query but the server can never READ what was typed: a
1222
+ // silently dead typeahead that renders perfectly and returns nothing
1223
+ // forever. Structurally invisible, so warn.
1224
+ if (n.searchAction && n.searchBind == null) {
1225
+ this.warnOnce("lookup-no-searchbind:" + n.name, "[vms:lookup-no-searchbind] lookup FieldNode '" + n.name +
1226
+ "' has a searchAction but no searchBind โ€” the query is dispatched but never round-trips, " +
1227
+ "so the server cannot see what was typed and the typeahead returns nothing forever; add searchBind:\"<path>\".");
1228
+ }
1229
+ // [vms:lookup-ambiguous-enter] โ€” D15. `allowCustom` + `searchAction` on
1230
+ // ONE field overloads Enter: "urgent" + Enter is BOTH "invent the tag"
1231
+ // and "search for it", and both are legitimate readings of the keystroke.
1232
+ // There is no precedence that serves both (invent-first starves search
1233
+ // the moment anything is typed; search-first starves invention forever) โ€”
1234
+ // and that there is no good ordering is the tell that the SHAPE is wrong,
1235
+ // so v1 does not guess. A combination that silently half-works is exactly
1236
+ // the quiet failure principle 8 forbids โ‡’ loud, but NOT fatal (the
1237
+ // [vms:orphan-file] precedent): we degrade to the searchAction reading
1238
+ // below and render a coherent directory picker.
1239
+ if (n.allowCustom === true && n.searchAction) {
1240
+ this.warnOnce("lookup-ambiguous-enter:" + n.name, "[vms:lookup-ambiguous-enter] lookup FieldNode '" + n.name +
1241
+ "' declares BOTH allowCustom and searchAction โ€” one Enter cannot both invent a value and " +
1242
+ "run a search, so this combination is NOT supported in v1 and allowCustom is being IGNORED " +
1243
+ "(Enter searches). Declare exactly one: searchAction WITHOUT allowCustom (a directory/reference " +
1244
+ "picker โ€” Enter searches, arrow+Enter accepts a candidate), or allowCustom WITHOUT searchAction " +
1245
+ "(a free-form tags field โ€” Enter invents). Suggestions on a tags field are deferred.");
1246
+ }
1247
+ const inp = document.createElement("input");
1248
+ inp.type = "text";
1249
+ // MANDATORY: decorateField() finds the control via
1250
+ // wrapper.querySelector(".vms-field__input"). Without this class every
1251
+ // decoration (disabled/readonly/error/help/aria-describedby/aria-invalid)
1252
+ // silently no-ops โ€” it fails quietly and structurally passes.
1253
+ inp.className = "vms-field__input";
1254
+ // Stable id so render()'s focus+caret restore can re-find this input
1255
+ // after a re-render. The table filter needs this because an unlucky
1256
+ // silent poll can land mid-keystroke; a lookup is WORSE โ€” it dispatches
1257
+ // on its OWN keystrokes, so a re-render lands mid-typing on EVERY search
1258
+ // (~300ms), not just on an unlucky tick. Without the id the value
1259
+ // survives (it's bound state) but focus and caret are destroyed on every
1260
+ // debounce fire and the control is unusable.
1261
+ inp.id = `vms-${n.name}`;
1262
+ inp.name = n.name;
1263
+ if (n.placeholder)
1264
+ inp.placeholder = n.placeholder;
1265
+ if (n.required)
1266
+ inp.required = true;
1267
+ // The browser's own autofill dropdown would fight the listbox popup.
1268
+ inp.autocomplete = "off";
1269
+ // โ”€โ”€ THE DISPLAY PATH (D1) โ€” the decision this primitive exists to protect
1270
+ //
1271
+ // ๐Ÿšจ The label is VIEW, not STATE. It is read from `n.selected` and ONLY
1272
+ // from `n.selected`. It is NEVER resolved out of `n.candidates`, which
1273
+ // feeds the popup listbox and NOTHING ELSE. In BOTH modes it is rendered
1274
+ // as a CHIP (D2a) โ€” it never enters `inp.value`.
1275
+ //
1276
+ // The select arm ~40 lines above resolves what it displays out of its
1277
+ // `options` (`o.textContent = opt.label`). Mirroring that instinct here
1278
+ // is THE TRAP: with an id-valued field, "filter the candidate list" and
1279
+ // "forget what's selected" are THE SAME OPERATION โ€” no cache separates
1280
+ // them, and one cannot be seeded for a value the client has never seen.
1281
+ // So a lookup that resolved its label from `candidates` renders a raw
1282
+ // database id on the one case that matters most: a form that loads with a
1283
+ // value already set, where nobody has searched and `candidates` is empty.
1284
+ // Ant Design ships exactly this failure silently (`label: ... ?? item.value`);
1285
+ // Zag chased it across four changelog entries and two years; SAP names it
1286
+ // as its own degenerate case. If you are here to "simplify" this by
1287
+ // reading `candidates`, the tests at the top of test/lookup-render.test.ts
1288
+ // are telling you not to.
1289
+ //
1290
+ // Per D5, an entry whose `label` is omitted displays its `value` โ€” a
1291
+ // label that merely repeats the id carries no information, so it is
1292
+ // absent (that is exactly the free-form-tag case).
1293
+ const selectedItems = n.selected ?? [];
1294
+ // โ•โ• ๐Ÿšจ `inp.value` IS THE QUERY. UNCONDITIONALLY. IN BOTH MODES. (D2a) โ•โ•
1295
+ //
1296
+ // There is NO precedence rule here, NO `query != null` vs truthiness
1297
+ // split, and NO flag tracking "is the box showing a label or a query?".
1298
+ // Their ABSENCE is the point of 21-14, so read why before adding one back:
1299
+ //
1300
+ // The headline bug (`ownerQuery: ""` beating the label โ‡’ the placeholder
1301
+ // rendering where a reference was already set) existed because THE INPUT
1302
+ // ANSWERED TWO QUESTIONS AT ONCE โ€” *is this the selection or the query?* โ€”
1303
+ // arbitrated by a fragile test that ALSO had to not break OPEN-6's
1304
+ // empty-query dispatch. Two correct decisions colliding in one field:
1305
+ //
1306
+ // โ€ข OPEN-6 โ€” an EMPTY QUERY IS A LEGITIMATE QUERY (it is how an app
1307
+ // serves a most-recently-used list), so `""` must still reach the
1308
+ // server โ‡’ that rule is about DISPATCH.
1309
+ // โ€ข D1 โ€” the label comes from `selected` โ‡’ that rule is about DISPLAY.
1310
+ //
1311
+ // 21-11 fixed it by SPLITTING the two tests (display keyed on non-empty,
1312
+ // dispatch on non-null). Correct, and still a patch: the arbitration
1313
+ // remained, so the next reader could still get it wrong.
1314
+ //
1315
+ // ๐Ÿšจ D2a DISSOLVES THE ROOT CAUSE INSTEAD. The operator hit the real
1316
+ // problem at the live page โ€” with the input BEING the selection (the
1317
+ // 21-13 SLDS pill), THERE IS NOWHERE TO CLICK TO TYPE; clicking in just
1318
+ // appends to "Sally Omer":
1319
+ //
1320
+ // "maybe we should just make the pill separate from the input like the
1321
+ // tag setup, even if it is a little awkward. so you always have a place
1322
+ // to type. but instead of adding a pill like with tags, it replaces."
1323
+ //
1324
+ // So the selection MOVED OUT of the input and became a chip โ€” the same
1325
+ // chip multi already used. With the selection in a chip, `inp.value` is
1326
+ // unconditionally the query: THERE IS NO QUESTION LEFT TO ARBITRATE, so
1327
+ // there is no precedence rule left to get wrong. The class of bug is gone,
1328
+ // not fixed.
1329
+ //
1330
+ // โ‡’ IF YOU ARE ABOUT TO ADD A BRANCH HERE THAT DECIDES WHAT THE INPUT
1331
+ // SHOWS, STOP โ€” that branch is the bug, and re-adding it re-opens the
1332
+ // placeholder-instead-of-label failure the operator saw on the tailnet.
1333
+ //
1334
+ // (OPEN-6 is untouched and lives where it always belonged: the DISPATCH
1335
+ // question, answered in the Enter handler / search(). An empty query still
1336
+ // dispatches โ€” it just no longer has a display rule to fight with.)
1337
+ const query = n.searchBind != null ? this.readBind(n.searchBind) : undefined;
1338
+ inp.value = query != null ? String(query) : "";
1339
+ // ยง7 items 1-3 โ€” role="combobox" on the INPUT ITSELF (ARIA 1.2; the 1.0
1340
+ // wrapper + aria-owns pattern is deprecated). aria-expanded is ALWAYS
1341
+ // present, even when closed. NO aria-haspopup: `listbox` is implicit for
1342
+ // role="combobox", so setting it is noise.
1343
+ const popupId = `vms-${n.name}-popup`;
1344
+ inp.setAttribute("role", "combobox");
1345
+ inp.setAttribute("aria-expanded", "false");
1346
+ inp.setAttribute("aria-controls", popupId);
1347
+ inp.setAttribute("aria-autocomplete", "list");
1348
+ // ยง7 items 4-6 โ€” the popup. Rendered ALWAYS and merely hidden when
1349
+ // closed, because aria-controls must stay valid while the popup is
1350
+ // hidden. Excluded from the tab sequence (no tabindex anywhere in here):
1351
+ // only the input is tabbable, and DOM focus NEVER leaves it โ€” the active
1352
+ // option is conveyed by aria-activedescendant rather than a roving
1353
+ // tabindex, because moving real focus out of a text input breaks typing.
1354
+ const popup = document.createElement("div");
1355
+ popup.className = "vms-field__popup";
1356
+ popup.id = popupId;
1357
+ popup.setAttribute("role", "listbox");
1358
+ popup.setAttribute("aria-label", n.label ?? n.name);
1359
+ // ยง7 item 30 โ€” the multi listbox declares itself multi-selectable. Set
1360
+ // because it is correct and cheap, but treated as NON-COMMUNICATING (ยง7
1361
+ // item 32): like aria-selected it is barely announced, so the live region
1362
+ // is what actually tells an AT user what is happening. Absent (not
1363
+ // "false") on single-select โ€” an unset optional is simply absent.
1364
+ if (isMulti)
1365
+ popup.setAttribute("aria-multiselectable", "true");
1366
+ popup.hidden = true;
1367
+ // The key render()'s pre-wipe popup-open snapshot walks.
1368
+ popup.dataset.vmsLookupKey = n.name;
1369
+ const optionEls = [];
1370
+ // ๐Ÿšจ D12 โ€” CANDIDATES ARE PRESENTED AS GIVEN. This forEach SORTS NOTHING,
1371
+ // DEDUPES NOTHING, and TRUNCATES NOTHING, and it must stay that way.
1372
+ //
1373
+ // The reason is written here because the next reader's instinct is to
1374
+ // "tidy" an unsorted list: candidate ORDER IS MEANINGFUL APP DATA.
1375
+ // Relevance ordering is the SERVER's judgment, never the widget's โ€” that
1376
+ // is universal in mature pickers (Salesforce's picker `searchType`
1377
+ // defaults to `Recent`; Dynamics shows 5 most-recently-used rows plus 5
1378
+ // favourites, explicitly NOT filtered by the search term). A real
1379
+ // consumer ranks candidates by recency-weighted mention frequency in
1380
+ // their own handler, and a renderer that helpfully alphabetized for
1381
+ // tidiness would SILENTLY DESTROY that ranking with no way for the app to
1382
+ // stop it. If an app wants a cap, D7 applies: it says so visibly in the
1383
+ // tree (a TextNode โ€” "Refine your filter โ€” N matches, max is X"), because
1384
+ // nothing truncates silently.
1385
+ (n.candidates ?? []).forEach((c, i) => {
1386
+ const opt = document.createElement("div");
1387
+ opt.className = "vms-field__option";
1388
+ // Index-keyed, NOT value-keyed: candidates are deliberately not deduped
1389
+ // (D12), so two entries may legitimately share a value โ€” an id derived
1390
+ // from the value would collide and break aria-activedescendant.
1391
+ opt.id = `${popupId}-opt-${i}`;
1392
+ // ยง7 item 7 โ€” a role="option" ELEMENT, never a <button>/<a>: an
1393
+ // interactive descendant destroys the listbox accessibility tree.
1394
+ opt.setAttribute("role", "option");
1395
+ opt.setAttribute("aria-selected", "false");
1396
+ // textContent, never innerHTML โ€” a server-supplied label is text, not
1397
+ // markup (the house idiom throughout this file).
1398
+ opt.textContent = c.label ?? c.value;
1399
+ opt.dataset.vmsValue = c.value;
1400
+ if (c.type != null)
1401
+ opt.dataset.vmsType = c.type;
1402
+ popup.appendChild(opt);
1403
+ optionEls.push(opt);
1404
+ });
1405
+ // Popup/highlight state is DOM-local and starts fresh on every render.
1406
+ // ๐Ÿšจ That is deliberate and is half of ยง7 item 14: we NEVER auto-highlight
1407
+ // the first option when results arrive. React Aria's NVDA finding is why
1408
+ // โ€” with an option auto-focused, "character deletions and text cursor
1409
+ // movement in the ComboBox input weren't being announced at all", and it
1410
+ // bites async HARDEST, because the natural implementation highlights
1411
+ // option 1 the moment results land, mid-typing. Starting at -1 makes that
1412
+ // structural rather than a rule someone has to remember.
1413
+ let open = false;
1414
+ let activeIndex = -1;
1415
+ // Phase 21 (LOOK-05) โ€” the two aria-live regions. Fetched from the
1416
+ // PERSISTENT map and RE-APPENDED (never rebuilt): these exact node objects
1417
+ // predate this render and must outlive it, or the assistive tech's
1418
+ // registration dies with them and every announcement stops silently. This
1419
+ // is the chartInstances idiom; see lookupLiveRegions().
1420
+ const live = this.lookupLiveRegions(n.name);
1421
+ const announce = (message) => this.announceLookup(n.name, message);
1422
+ // ๐Ÿšจ ยง7 item 27 โ€” ANNOUNCE ADD AND REMOVE, AND ON MULTI ALWAYS WITH THE
1423
+ // RUNNING COUNT. GOV.UK FAILED REVIEW FOR EXACTLY THIS OMISSION ("does not
1424
+ // announce the selections effectively"): without the count an AT user
1425
+ // cannot know the SIZE of the set they are building without abandoning the
1426
+ // input to audit the chips one by one. If you are here to shorten the multi
1427
+ // strings, that is what you are deleting.
1428
+ //
1429
+ // ๐Ÿšจ SINGLE DROPS THE COUNT, AND THAT IS NOT A WEAKENING OF ITEM 27 (D2a).
1430
+ // Item 27's count exists to convey THE SIZE OF AN ACCUMULATING SET. Single
1431
+ // has no set: picking REPLACES, so the count is always exactly 1 and
1432
+ // carries zero information โ€” while "Sally Omer selected. 1 items selected."
1433
+ // is both ungrammatical and actively misleading, because it implies an
1434
+ // additive control the user does not have (the D2a "watch for": a chip on a
1435
+ // single-select could read as "you can add more"). The remove string is the
1436
+ // 21-13 clear โœ•'s wording, kept verbatim: it says what actually happened.
1437
+ const announceAdd = (label, count) => announce(isMulti ? `${label} selected. ${count} items selected.` : `${label} selected.`);
1438
+ const announceRemove = (label, count) => announce(isMulti ? `${label} removed. ${count} items selected.` : `${label} removed. Selection cleared.`);
1439
+ // ยง7 item 13 โ€” the assistive hint, wired via aria-describedby and REMOVED
1440
+ // after the first input so it is not a per-keystroke tax. `hintShown`
1441
+ // lives on the persistent entry because "the user has typed here before"
1442
+ // must survive the re-render their own typing causes.
1443
+ //
1444
+ // Set on the input directly rather than passed to decorateField: that
1445
+ // method seeds its own describedBy list from this attribute (see there),
1446
+ // so a lookup carrying `help` and/or `error` keeps all of them.
1447
+ let hintEl;
1448
+ if (!live.hintShown) {
1449
+ hintEl = document.createElement("div");
1450
+ hintEl.id = `vms-${n.name}-hint`;
1451
+ hintEl.className = "vms-field__live";
1452
+ hintEl.textContent =
1453
+ "When results are available use up and down arrows to review and enter to select.";
1454
+ inp.setAttribute("aria-describedby", hintEl.id);
1455
+ }
1456
+ // "The user is mid-search" โ€” survives the re-render the search causes, via
1457
+ // the same pre-wipe snapshot that preserves popup-open.
1458
+ const snapshot = this.lookupOpenSnapshot.get(n.name);
1459
+ let querying = snapshot?.querying === true;
1460
+ const setQuerying = (v) => {
1461
+ querying = v;
1462
+ popup.dataset.vmsLookupQuerying = String(v);
1463
+ };
1464
+ setQuerying(querying);
1465
+ // โ•โ• THE CHIPS LAYER (LOOK-03; BOTH MODES since D2a) โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
1466
+ //
1467
+ // ๐Ÿšจ D2a โ€” `lookup` AND `lookup-multiple` RENDER SELECTIONS IDENTICALLY:
1468
+ // chip(s) OUTSIDE the input, from the SAME code below. THE ONLY DIFFERENCE
1469
+ // IS ARITY โ€” single REPLACES on pick, multi APPENDS โ€” and it lives in
1470
+ // exactly one place (addValue()). Do NOT fork a parallel single-select
1471
+ // chip: the banked "provide-your-own-X" lesson is that a divergent second
1472
+ // implementation of the same shape is where behavior silently drops, and
1473
+ // everything below (the item-specific remove name, the running-count
1474
+ // announcement, the focus-after-removal rule) is what a fork would lose.
1475
+ //
1476
+ // โš ๏ธ READ design ยง7 items 23-31 BEFORE CHANGING ANYTHING BELOW. There is
1477
+ // NO APG PATTERN FOR CHIPS AT ALL โ€” every rule here is extrapolation from
1478
+ // a PUBLIC FAILURE plus vendor convention, so it is built conservatively
1479
+ // and it is all tested (test/lookup-multiple.test.ts).
1480
+ //
1481
+ // The failure it is extrapolated from: `alphagov/accessible-autocomplete-
1482
+ // multiselect` carries the notice "This project is retired as the
1483
+ // component is not accessible." It failed GOV.UK's OWN review because it
1484
+ // "does not announce the selections effectively or the presence of the
1485
+ // 'Remove' button for screenreaders", and they judged the fixes "will be
1486
+ // challenging" enough to WITHDRAW rather than repair it. The UK government
1487
+ // shipped this control and had to pull it. Items 25 (item-specific remove
1488
+ // names), 27 (announce WITH the running count) and 29 (the focus rule) are
1489
+ // not polish โ€” they are the difference between this and a retired control.
1490
+ //
1491
+ // This is why D2 makes multi a SEPARATE inputType: the selected set is a
1492
+ // SECOND FOCUSABLE DIMENSION โ€” a second widget grafted onto the first, as
1493
+ // Downshift charges a separate hook for. It is NOT an orthogonal flag.
1494
+ // And `select-multiple` REMAINS the control for ENUMERABLE sets: that
1495
+ // split is an ACCESSIBILITY REQUIREMENT, not taste (the APG combobox has
1496
+ // "tested poorly with users for more than two decades"). The lookup must
1497
+ // never try to swallow it.
1498
+ const chipButtons = [];
1499
+ const chipValues = [];
1500
+ const chipLabels = [];
1501
+ let chipList;
1502
+ // The value of the chip currently armed for the two-step Backspace
1503
+ // (ยง7 item 31), or null. A VALUE rather than an index โ€” see the snapshot.
1504
+ let armed = null;
1505
+ let roving = 0;
1506
+ /** Mirror the DOM-local chip facts onto the snapshot carrier. */
1507
+ const syncChipSnapshot = () => {
1508
+ popup.dataset.vmsLookupRoving = String(roving);
1509
+ popup.dataset.vmsLookupArmed = armed ?? "";
1510
+ };
1511
+ /**
1512
+ * ยง7 item 26 โ€” ROVING TABINDEX across the remove buttons: exactly one is
1513
+ * in the tab sequence at a time; Left/Right traverse.
1514
+ *
1515
+ * ๐Ÿšจ Roving tabindex is correct HERE and WRONG for the popup 40 lines
1516
+ * below, and the difference is not a style choice. A chip is NOT
1517
+ * text-editable, so real DOM focus can move onto it freely โ€” and the
1518
+ * remove buttons NEED real focus to be operable at all, which
1519
+ * aria-activedescendant cannot give them. The input IS text-editable, so
1520
+ * moving DOM focus out of it would break typing, which is why the popup
1521
+ * uses aria-activedescendant instead. Same control, two focus models, for
1522
+ * two different reasons. Do not "unify" them.
1523
+ */
1524
+ const setRoving = (i) => {
1525
+ if (chipButtons.length === 0) {
1526
+ roving = 0;
1527
+ syncChipSnapshot();
1528
+ return;
1529
+ }
1530
+ roving = Math.min(Math.max(i, 0), chipButtons.length - 1);
1531
+ chipButtons.forEach((b, j) => { b.tabIndex = j === roving ? 0 : -1; });
1532
+ syncChipSnapshot();
1533
+ };
1534
+ const setArmed = (value) => {
1535
+ armed = value;
1536
+ chipList?.querySelectorAll(".vms-field__chip").forEach((li, i) => {
1537
+ li.classList.toggle("vms-field__chip--armed", value != null && chipValues[i] === value);
1538
+ });
1539
+ syncChipSnapshot();
1540
+ };
1541
+ /**
1542
+ * ๐Ÿšจ ยง7 item 29 โ€” THE FOCUS-AFTER-REMOVAL RULE.
1543
+ *
1544
+ * next chip's remove button -> else previous chip's -> else the text
1545
+ * input. NEVER <body>.
1546
+ *
1547
+ * Removing the focused element dumps focus to <body>, which strands the
1548
+ * user AT THE TOP OF THE PAGE with no idea where they are or what just
1549
+ * happened. This is one of the two failures that retired GOV.UK's
1550
+ * component, and it has NO ANALOG in this codebase โ€” nothing else here
1551
+ * manages focus across a SET (the <details> restore is by id, not by set
1552
+ * position). It is a single named helper on purpose: it is the highest-risk
1553
+ * item in this arm and it must be findable, greppable, and testable.
1554
+ *
1555
+ * Called AFTER the removed entry has been spliced out of `chipButtons`,
1556
+ * so `chipButtons[removedIndex]` IS the chip that shifted into the gap โ€”
1557
+ * i.e. the "next" one โ€” and it is `undefined` when the removed chip was
1558
+ * last. Both fallbacks are therefore structural rather than conditional
1559
+ * branches someone can forget to write.
1560
+ */
1561
+ const focusAfterChipRemoval = (removedIndex) => {
1562
+ const next = chipButtons[removedIndex];
1563
+ const previous = chipButtons[removedIndex - 1];
1564
+ const target = next ?? previous ?? inp;
1565
+ target.focus();
1566
+ if (target !== inp)
1567
+ setRoving(chipButtons.indexOf(target));
1568
+ else
1569
+ setRoving(0);
1570
+ };
1571
+ const removeChipAt = (i) => {
1572
+ const value = chipValues[i];
1573
+ const label = chipLabels[i];
1574
+ const li = chipButtons[i]?.closest(".vms-field__chip");
1575
+ if (value == null || li == null)
1576
+ return;
1577
+ // The id โ€” and ONLY the id โ€” is what persists (D1). The label was never
1578
+ // in the bind, so there is nothing to keep in sync here.
1579
+ //
1580
+ // ๐Ÿšจ THE ARITY SPLIT, WRITE SIDE (D2a). The wire is explicit and the two
1581
+ // shapes are NOT interchangeable: `bind` is a `string` for `lookup` and a
1582
+ // `string[]` for `lookup-multiple`. Writing `[]` into a single-select's
1583
+ // scalar slot would hand the server an array where its state record
1584
+ // declares a string โ€” a cross-backend type mismatch that System.Text.Json
1585
+ // rejects outright on the `_state` deserialize. Removing single's one
1586
+ // chip IS clearing the selection, so it writes the empty id.
1587
+ let remaining;
1588
+ if (isMulti) {
1589
+ const current = this.readBind(n.bind);
1590
+ const ids = Array.isArray(current) ? current.map(String) : [];
1591
+ const nextIds = ids.filter(id => id !== value);
1592
+ this.writeBind(n.bind, nextIds);
1593
+ remaining = nextIds.length;
1594
+ }
1595
+ else {
1596
+ this.writeBind(n.bind, "");
1597
+ remaining = 0;
1598
+ }
1599
+ // The chip leaves the DOM NOW, not when the server answers. A bind write
1600
+ // does not re-render โ€” `selected` is server-owned VIEW โ€” so without this
1601
+ // the user clicks "Remove Sally Omer" and Sally's chip just sits there.
1602
+ // Same model as every other input in this file: the DOM shows the change
1603
+ // immediately, state round-trips, and the SERVER'S NEXT RENDER IS
1604
+ // AUTHORITATIVE (chips are rebuilt from `selected` above, so a server
1605
+ // that rejects the removal simply puts the chip back).
1606
+ li.remove();
1607
+ chipButtons.splice(i, 1);
1608
+ chipValues.splice(i, 1);
1609
+ chipLabels.splice(i, 1);
1610
+ if (armed === value)
1611
+ setArmed(null);
1612
+ // Focus BEFORE announcing: the user must never be left on <body>, and
1613
+ // the announcement is debounced anyway.
1614
+ //
1615
+ // ๐Ÿšจ ยง7 item 29 STILL APPLIES AT ONE CHIP, and single-select is the case
1616
+ // that exercises its LAST fallback: with one chip, `next` and `previous`
1617
+ // are both undefined, so focus lands on the INPUT. That is exactly right
1618
+ // (the user just cleared their selection and the next thing they want is
1619
+ // to type), and it is why the rule is written as a structural chain
1620
+ // rather than as conditionals someone could forget to extend to single.
1621
+ focusAfterChipRemoval(i);
1622
+ announceRemove(label, remaining);
1623
+ };
1624
+ /** Empty the chip list and the parallel DOM-local arrays. Single-select's
1625
+ * REPLACE is "clear, then append one" โ€” see addValue(). */
1626
+ const clearChips = () => {
1627
+ chipList?.replaceChildren();
1628
+ chipButtons.length = 0;
1629
+ chipValues.length = 0;
1630
+ chipLabels.length = 0;
1631
+ };
1632
+ /** Render one chip. `label` is display-only; `value` is the id (D1).
1633
+ * `type` is D6's polymorphic tag โ€” see the dataset write below. */
1634
+ const appendChip = (value, label, type) => {
1635
+ if (chipList == null)
1636
+ return;
1637
+ const i = chipValues.length;
1638
+ const li = document.createElement("li");
1639
+ li.className = "vms-field__chip";
1640
+ // ยง7 item 24 โ€” role="listitem" EXPLICITLY. `list-style: none` strips the
1641
+ // implicit list/listitem roles in Safari/VoiceOver, so a styled <ul>
1642
+ // silently stops being a list exactly where it matters most.
1643
+ li.setAttribute("role", "listitem");
1644
+ // D6 โ€” the polymorphic type tag rides ALONGSIDE the display and never
1645
+ // leaks into the bound value: `bind` holds the id and nothing else. An id
1646
+ // alone is not an identity (a Dataverse owner GUID "doesn't tell you
1647
+ // whether the owner of the record is a user or a team"), so a polymorphic
1648
+ // reference exposes what KIND of thing it names.
1649
+ //
1650
+ // ๐Ÿšจ It hangs on the CHIP, which is where the selection now IS (D2a). It
1651
+ // used to hang on the INPUT as `data-vms-selected-type`, single-select
1652
+ // only โ€” correct when the input WAS the selection, stale the moment the
1653
+ // selection moved out, and it would have left a type tag on a box that
1654
+ // holds nothing but the query. Chipping it also gives MULTI the exposure
1655
+ // it never had (each chip tags its own reference, which is the only shape
1656
+ // that can work for a mixed user/team set).
1657
+ if (type != null)
1658
+ li.dataset.vmsType = type;
1659
+ const text = document.createElement("span");
1660
+ text.className = "vms-field__chip-label";
1661
+ // textContent, never innerHTML โ€” a server-supplied label is text, not
1662
+ // markup (the house idiom throughout this file).
1663
+ text.textContent = label;
1664
+ li.appendChild(text);
1665
+ const btn = document.createElement("button");
1666
+ // MANDATORY: a chip inside a FormNode would otherwise SUBMIT it on every
1667
+ // remove click โ€” <button>'s default type is "submit".
1668
+ btn.type = "button";
1669
+ btn.className = "vms-field__chip-remove";
1670
+ // Index-keyed, matching the popup options' id scheme and for the same
1671
+ // reason: `selected` is the server's array and is not guaranteed deduped,
1672
+ // so a value-derived id could collide โ€” and a value may contain spaces
1673
+ // (a free-form tag), which an id may not. render()'s generic focus
1674
+ // restore re-finds this button by id after a re-render.
1675
+ btn.id = `vms-${n.name}-chip-${i}-remove`;
1676
+ // ๐Ÿšจ ยง7 item 25 โ€” A UNIQUE, ITEM-SPECIFIC ACCESSIBLE NAME. NOT "Remove",
1677
+ // NOT "x", NOT an unlabelled icon. THIS EXACT FAILURE IS WHAT KILLED THE
1678
+ // GOV.UK MULTISELECT: their review found it "does not announce ... the
1679
+ // presence of the 'Remove' button for screenreaders", and a row of
1680
+ // identically-named buttons is unusable โ€” the user hears "Remove button,
1681
+ // Remove button, Remove button" and cannot tell which is which. Per D5 an
1682
+ // item whose label is omitted names itself by its value, so this is never
1683
+ // unnamed. setAttribute takes an attribute VALUE โ€” never parsed as
1684
+ // markup โ€” so a hostile server label cannot inject here.
1685
+ btn.setAttribute("aria-label", `Remove ${label}`);
1686
+ // Decorative: the accessible name above is the real one.
1687
+ btn.textContent = "ร—";
1688
+ btn.setAttribute("aria-hidden", "false");
1689
+ btn.addEventListener("click", (e) => {
1690
+ e.preventDefault();
1691
+ // stopPropagation for the same reason the table's row-action does it:
1692
+ // a chip row may live inside a clickable ancestor.
1693
+ e.stopPropagation();
1694
+ removeChipAt(chipButtons.indexOf(btn));
1695
+ });
1696
+ btn.addEventListener("keydown", (e) => {
1697
+ const lastChip = chipButtons.length - 1;
1698
+ const at = chipButtons.indexOf(btn);
1699
+ if (e.key === "ArrowRight") {
1700
+ e.preventDefault();
1701
+ // ยง7 item 26 โ€” traverse. Clamped, NOT wrapped: the popup listbox
1702
+ // wraps (ยง7 item 16) because it is a closed loop the user is
1703
+ // cycling; a chip row is a line the user is walking, and wrapping
1704
+ // from the last chip back to the first silently moves focus across
1705
+ // the whole widget.
1706
+ const to = Math.min(at + 1, lastChip);
1707
+ chipButtons[to]?.focus();
1708
+ setRoving(to);
1709
+ return;
1710
+ }
1711
+ if (e.key === "ArrowLeft") {
1712
+ e.preventDefault();
1713
+ const to = Math.max(at - 1, 0);
1714
+ chipButtons[to]?.focus();
1715
+ setRoving(to);
1716
+ return;
1717
+ }
1718
+ });
1719
+ li.appendChild(btn);
1720
+ chipList.appendChild(li);
1721
+ chipButtons.push(btn);
1722
+ chipValues.push(value);
1723
+ chipLabels.push(label);
1724
+ };
1725
+ /**
1726
+ * Commit `value` into the selection. The ONE path both a picked candidate
1727
+ * and an invented (allowCustom) value take (see commitCustom()) โ€” and, since
1728
+ * D2a, the ONE path BOTH MODES take.
1729
+ *
1730
+ * ๐Ÿšจ THIS FUNCTION IS THE ONLY PLACE `lookup` AND `lookup-multiple` BEHAVE
1731
+ * DIFFERENTLY, AND THE DIFFERENCE IS ARITY AND NOTHING ELSE:
1732
+ *
1733
+ * single REPLACES ยท multi APPENDS
1734
+ *
1735
+ * Everything else about the two โ€” the chip markup, the a11y contract, the
1736
+ * focus rules, the input holding nothing but the query โ€” is identical by
1737
+ * construction, because it is literally the same code. If you find yourself
1738
+ * adding a second `isMulti` branch somewhere else in this arm, check first
1739
+ * whether it belongs here instead.
1740
+ */
1741
+ const addValue = (value, label, type) => {
1742
+ if (!isMulti) {
1743
+ // ๐Ÿšจ REPLACE (D2a). The operator's words: "instead of adding a pill like
1744
+ // with tags, it replaces." There is never a second chip, which is also
1745
+ // what makes replace-on-pick self-evident in use โ€” the mitigation for
1746
+ // the one risk D2a records ("a chip on a single-select could imply you
1747
+ // can add more").
1748
+ //
1749
+ // The id โ€” and ONLY the id โ€” is what persists (D1), and for `lookup`
1750
+ // that id is a bare `string`, never an array โ€” see removeChipAt() for
1751
+ // why the two shapes are not interchangeable.
1752
+ this.writeBind(n.bind, value);
1753
+ // Optimistic, and the SERVER'S NEXT RENDER IS AUTHORITATIVE โ€” the chip
1754
+ // render path reads `n.selected` and only `n.selected`. Rebuilt rather
1755
+ // than mutated in place so the chip's stable index-keyed id, its
1756
+ // roving tabindex and its aria-label are all produced by the one
1757
+ // appendChip() path instead of a second, drifting update path.
1758
+ clearChips();
1759
+ appendChip(value, label, type);
1760
+ setRoving(0);
1761
+ announceAdd(label, 1);
1762
+ inp.value = "";
1763
+ this.writeBind(n.searchBind, "");
1764
+ setArmed(null);
1765
+ return;
1766
+ }
1767
+ const current = this.readBind(n.bind);
1768
+ const ids = Array.isArray(current) ? current.map(String) : [];
1769
+ // ๐Ÿšจ D12 SCOPE โ€” deduping `bind` ON COMMIT is ALLOWED AND CORRECT, and is
1770
+ // NOT a D12 violation. A reader fresh off D12 WILL flag this line (the
1771
+ // phase planner did, which is why the decision now spells the scope out
1772
+ // and why this comment exists).
1773
+ //
1774
+ // D12 forbids second-guessing the SERVER'S ANSWER: the renderer may not
1775
+ // reorder, filter, dedupe or truncate `candidates` FOR DISPLAY. That
1776
+ // list is the app's judgment โ€” a real consumer ranks it by
1777
+ // recency-weighted mention frequency โ€” and a renderer with an opinion
1778
+ // about it would silently destroy that ranking.
1779
+ //
1780
+ // THIS IS NOT THAT. This is a STATE WRITE about the user's OWN
1781
+ // accumulated selection. A selection set has set semantics; a duplicate
1782
+ // id in `bind` is meaningless in every case anyone has been able to
1783
+ // construct, and mature libraries prevent it structurally
1784
+ // (react-select's `hideSelectedOptions` defaults on for multi).
1785
+ //
1786
+ // Presentation vs. state write. Two different things.
1787
+ const already = ids.includes(value);
1788
+ const nextIds = already ? ids : [...ids, value];
1789
+ if (!already) {
1790
+ this.writeBind(n.bind, nextIds);
1791
+ // Optimistic, for the same reason removeChipAt() is โ€” see there. The
1792
+ // label is the one the user JUST PICKED (or JUST TYPED), held in hand
1793
+ // at the moment of the act.
1794
+ //
1795
+ // ๐Ÿšจ This is NOT the D1 trap, and the distinction is exact: the trap is
1796
+ // RESOLVING AN ALREADY-SELECTED ID'S LABEL BY SEARCHING `candidates` โ€”
1797
+ // which fails precisely when it matters (a cold-start form load, or a
1798
+ // filtered list that excludes the selection) because there is nothing
1799
+ // to find. Nothing here searches: this label is the clicked item's own,
1800
+ // used once, immediately, and replaced by the server's authoritative
1801
+ // `selected` on the very next render. The chip RENDER path above reads
1802
+ // `n.selected` and only `n.selected`.
1803
+ appendChip(value, label, type);
1804
+ setRoving(roving);
1805
+ }
1806
+ // ๐Ÿšจ ยง7 items 27 + 32 โ€” announce WITH THE RUNNING COUNT (see
1807
+ // announceAdd(), which holds the rule and the reason). This is also the
1808
+ // ONLY thing that actually conveys the selection: aria-selected /
1809
+ // aria-multiselectable are "mostly not announced when true", and on
1810
+ // Safari/VoiceOver the ARIA path conveys NOTHING. Set the attributes
1811
+ // (correct, cheap, support improves); TELL the user here.
1812
+ //
1813
+ // A duplicate still announces, and the sentence stays true โ€” the item IS
1814
+ // selected and the count IS accurate. Silence would just look broken.
1815
+ announceAdd(label, nextIds.length);
1816
+ inp.value = "";
1817
+ this.writeBind(n.searchBind, "");
1818
+ setArmed(null);
1819
+ };
1820
+ // ๐Ÿšจ BOTH MODES (D2a) โ€” this used to be gated on `isMulti`. Single-select's
1821
+ // selection is now a chip built by the SAME code, and the whole a11y
1822
+ // contract below comes with it for free precisely BECAUSE it is not a fork.
1823
+ {
1824
+ chipList = document.createElement("ul");
1825
+ chipList.className = "vms-field__chips";
1826
+ // ยง7 item 24 โ€” role="list", NOT a listbox with option children. A chip
1827
+ // CONTAINS a remove button, and an interactive descendant inside
1828
+ // role="option" is invalid and DESTROYS the accessibility tree. Explicit
1829
+ // despite the <ul> for the Safari `list-style: none` bug (see appendChip).
1830
+ chipList.setAttribute("role", "list");
1831
+ // ยง7 item 28 โ€” the group needs an accessible name or it cannot be found.
1832
+ chipList.setAttribute("aria-label", "Selected items");
1833
+ // ๐Ÿšจ THE DISPLAY PATH (D1) โ€” chips are built from `n.selected`, and ONLY
1834
+ // from `n.selected`. `candidates` feeds the popup listbox and NOTHING
1835
+ // else. Mid-search the candidate list routinely EXCLUDES what is already
1836
+ // chosen, so a chip labelled out of `candidates` renders a raw database
1837
+ // id or vanishes on the case that matters most. Per D5 an item whose
1838
+ // label is omitted displays its value.
1839
+ //
1840
+ // ๐Ÿšจ `selected` IS ALWAYS AN ARRAY, INCLUDING FOR SINGLE-SELECT, where it
1841
+ // holds 0 or 1 entries โ€” that is the wire shape (ยง4), chosen so a `T |
1842
+ // T[]` union could not drift across the two backends. Which means this
1843
+ // one line renders BOTH modes with no arity branch: single is simply the
1844
+ // array that is never longer than one. Nothing clamps it here on purpose
1845
+ // โ€” `selected` is the SERVER'S answer, and if a server ever sent two
1846
+ // entries for a `lookup`, silently hiding one would be this renderer
1847
+ // second-guessing it (D12's instinct, if not its letter). Two chips on a
1848
+ // single-select is a server bug, and it should look like one.
1849
+ selectedItems.forEach(item => appendChip(item.value, item.label ?? item.value, item.type));
1850
+ // Restore the DOM-local chip facts the wipe destroyed. Clamped, because
1851
+ // the server may have returned fewer chips than the last render had.
1852
+ setRoving(snapshot?.roving ?? 0);
1853
+ // Only re-arm if the LAST chip is still the SAME ITEM โ€” see the snapshot
1854
+ // declaration for why this is keyed by value and not by a boolean.
1855
+ // Single never arms (the two-step Backspace is multi-only, D14), so this
1856
+ // is structurally a no-op there rather than a branch.
1857
+ const armedValue = snapshot?.armed;
1858
+ if (armedValue != null && chipValues[chipValues.length - 1] === armedValue) {
1859
+ setArmed(armedValue);
1860
+ }
1861
+ else {
1862
+ setArmed(null);
1863
+ }
1864
+ }
1865
+ const setActive = (i) => {
1866
+ activeIndex = i;
1867
+ // ยง7 item 32 โ€” keep aria-selected accurate on EVERY option (true AND
1868
+ // false), while treating it as NON-COMMUNICATING: it is "mostly not
1869
+ // announced when true", so the live region (Plan 21-04) is what
1870
+ // actually tells an AT user what is highlighted. Set it anyway: it is
1871
+ // correct, it is cheap, and support improves.
1872
+ optionEls.forEach((el, j) => el.setAttribute("aria-selected", String(j === i)));
1873
+ const activeEl = i >= 0 ? optionEls[i] : undefined;
1874
+ // ยง7 item 2 โ€” present ONLY while an option is active; removed otherwise.
1875
+ if (activeEl)
1876
+ inp.setAttribute("aria-activedescendant", activeEl.id);
1877
+ else
1878
+ inp.removeAttribute("aria-activedescendant");
1879
+ // ยง7 items 11 + 32 โ€” the highlight is ALSO spoken. The ARIA above is set
1880
+ // because it is correct and cheap, but it is NOT the delivery mechanism:
1881
+ // aria-selected is "mostly not announced when true", and on
1882
+ // Safari/VoiceOver the ARIA path conveys nothing at all. Every fact the
1883
+ // ARIA encodes must also reach the user as live-region TEXT.
1884
+ if (activeEl) {
1885
+ announce(`${activeEl.textContent} ${i + 1} of ${optionEls.length} is highlighted`);
1886
+ }
1887
+ };
1888
+ const setOpen = (v) => {
1889
+ open = v;
1890
+ popup.hidden = !v;
1891
+ inp.setAttribute("aria-expanded", String(v));
1892
+ // Closing the popup ends the search session: the user has committed,
1893
+ // escaped, or tabbed away. Anything they hear after that is noise about
1894
+ // a question they stopped asking.
1895
+ if (!v) {
1896
+ setActive(-1);
1897
+ setQuerying(false);
1898
+ }
1899
+ };
1900
+ // โ•โ• THE 21-13 PILL-INPUT TREATMENT IS GONE โ€” DO NOT BRING IT BACK โ•โ•โ•โ•โ•โ•
1901
+ //
1902
+ // 21-13 styled THE INPUT ITSELF as a pill when a record was selected (the
1903
+ // SLDS shape: for single-select no separate pill element exists at all),
1904
+ // plus an inline clear โœ• overlaid on the input's right edge. Both are
1905
+ // DELETED, along with the `labelShown` flag that drove them and the
1906
+ // `.vms-field--lookup-selected` CSS.
1907
+ //
1908
+ // ๐Ÿšจ WHY, so nobody restores it as "the missing polish": the operator drove
1909
+ // it and found it had NOWHERE TO CLICK TO TYPE. The pill WAS the input, so
1910
+ // clicking into it just appended to "Sally Omer". SLDS's model is coherent
1911
+ // GIVEN clear-then-search; the operator demonstrated clear-then-search has
1912
+ // no click target. Ours is coherent given always-typeable. That divergence
1913
+ // from the survey is deliberate and recorded at D2a ยง"The honest divergence
1914
+ // from the survey" โ€” do not "correct" us back toward SLDS on the strength
1915
+ // of the citation in the design doc's ยง2.
1916
+ //
1917
+ // The clear โœ• went with it because THE CHIP'S OWN โœ• now does that job, in
1918
+ // both modes, from one implementation โ€” a second, differently-shaped clear
1919
+ // affordance beside a chip that already has one would be exactly the
1920
+ // parallel-path drift D2a's "reuse the chip" rule exists to prevent.
1921
+ //
1922
+ // And `labelShown` went with BOTH because the question it answered ("is the
1923
+ // box showing a label or a query?") NO LONGER EXISTS. See the display path
1924
+ // above: `inp.value` is the query, unconditionally.
1925
+ // โ”€โ”€ POPUP-OPEN PRESERVATION (Phase 21, LOOK-02) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1926
+ //
1927
+ // ๐Ÿšจ PRESERVE OPEN. DO NOT PRESERVE ACTIVE. The two lines below look like
1928
+ // they are missing a third; they are not.
1929
+ //
1930
+ // Why open is preserved: this arm's popup state is DOM-local, so it dies
1931
+ // in render()'s innerHTML wipe โ€” and the search itself CAUSES a re-render.
1932
+ // Without this, the results of the very search the user just asked for
1933
+ // would arrive with the popup slammed shut. (Enter-to-search makes this
1934
+ // one re-render per question rather than one every ~300ms, but it does not
1935
+ // make it go away: the answer still lands on a rebuilt tree.)
1936
+ //
1937
+ // Why active is NOT preserved: `activeIndex` starts at -1 on every render,
1938
+ // and that is HALF OF ยง7 item 14 โ€” we never auto-highlight when results
1939
+ // arrive. Restoring the highlight here is the natural-looking completion
1940
+ // of this pass and it would resurrect the exact React Aria NVDA failure
1941
+ // item 14 exists to prevent: with an option auto-focused, "character
1942
+ // deletions and text cursor movement in the ComboBox input weren't being
1943
+ // announced at all". Restoring open costs nothing; restoring active
1944
+ // silently breaks the announcement of the user's own typing.
1945
+ //
1946
+ // Restored HERE rather than in a post-render DOM walk (the [data-section-key]
1947
+ // details pattern) because `open` is a CLOSURE variable: setting
1948
+ // popup.hidden from outside would leave the closure believing the popup is
1949
+ // closed, and Escape would then take its popup-already-closed branch and
1950
+ // CLEAR the user's selection โ€” silent data loss on a keypress that meant
1951
+ // "get this out of my way". The closure and the DOM must never disagree.
1952
+ //
1953
+ // An empty candidate set stays closed: there is nothing to show, matching
1954
+ // the input listener's own `optionEls.length > 0` gate below.
1955
+ //
1956
+ // `querying` opens it too, and that is NOT redundant with `open`: on the
1957
+ // FIRST search there are no prior options, so the input listener's own
1958
+ // `if (optionEls.length > 0) setOpen(true)` cannot fire and the popup was
1959
+ // never open to preserve. Without this, the results of the very first
1960
+ // search would arrive invisibly and the user would have to press ArrowDown
1961
+ // to discover the answer they just asked for. `querying` is now set by
1962
+ // search() โ€” the Enter that ASKED โ€” rather than by typing.
1963
+ if ((snapshot?.open === true || querying) && optionEls.length > 0) {
1964
+ setOpen(true);
1965
+ }
1966
+ // ยง7 item 11 โ€” results arriving is a fact the user must be TOLD, not just
1967
+ // shown. Gated on `querying` so that a re-render for unrelated reasons (a
1968
+ // poll tick, another action) never narrates a candidate count out of
1969
+ // nowhere. One Enter, one announcement โ€” no debounce, because there is no
1970
+ // longer a per-keystroke firehose to tame.
1971
+ if (querying) {
1972
+ announce(optionEls.length > 0
1973
+ ? `${optionEls.length} results are available.`
1974
+ : "No search results");
1975
+ }
1976
+ /** Accept the candidate at `i` โ€” the ONLY path that writes the bind.
1977
+ *
1978
+ * ๐Ÿšจ ONE PATH FOR BOTH MODES (D2a). This used to fork: multi called
1979
+ * addValue(), single hand-wrote its own bind write + `inp.value = label` +
1980
+ * query clear + pill flag, and that duplicated commit path is precisely
1981
+ * where the "leaving a stale query behind redraws the box as 'sal'" bug
1982
+ * lived. The arity difference lives in addValue() and NOWHERE ELSE. */
1983
+ const commit = (i) => {
1984
+ const c = (n.candidates ?? [])[i];
1985
+ if (c == null)
1986
+ return;
1987
+ // Per D5 an omitted label means the label IS the value.
1988
+ addValue(c.value, c.label ?? c.value, c.type);
1989
+ if (isMulti) {
1990
+ // ยง7 item 30 โ€” do NOT close the popup on select in a multi-select;
1991
+ // the user is usually picking several.
1992
+ setActive(-1);
1993
+ }
1994
+ else {
1995
+ // Single is done: the question was "which one?", it has been answered,
1996
+ // and the list has nothing left to offer.
1997
+ setOpen(false);
1998
+ }
1999
+ inp.focus();
2000
+ };
2001
+ /**
2002
+ * D3 (LOOK-04) โ€” commit an INVENTED value: one the server never offered.
2003
+ *
2004
+ * ๐Ÿšจ GATED ON THE DECLARED `allowCustom` AXIS, NEVER INFERRED FROM
2005
+ * BEHAVIOR. The rationale is the whole decision: "choosing somebody to
2006
+ * mention is very different from inventing a new tag." Those are different
2007
+ * ACTS sharing one widget, so the control DECLARES which it is doing
2008
+ * rather than leaving it to be guessed from what the user happened to
2009
+ * type. Omitted/false โ‡’ a typed non-candidate commits NOTHING.
2010
+ *
2011
+ * ๐Ÿšจ An invented value is a HOMOGENEOUS LookupItem โ€” a value whose label
2012
+ * equals itself (and is therefore omitted, D5) โ€” and NEVER a bare string.
2013
+ * That is the entire reason one control can serve both acts. MUI's
2014
+ * `multiple + freeSolo` yields `Array<Value | string>`: a heterogeneous
2015
+ * union that forces EVERY consumer to branch on `typeof`, and whose own
2016
+ * docs warn it "may cause type mismatch". Their tags demo dodges it only by
2017
+ * degrading options to bare strings. We never admit a bare string, so the
2018
+ * union cannot arise: `bind` stays uniformly string[] of ids whether the
2019
+ * entries were picked or invented.
2020
+ *
2021
+ * โ‡’ `allowCustom: true` + NO candidates + labels omitted IS a free-form
2022
+ * tags input, with NO SPECIAL CASE ANYWHERE IN THIS RENDERER โ€” this
2023
+ * function is the same one the picked path uses. That composition is why
2024
+ * the separately-designed `inputType: "tags"` proposal was superseded
2025
+ * rather than built.
2026
+ *
2027
+ * ๐Ÿšจ NO PROVENANCE MARKER, DELIBERATELY. Do not add react-select's
2028
+ * `__isNew__`, and do not add a distinct `create-option` action. The next
2029
+ * person WILL reach for them โ€” react-select is the obvious precedent โ€”
2030
+ * so: react-select needs a marker because it is CLIENT-ONLY and has no
2031
+ * server to ask. We have a server, and it produced every candidate it ever
2032
+ * offered, so "is this id one of mine?" is server-decidable and picked-vs-
2033
+ * invented needs no wire field (OPEN-3). The explicitness D3 demands is
2034
+ * carried by `allowCustom` being a DECLARED AXIS ON THE NODE โ€” the app
2035
+ * declares the act โ€” not by a per-value flag.
2036
+ */
2037
+ const commitCustom = (raw) => {
2038
+ // Trim on the commit path (carried over from the superseded `tags`
2039
+ // research per D3): a trailing space is a slip, not a distinct tag.
2040
+ const value = raw.trim();
2041
+ if (value === "")
2042
+ return;
2043
+ // An invented value is a value whose label equals itself (D5), and it
2044
+ // takes the SAME commit path as a picked one โ€” including D2a's arity
2045
+ // split, so a single-select tags field replaces its one tag rather than
2046
+ // accumulating. Dedupe lives in addValue() โ€” see the D12 SCOPE note there.
2047
+ addValue(value, value);
2048
+ if (isMulti)
2049
+ setActive(-1);
2050
+ else
2051
+ setOpen(false);
2052
+ inp.focus();
2053
+ };
2054
+ optionEls.forEach((opt, i) => {
2055
+ // mousedown (not click) + preventDefault: keeps DOM focus in the input
2056
+ // instead of letting the press blur it, which is the same reason the
2057
+ // active option is tracked with aria-activedescendant at all.
2058
+ opt.addEventListener("mousedown", (e) => {
2059
+ e.preventDefault();
2060
+ commit(i);
2061
+ });
2062
+ });
2063
+ // โ”€โ”€ THE SEARCH (Phase 21, 21-11 / D4 / D11 โ€” BOTH REVERSED) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
2064
+ //
2065
+ // ๐Ÿšจ `searchAction` IS AN ORDINARY, BLOCKING ACTION FIRED ON ENTER. It is
2066
+ // byte-for-byte the cadence `TableNode.filterAction` has always used
2067
+ // (table(), below: keystrokes write the bind; ENTER dispatches) and the one
2068
+ // the text arm uses. There is NO debounce timer, NO live-query lane, and
2069
+ // NOTHING here touches `blocking`.
2070
+ //
2071
+ // It did not start that way. The first cut of this control searched on a
2072
+ // ~300ms type-as-you-go debounce and FORCED `blocking: false`, and the
2073
+ // operator drove it on the tailnet and reversed both. The reasons are
2074
+ // recorded here because every deleted mechanism will look like a missing
2075
+ // feature to the next reader:
2076
+ //
2077
+ // 1. WHY BLOCKING IS A CORRECTNESS WIN, not a UX preference. A blocking
2078
+ // action is SERIALIZED BY THE EXISTING DISPATCH GUARD โ€” a second action
2079
+ // cannot dispatch while a round trip is in flight, and has not been able
2080
+ // to since long before this control existed. So the entire stale-response
2081
+ // race category is not MITIGATED here; it is STRUCTURALLY IMPOSSIBLE.
2082
+ // That is stronger than any test suite, and it is why the four
2083
+ // adversarial race tests that used to guard this file are GONE rather
2084
+ // than ported: they rigorously proved properties of a mechanism we no
2085
+ // longer use. Rigor inside the wrong frame is not rigor.
2086
+ //
2087
+ // 2. WHY THE RENDERER MUST NEVER SET `blocking` (the framework rule, which
2088
+ // outlives this control โ€” see AGENTS.md):
2089
+ //
2090
+ // NON-BLOCKING IS ALWAYS THE APP'S EXPLICIT, OPT-IN CHOICE. The
2091
+ // framework never forces, infers, or upgrades a dispatch onto the
2092
+ // non-blocking lane.
2093
+ //
2094
+ // `blocking: false` is SEMANTIC: it means this response may be
2095
+ // discarded, may arrive out of order, and may coexist with another in
2096
+ // flight. An app that did not ask for those semantics can have its logic
2097
+ // broken by them, silently. That is not the framework's call to make.
2098
+ // The old argument for forcing it here was circular โ€” it forced the lane
2099
+ // because an app that forgot the flag would busy-lock the page on every
2100
+ // keystroke, but that failure only existed because typing triggered round
2101
+ // trips in the first place. The feature invented the problem, then took a
2102
+ // power away from the app to paper over it.
2103
+ //
2104
+ // โ‡’ The spread below is `on(searchAction)` โ€” the app's ActionEvent,
2105
+ // UNTOUCHED. If you find yourself wanting to force the lane here, STOP:
2106
+ // that urge is the exact smell the rule names, and it means the shape is
2107
+ // wrong, not the rule.
2108
+ //
2109
+ // 3. WHAT DIED WITH THE CADENCE, so nobody "restores" it piecemeal: the
2110
+ // 300ms query debounce and its adapter-keyed timer map; the ~1400ms
2111
+ // announcement debounce (a firehose tamer with no firehose left); the
2112
+ // popup slamming shut mid-typing; the chips dying mid-interaction; and
2113
+ // the results jumping under the operator's cursor as she reached to click
2114
+ // a name. EVERY ONE of those existed to serve type-as-you-go.
2115
+ const search = () => {
2116
+ const searchAction = n.searchAction;
2117
+ if (searchAction == null)
2118
+ return;
2119
+ // Belt-and-suspenders, exactly as the text arm's Enter handler and the
2120
+ // table filter's do: flush the box to state BEFORE dispatching, in case
2121
+ // the browser has not fired `input` yet (an autofill or IME commit that
2122
+ // lands then submits). The dispatched `_state` must be what the box
2123
+ // actually says.
2124
+ //
2125
+ // ๐Ÿšจ DISPATCHED UNCONDITIONALLY โ€” never gated on a non-empty value. AN
2126
+ // EMPTY QUERY IS A LEGITIMATE QUERY (OPEN-6): it is how an app serves a
2127
+ // most-recently-used list on an empty box (Salesforce's picker
2128
+ // `searchType` DEFAULTS to `Recent`; Dynamics shows 5 MRU + 5 favourites).
2129
+ // An `if (inp.value)` gate here voids the MRU decision silently. This is
2130
+ // the DISPATCH question โ€” it keys on the query being non-null, NOT
2131
+ // non-empty. The DISPLAY question is the other one, and it is answered
2132
+ // ~200 lines above; conflating the two is what shipped the
2133
+ // placeholder-instead-of-label bug.
2134
+ //
2135
+ // ๐Ÿšจ THE BOX IS THE QUERY. FLUSH IT. NO CONDITION (D2a).
2136
+ //
2137
+ // This line used to read `labelShown ? "" : inp.value` โ€” it had to know
2138
+ // whether the box was holding the server's label or the user's query,
2139
+ // because in single-select it could be either, and flushing a label would
2140
+ // have sent "Sally Omer" as the search term for a field whose owner
2141
+ // already IS Sally Omer. With the selection in a chip, the box can only
2142
+ // ever hold a query, so there is nothing to ask and nothing to get wrong.
2143
+ // Enter on an untouched box sends "" โ€” the MRU question it looks like it
2144
+ // is asking (OPEN-6) โ€” because the box is genuinely empty, not because a
2145
+ // flag said to pretend it was.
2146
+ this.writeBind(n.searchBind, inp.value);
2147
+ // "The user just asked a question": lets the answer open the popup (a
2148
+ // first search has no prior options, so the input listener's own open
2149
+ // cannot fire) and gates the result announcement, so an unrelated
2150
+ // re-render never narrates a candidate count out of nowhere.
2151
+ setQuerying(true);
2152
+ // ยง7 item 11 โ€” announce LOADING. An async combobox that is silent during
2153
+ // the fetch leaves AT users unable to tell a slow server from a dead one.
2154
+ announce("Loading results");
2155
+ on(searchAction);
2156
+ };
2157
+ inp.addEventListener("input", () => {
2158
+ // ๐Ÿšจ Written UNCONDITIONALLY โ€” NEVER gated on a non-empty value; see
2159
+ // search() above for why (OPEN-6 / the MRU decision). Keystrokes WRITE
2160
+ // the bind and dispatch NOTHING: this is the table filter's cadence
2161
+ // exactly.
2162
+ //
2163
+ // ๐Ÿšจ There is no `setLabelShown(false)` here any more and NOTHING is
2164
+ // missing (D2a). The box was never showing a label, so typing cannot
2165
+ // change what it means: it is the query before the keystroke and the
2166
+ // query after it. The whole "typing drops the pill" dance existed only
2167
+ // because the input did double duty.
2168
+ this.writeBind(n.searchBind, inp.value);
2169
+ // ๐Ÿšจ ยง7 items 14 + 21 โ€” clear the active option whenever the query text
2170
+ // changes, and never let list-typeahead swallow typing. Typing is the
2171
+ // user's; the list does not get to eat it.
2172
+ setActive(-1);
2173
+ // ยง7 item 31 โ€” typing DISARMS the two-step Backspace. This is what
2174
+ // BOUNDS the armed window: an arm can never survive across an unrelated
2175
+ // edit and turn a later Backspace into a delete of something the user
2176
+ // was told about minutes ago.
2177
+ setArmed(null);
2178
+ // ๐Ÿšจ TYPING DOES NOT OPEN THE POPUP. THIS IS NOT A MISSING LINE (21-13).
2179
+ //
2180
+ // It used to: `if (optionEls.length > 0) setOpen(true)` lived here, a
2181
+ // leftover from the type-as-you-go model D4 reversed. Under Enter-to-
2182
+ // search it is actively WRONG, and the operator named the harm exactly:
2183
+ //
2184
+ // "it shouldn't pop up the box before I hit enter, because otherwise
2185
+ // it's just kind of throwing random possibilities at me."
2186
+ //
2187
+ // She is describing the popup volunteering the PREVIOUS query's answers
2188
+ // (or a server-supplied MRU list) against text she is still typing โ€”
2189
+ // candidates she never asked for, presented as though she had. Under a
2190
+ // typeahead the list tracked the keystrokes and that was the contract;
2191
+ // under Enter-to-search THE USER ASKS, and the answer arrives when they
2192
+ // ask. A popup that opens on its own is guessing.
2193
+ //
2194
+ // โ‡’ THE POPUP OPENS ON EXACTLY TWO EVENTS, AND NEITHER IS TYPING:
2195
+ // 1. RESULTS ARRIVE from a search the user ran (the `querying` branch
2196
+ // of the open-preservation block ~200 lines above โ€” Enter โ†’ dispatch
2197
+ // โ†’ the response's candidates render โ†’ open).
2198
+ // 2. Down / Alt+Down / Up on a CLOSED popup that HAS candidates (ยง7
2199
+ // item 15, in the keydown handler below) โ€” an EXPLICIT request for
2200
+ // the list, which is the opposite of a guess.
2201
+ //
2202
+ // Focus does not open it either, and there is deliberately no focus
2203
+ // listener in this arm โ€” same reason.
2204
+ //
2205
+ // Options already on screen are the previous query's answer: if the
2206
+ // popup is ALREADY open the user asked for them, so they stay visible
2207
+ // and pickable (nothing here closes it). This is NOT a new search
2208
+ // session either way, so `querying` is left alone: only search() sets it.
2209
+ // ยง7 item 13 โ€” the assistive hint has done its job the moment the user
2210
+ // starts typing; from here on it would be a per-keystroke tax read out
2211
+ // on every visit to the field.
2212
+ live.hintShown = true;
2213
+ hintEl?.remove();
2214
+ });
2215
+ // โ”€โ”€ CLICK-OUTSIDE CLOSES THE POPUP (21-11) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
2216
+ //
2217
+ // The APG does NOT specify this, which is exactly why ยง7 missed it and why
2218
+ // the operator found it by hand: every real combobox has it, and a popup
2219
+ // that only Escape can dismiss feels broken to a mouse user who has simply
2220
+ // moved on.
2221
+ //
2222
+ // ๐Ÿšจ IT CLOSES. IT DOES NOT CLEAR โ€” not the selection, not the query.
2223
+ // setOpen(false) drops the highlight and ends the search session and
2224
+ // touches NOTHING ELSE. Escape is the only thing that clears (see its
2225
+ // handler); silently discarding a reference because the user clicked
2226
+ // elsewhere would be exactly the unannounced data loss that stage one of
2227
+ // Escape's two-stage rule exists to prevent.
2228
+ //
2229
+ // `mousedown`, not `click`, and `wrapper.contains()` as the test โ€” both
2230
+ // load-bearing for picking a candidate:
2231
+ // โ€ข An option commits on MOUSEDOWN (it preventDefaults to keep DOM focus
2232
+ // in the input). A `click`-based close would fire on the same press;
2233
+ // mousedown ordering plus the containment test keeps the two apart.
2234
+ // โ€ข The option, the chips and their remove buttons all live INSIDE the
2235
+ // wrapper, so every in-widget press is excluded by containment rather
2236
+ // than by a pile of per-element special cases.
2237
+ // Registered on `document` (an outside click is by definition not on our
2238
+ // subtree) and swept per-render โ€” see lookupOutsideHandlers.
2239
+ const onOutsideMouseDown = (e) => {
2240
+ if (!open)
2241
+ return;
2242
+ const target = e.target;
2243
+ if (target != null && wrapper.contains(target))
2244
+ return;
2245
+ setOpen(false);
2246
+ };
2247
+ document.addEventListener("mousedown", onOutsideMouseDown);
2248
+ this.lookupOutsideHandlers.push(onOutsideMouseDown);
2249
+ inp.addEventListener("keydown", (e) => {
2250
+ const last = optionEls.length - 1;
2251
+ if (e.key === "ArrowDown") {
2252
+ e.preventDefault();
2253
+ if (!open) {
2254
+ setOpen(true);
2255
+ // ยง7 item 15 โ€” Alt+Down opens WITHOUT moving focus into the list.
2256
+ if (!e.altKey && optionEls.length > 0)
2257
+ setActive(0);
2258
+ }
2259
+ else if (optionEls.length > 0) {
2260
+ // ยง7 item 16 โ€” wrap: last โ†’ first.
2261
+ setActive(activeIndex >= last ? 0 : activeIndex + 1);
2262
+ }
2263
+ return;
2264
+ }
2265
+ if (e.key === "ArrowUp") {
2266
+ e.preventDefault();
2267
+ if (!open) {
2268
+ // ยง7 item 15 โ€” Up opens and focuses the LAST option.
2269
+ setOpen(true);
2270
+ if (optionEls.length > 0)
2271
+ setActive(last);
2272
+ }
2273
+ else if (optionEls.length > 0) {
2274
+ // ยง7 item 16 โ€” wrap: first โ†’ last.
2275
+ setActive(activeIndex <= 0 ? last : activeIndex - 1);
2276
+ }
2277
+ return;
2278
+ }
2279
+ if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
2280
+ // ยง7 item 16 โ€” Left/Right RETURN TO THE INPUT TEXT and move the
2281
+ // caret: they EXIT the list, they do NOT navigate it. Deliberately
2282
+ // NOT preventDefault'd โ€” the browser's own caret movement is the
2283
+ // entire point, and the APG's own warning is to "avoid JavaScript
2284
+ // interference with browser-provided editing functions". We only drop
2285
+ // the highlight.
2286
+ setActive(-1);
2287
+ return;
2288
+ }
2289
+ if (e.key === "Home" || e.key === "End") {
2290
+ // ๐Ÿšจ ยง7 item 16 โ€” Home/End are TEXT-EDITING keys here (caret to
2291
+ // start/end of the text), NOT first/last-option. This is an EDITABLE
2292
+ // combobox: the caret wins. Wiring these to the list is the single
2293
+ // most likely well-meaning break in this handler โ€” a combobox with a
2294
+ // listbox popup is not a listbox, and the user is typing in it.
2295
+ // So: no preventDefault, and no active-option change. The browser
2296
+ // edits, we stay out of the way. This early return exists ONLY to say
2297
+ // so; deleting it changes nothing today and invites the "fix"
2298
+ // tomorrow.
2299
+ return;
2300
+ }
2301
+ if (e.key === "Enter") {
2302
+ if (open && activeIndex >= 0) {
2303
+ // ยง7 item 17 โ€” accept, set the input value, close, keep focus in
2304
+ // the input (all of which commit() does).
2305
+ e.preventDefault();
2306
+ commit(activeIndex);
2307
+ return;
2308
+ }
2309
+ // โ”€โ”€ ENTER'S PRECEDENCE (21-12, D15) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
2310
+ //
2311
+ // โš ๏ธ Enter carries a dispatch because of the D4 reversal, and the
2312
+ // table filter this copies has exactly ONE Enter act. D15 restores
2313
+ // that precondition rather than out-clevering it: `allowCustom` +
2314
+ // `searchAction` together is UNSUPPORTED and fails loud at render
2315
+ // (see [vms:lookup-ambiguous-enter] above). With that combo excluded,
2316
+ // NO TWO ARMS BELOW CAN BOTH APPLY โ€” the order is a formality, not a
2317
+ // tie-break, and Enter means exactly one thing in every SUPPORTED
2318
+ // shape:
2319
+ //
2320
+ // 1. an active option โ†’ commit it (ยง7 item 17; either shape)
2321
+ // 2. searchAction โ†’ ask the server (D4 โ€” a directory picker)
2322
+ // 3. allowCustom โ†’ invent it (D3 โ€” a tags field)
2323
+ // 4. action โ†’ the field's own act
2324
+ //
2325
+ // ๐Ÿšฉ 2 BEFORE 3 IS THE DEGRADE PATH, NOT A PRECEDENCE. It only ever
2326
+ // fires for the unsupported combo, where it makes the field read as
2327
+ // the directory picker the warning names. Do NOT reintroduce a
2328
+ // heuristic that guesses the act from what the user typed โ€” D3's whole
2329
+ // point is that the act is DECLARED, never inferred. The deferred
2330
+ // answer is already known (react-select's synthetic "Create 'urgent'"
2331
+ // candidate, so Enter always means "accept the active option"); see
2332
+ // D15 before building it.
2333
+ //
2334
+ // ๐Ÿšจ THE SEARCH. Fires with NO option active โ€” including on an EMPTY
2335
+ // box, which is the MRU path (OPEN-6). See search().
2336
+ if (n.searchAction) {
2337
+ e.preventDefault();
2338
+ search();
2339
+ return;
2340
+ }
2341
+ if (n.allowCustom === true && inp.value.trim() !== "") {
2342
+ e.preventDefault();
2343
+ commitCustom(inp.value);
2344
+ return;
2345
+ }
2346
+ // No option active, nothing to search, nothing to invent: fall through
2347
+ // to the field's own `action`.
2348
+ //
2349
+ // โš ๏ธ KNOWN, DELIBERATE LIMITATION (21-12) โ€” `action` IS UNREACHABLE ON
2350
+ // A LOOKUP THAT DECLARES `searchAction`, and that is correct, not a
2351
+ // bug. Enter is this control's ONLY dispatch key, `searchAction` owns
2352
+ // it, and there is no second Enter to hand `action`. This is NOT the
2353
+ // D15 ambiguity (two acts fighting over one key); it is one act
2354
+ // OCCUPYING the key, which is what declaring a search means. Fixing it
2355
+ // would require inventing a second submit gesture โ€” a new key binding
2356
+ // no APG pattern sanctions โ€” so the honest answer is: on a searching
2357
+ // lookup, put the submit on a ButtonNode. Documented on the node's
2358
+ // TSDoc; do NOT "fix" it by re-ordering the arms above.
2359
+ if (n.action) {
2360
+ e.preventDefault();
2361
+ this.writeBind(n.searchBind, inp.value);
2362
+ on(n.action);
2363
+ }
2364
+ return;
2365
+ }
2366
+ if (e.key === "Escape") {
2367
+ if (open) {
2368
+ // ๐Ÿšจ ยง7 item 18 โ€” Escape is TWO-STAGE, and THIS stage is the
2369
+ // load-bearing half: with the popup OPEN it closes and KEEPS the
2370
+ // value. ESCAPE MUST NEVER CLEAR WHILE THE POPUP IS OPEN. The user
2371
+ // is dismissing the popup, not discarding their selection;
2372
+ // conflating the two silently destroys data on a keypress that
2373
+ // meant "get this out of my way".
2374
+ e.preventDefault();
2375
+ setOpen(false);
2376
+ return;
2377
+ }
2378
+ // Stage two โ€” the popup is ALREADY CLOSED. The design leaves clearing
2379
+ // OPTIONAL ("optionally clear"), and what it clears is THE QUERY TEXT,
2380
+ // in BOTH modes.
2381
+ //
2382
+ // ๐Ÿšจ IT NO LONGER CLEARS SINGLE-SELECT'S SELECTION, AND THAT IS D2a
2383
+ // FOLLOWED THROUGH RATHER THAN A DROPPED FEATURE. Escape used to clear
2384
+ // the bind on single, for a reason written down at the time: "this is
2385
+ // the ONLY keyboard path to un-set a single-select lookup โ€” deleting
2386
+ // the input text does NOT clear the selection, because the text is the
2387
+ // LABEL, a view of the id in `bind`." BOTH HALVES OF THAT PREMISE ARE
2388
+ // NOW FALSE. The text is not the label (it is the query, always), and
2389
+ // Escape is not the only keyboard path: single's selection is a chip
2390
+ // whose remove โœ• is a real, focusable <button> in the tab sequence with
2391
+ // an item-specific accessible name โ€” the same path multi has always
2392
+ // had, and a far more discoverable one than "hunt for Escape".
2393
+ //
2394
+ // Keeping the old behavior would also have required clearing the bind
2395
+ // here AND tearing the chip out of the DOM by hand โ€” a second removal
2396
+ // path beside removeChipAt(), which is exactly the fork D2a forbids.
2397
+ // So the two modes now agree: Escape gets the popup and the query out
2398
+ // of your way; the chip's โœ• removes the selection.
2399
+ e.preventDefault();
2400
+ inp.value = "";
2401
+ this.writeBind(n.searchBind, "");
2402
+ setActive(-1);
2403
+ return;
2404
+ }
2405
+ if (e.key === "Tab") {
2406
+ // ๐Ÿšจ ยง7 item 19 / OPEN-2 โ€” Tab CLOSES the popup and does NOT SELECT.
2407
+ // It abandons the active option; the field keeps whatever value it
2408
+ // already had.
2409
+ //
2410
+ // โš ๏ธ APG IS SILENT here โ€” its table only specifies where Tab GOES,
2411
+ // never what it does to the active option โ€” so this is our call, and
2412
+ // it is a RECORDED DECISION rather than an accident. The next person
2413
+ // WILL be asked "why doesn't Tab accept like my IDE?", so the answer
2414
+ // lives here:
2415
+ //
2416
+ // Tab is a NAVIGATION key, and a navigation key must never silently
2417
+ // commit a value. The failure modes are ASYMMETRIC. Tab-abandons
2418
+ // costs a user who wanted IDE/URL-bar accept semantics ONE EXTRA
2419
+ // KEYSTROKE (press Enter, then Tab) โ€” and they SEE that nothing was
2420
+ // selected. Tab-accepts silently writes a WRONG REFERENCE into a
2421
+ // record when someone tabs past a field mid-typing, and an accidental
2422
+ // commit is UNANNOUNCED DATA CORRUPTION โ€” invisible to sighted users
2423
+ // and doubly invisible to AT users. It also matches Escape's
2424
+ // keep-the-value semantics above, so the two "get me out of here"
2425
+ // keys behave consistently rather than one committing and one not.
2426
+ // This will generate complaints; that is accepted.
2427
+ //
2428
+ // NOT preventDefault'd โ€” focus must actually move on.
2429
+ setOpen(false);
2430
+ return;
2431
+ }
2432
+ // โ”€โ”€ ยง7 item 31 โ€” THE TWO-STEP, NON-DESTRUCTIVE BACKSPACE โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
2433
+ //
2434
+ // ๐Ÿšจ NO AUTHORITY ADDRESSES THIS. It is our convention, and it is a
2435
+ // RECORDED DECISION rather than an accident, because the obvious
2436
+ // implementation โ€” one Backspace, chip gone โ€” is what every other
2437
+ // library does and what the next person will "restore".
2438
+ //
2439
+ // Why two steps: a single-press delete is DESTRUCTIVE, INVISIBLE TO AT,
2440
+ // and TRIVIALLY MIS-TRIGGERED. The trigger is Backspace on an empty
2441
+ // input โ€” which is exactly the keystroke of someone who has just cleared
2442
+ // a typo and is still deleting. One press too many and a reference they
2443
+ // chose is silently gone, with no announcement and nothing on screen to
2444
+ // notice. The two-step makes the first press SAY what is about to
2445
+ // happen. It costs mouse users nothing (they have a remove button) and
2446
+ // costs keyboard users one keystroke.
2447
+ //
2448
+ // ยง7 item 22 โ€” Backspace/Delete are PLAIN TEXT EDITING everywhere else
2449
+ // and are never intercepted: this arm is gated on isMulti AND an EMPTY
2450
+ // input, so it can never eat a real edit.
2451
+ if (isMulti && inp.value === "" && (e.key === "Backspace" || e.key === "Delete")) {
2452
+ if (chipButtons.length === 0)
2453
+ return;
2454
+ const lastIdx = chipButtons.length - 1;
2455
+ if (armed != null) {
2456
+ // Step two โ€” confirmed. Either key confirms, because the
2457
+ // announcement promises both ("press Backspace or Delete").
2458
+ e.preventDefault();
2459
+ removeChipAt(chipValues.indexOf(armed));
2460
+ return;
2461
+ }
2462
+ // Step one โ€” Backspace ARMS. Delete does not: the design specifies
2463
+ // Backspace-on-empty as the entry point, and a bare Delete on an empty
2464
+ // input is not a "remove the last chip" gesture anyone has asked for.
2465
+ if (e.key === "Backspace") {
2466
+ e.preventDefault();
2467
+ setArmed(chipValues[lastIdx]);
2468
+ // ๐Ÿšจ The highlight is a VISUAL fact, so it is ALSO spoken (ยง7 item
2469
+ // 32). An arm that only added a CSS class would leave an AT user
2470
+ // pressing Backspace, hearing nothing, pressing again, and deleting
2471
+ // something they were never told about โ€” the two-step would become a
2472
+ // single-press delete FOR EXACTLY THE USERS IT EXISTS TO PROTECT.
2473
+ announce(`${chipLabels[lastIdx]}, press Backspace or Delete to remove`);
2474
+ }
2475
+ return;
2476
+ }
2477
+ // Any other key is the user moving on โ€” disarm, so a stale arm can never
2478
+ // turn a later stray Backspace into a delete. (Typing disarms via the
2479
+ // input listener; this covers navigation keys that fire no input event.)
2480
+ if (armed != null && e.key !== "Backspace" && e.key !== "Delete")
2481
+ setArmed(null);
2482
+ // ยง7 item 20 โ€” PageUp/PageDown are NOT part of the listbox-popup
2483
+ // contract. Do not invent them; they fall through untouched.
2484
+ });
2485
+ // The chip group sits OUTSIDE the combobox, BEFORE the input โ€” in BOTH
2486
+ // modes now (D2a). Outside is not cosmetic: a listbox popup owning
2487
+ // interactive chips would be the ยง7 item 24 violation. Before the input is
2488
+ // what gives the user somewhere to type โ€” the failure that produced D2a was
2489
+ // a control whose selection occupied the only place there was to click.
2490
+ //
2491
+ // (SLDS renders single's selection INSIDE the input and ships no pill
2492
+ // element for single at all. We diverge deliberately; see the deleted-pill
2493
+ // note above and D2a.)
2494
+ if (chipList)
2495
+ wrapper.appendChild(chipList);
2496
+ wrapper.appendChild(inp);
2497
+ wrapper.appendChild(popup);
2498
+ if (hintEl)
2499
+ wrapper.appendChild(hintEl);
2500
+ // ๐Ÿšจ RE-APPEND, never rebuild. These two nodes were detached by render()'s
2501
+ // innerHTML wipe, NOT destroyed โ€” the same move the chart makes with its
2502
+ // canvas. Creating fresh ones here would look identical in the DOM and
2503
+ // announce nothing, forever.
2504
+ wrapper.appendChild(live.a);
2505
+ wrapper.appendChild(live.b);
2506
+ }
1056
2507
  else if (n.inputType === "file") {
1057
2508
  const inp = document.createElement("input");
1058
2509
  inp.type = "file";
@@ -1193,6 +2644,72 @@ export class BrowserAdapter {
1193
2644
  * render help + error text below it, wiring aria-describedby / aria-invalid.
1194
2645
  * Runs on the main field path (the hidden + checkbox-FieldNode variants
1195
2646
  * return before this). */
2647
+ /**
2648
+ * The lookup's two aria-live status regions for `key`, created ONCE and
2649
+ * reused forever after (ยง7 items 8 + 12).
2650
+ *
2651
+ * ๐Ÿšจ This is the `chartInstances` idiom, not a new mechanism: the nodes are
2652
+ * DETACHED by render()'s innerHTML wipe, NOT destroyed, and field() re-appends
2653
+ * these same objects on every render. That is the entire point โ€” a screen
2654
+ * reader's registration is held against the OBJECT, so a structurally
2655
+ * identical replacement is a region it has never heard of, and announcements
2656
+ * stop silently while the DOM still looks perfect.
2657
+ *
2658
+ * Created EMPTY, before any results exist (ยง7 item 8): creating an element and
2659
+ * injecting its text in the same tick announces NOTHING, because there was no
2660
+ * registered element to observe a change on.
2661
+ */
2662
+ lookupLiveRegions(key) {
2663
+ const existing = this.liveRegions.get(key);
2664
+ if (existing)
2665
+ return existing;
2666
+ const make = (slot) => {
2667
+ const el = document.createElement("div");
2668
+ // ยง7 item 9 โ€” role="status" IS politeness=polite. Never assertive:
2669
+ // assertive interrupts the user's own typing echo, and is reserved for
2670
+ // errors (which arrive via decorateField's role="alert" region instead).
2671
+ el.setAttribute("role", "status");
2672
+ el.className = "vms-field__live";
2673
+ el.dataset.vmsLive = key;
2674
+ el.dataset.vmsLiveSlot = slot;
2675
+ el.textContent = "";
2676
+ return el;
2677
+ };
2678
+ const entry = { a: make("a"), b: make("b"), next: "a", hintShown: false };
2679
+ this.liveRegions.set(key, entry);
2680
+ return entry;
2681
+ }
2682
+ /**
2683
+ * Announce `message` in `key`'s live region, IMMEDIATELY (ยง7 items 11 + 12).
2684
+ *
2685
+ * ๐Ÿšจ NO DEBOUNCE, DELIBERATELY (21-11). This used to wait ~1400ms โ€” GOV.UK's
2686
+ * `statusDebounceMillis` โ€” and that timer had exactly one job: the lookup
2687
+ * searched on a ~300ms type-as-you-go cadence, so the region faced a
2688
+ * PER-KEYSTROKE FIREHOSE, and on Safari/VoiceOver "typing echo can otherwise
2689
+ * interrupt announcement of the aria live content". `searchAction` now fires on
2690
+ * ENTER: one Enter, one announcement. The firehose is gone, so the tamer goes
2691
+ * with it โ€” and keeping it would mean an AT user waits 1.4 seconds to hear the
2692
+ * answer to a question they explicitly asked, which is the opposite of the
2693
+ * item-11 goal ("an async combobox silent during the fetch leaves AT users
2694
+ * with no signal").
2695
+ *
2696
+ * โ‡’ Do not re-add a debounce here without first re-adding the cadence that
2697
+ * justified it. There isn't one.
2698
+ *
2699
+ * Alternates the two regions (ยง7 item 12) and clears the other, so identical
2700
+ * consecutive messages still register as a change and are re-announced โ€”
2701
+ * writing the same text into one region twice is not a change, and is silence.
2702
+ */
2703
+ announceLookup(key, message) {
2704
+ const entry = this.liveRegions.get(key);
2705
+ if (entry == null)
2706
+ return;
2707
+ const target = entry.next === "a" ? entry.a : entry.b;
2708
+ const other = entry.next === "a" ? entry.b : entry.a;
2709
+ other.textContent = "";
2710
+ target.textContent = message;
2711
+ entry.next = entry.next === "a" ? "b" : "a";
2712
+ }
1196
2713
  decorateField(wrapper, n) {
1197
2714
  const control = wrapper.querySelector(".vms-field__input");
1198
2715
  if (n.disabled) {
@@ -1217,7 +2734,15 @@ export class BrowserAdapter {
1217
2734
  (control instanceof HTMLInputElement || control instanceof HTMLTextAreaElement)) {
1218
2735
  control.maxLength = n.maxLength;
1219
2736
  }
2737
+ // Seed from any aria-describedby an inputType arm already wired (Phase 21:
2738
+ // the lookup's ยง7 item 13 assistive hint). The control is freshly created on
2739
+ // every render, so this cannot accumulate stale ids โ€” and without the seed,
2740
+ // the unconditional set below would silently CLOBBER the arm's hint the
2741
+ // moment the field also carried a `help` or an `error`.
1220
2742
  const describedBy = [];
2743
+ const preset = control?.getAttribute("aria-describedby");
2744
+ if (preset != null && preset !== "")
2745
+ describedBy.push(...preset.split(" "));
1221
2746
  if (n.help != null && n.help !== "") {
1222
2747
  const helpEl = document.createElement("div");
1223
2748
  helpEl.className = "vms-field__help";