@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/agent-skill.md +69 -0
- package/dist/browser.d.ts +42 -0
- package/dist/browser.js +1525 -0
- package/dist/index.d.ts +260 -4
- package/dist/server.js +6 -0
- package/dist/tui.js +41 -0
- package/package.json +1 -1
- package/styles/default.css +225 -0
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";
|