@ashley-shrok/viewmodel-shell 5.1.1 → 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 +241 -0
package/agent-skill.md
CHANGED
|
@@ -145,6 +145,75 @@ It never appears in the `_action` POST payload you send. The request body shape
|
|
|
145
145
|
|
|
146
146
|
This connects to the polling section above: the `{"name": "poll"}` dispatch this manual already documents is itself an instance of a non-blocking action — a poll always rides the non-blocking lane client-side — so nothing about how you send a poll dispatch changes either.
|
|
147
147
|
|
|
148
|
+
## Lookup / reference fields (`inputType:"lookup"`, `"lookup-multiple"`)
|
|
149
|
+
|
|
150
|
+
A `FieldNode` whose `inputType` is `lookup` or `lookup-multiple` is a reference to a row in some server-side set too large to enumerate into the tree (a 5,000-person directory, an 80,000-row customer table). A `select` says *"here are all the values, pick one"*; a lookup says *"the values are a database table — describe which row you mean."*
|
|
151
|
+
|
|
152
|
+
**The picker's search is an ordinary action on this same public wire.** No special API, no private transport, no undocumented endpoint — you drive a lookup with the exact dispatch shape documented above. This is worth stating plainly because it is not the norm: surveyed platforms keep their reference picker's transport private and undocumented. Here it is just the wire.
|
|
153
|
+
|
|
154
|
+
### The happy path: set `bind` to the id. You are done.
|
|
155
|
+
|
|
156
|
+
**`bind` is the ONLY authoritative thing.** For `lookup` it holds a `string` (one id); for `lookup-multiple` a `string[]` (the ids). To set a reference, write the id at the bound path in the state blob and dispatch — exactly as with any other input (see *The round-trip rule*).
|
|
157
|
+
|
|
158
|
+
🚨 **You do not need to know the label, and you must not supply one.** The label is not state. It travels on `selected`, **server→client ONLY**, is recomputed on every render, and is **never trusted coming back from the client** — a client-supplied label is meaningless. Direction is the whole safety argument: you cannot forge a label into a handler because you never send one. An agent that assumes `selected` round-trips will write labels into state and be silently wrong.
|
|
159
|
+
|
|
160
|
+
If you already know the id, **skip the search entirely** — set `bind` and dispatch. This is the common agent case, and it is the whole reason the model suits agents: knowing the id but not the label is not a wrinkle here, it is the normal way in.
|
|
161
|
+
|
|
162
|
+
### Reading one
|
|
163
|
+
|
|
164
|
+
```json
|
|
165
|
+
{ "type": "field", "name": "assignee", "inputType": "lookup",
|
|
166
|
+
"bind": "form.assigneeId",
|
|
167
|
+
"selected": [ { "value": "u-1", "label": "Sally Omer" } ],
|
|
168
|
+
"searchBind": "form.assigneeQuery",
|
|
169
|
+
"searchAction": { "name": "search-agents" },
|
|
170
|
+
"candidates": [
|
|
171
|
+
{ "value": "u-7", "label": "Sam Ortiz" },
|
|
172
|
+
{ "value": "u-1", "label": "Sally Omer" }
|
|
173
|
+
] }
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
| Field | Direction | Read it as |
|
|
177
|
+
|---|---|---|
|
|
178
|
+
| `bind` | **round-trips — STATE** | The id (`string`, or `string[]` for `lookup-multiple`). The authoritative value. |
|
|
179
|
+
| `selected` | **server→client — VIEW** | `Array<{ value, label?, type? }>` — the resolved label(s) for what is **currently chosen**. Always an array (0 or 1 entries for single `lookup`). **This is the only source of a selected label.** |
|
|
180
|
+
| `candidates` | **server→client — VIEW** | `Array<{ value, label?, type? }>` — the **current search results**, i.e. what the popup offers. **Never the source of a selected label.** |
|
|
181
|
+
| `searchBind` | **round-trips — STATE** | Where the typed query lives. Write your query here to search. |
|
|
182
|
+
| `searchAction` | — | The action to dispatch to run a search. |
|
|
183
|
+
| `allowCustom` | — | See below. Omitted = false. |
|
|
184
|
+
|
|
185
|
+
**Never conflate `selected` and `candidates`.** They are separate fields on purpose. Resolving a label out of `candidates` is the classic failure: with an id-valued field, *"filter the candidate list"* and *"forget what's selected"* are the same operation, so a form that loads with a value already set and no search having occurred has an empty `candidates` and would render a raw database id. Read the label from `selected` and only from `selected`. On an item, `label` is **omitted when it equals `value`** (an option not set is simply absent) — so a missing `label` means the label *is* the id, not that it's unknown. `type` is the polymorphic-reference tag (an id alone is not an identity); it is omitted for monomorphic references.
|
|
186
|
+
|
|
187
|
+
### 🚨 Candidate order is meaningful. It is preserved exactly.
|
|
188
|
+
|
|
189
|
+
The renderer presents `candidates` in the order given — it **sorts nothing, dedupes nothing, truncates nothing**. This cuts both ways, and you need both directions:
|
|
190
|
+
|
|
191
|
+
- **Reading a lookup:** the order **IS the server's ranking** (relevance / recency / most-recently-used). **Position 0 is the server's best answer.** Do not re-sort before choosing — re-sorting discards the server's judgment. Real adopters rank by things you cannot reconstruct client-side (e.g. recency-weighted mention frequency, computed server-side per operator).
|
|
192
|
+
- **Authoring a provider handler:** the order **you return is the order the user sees.** Ranking is yours to decide and yours to get right; nothing downstream will fix it up for you.
|
|
193
|
+
|
|
194
|
+
### Searching (optional — it is just an action)
|
|
195
|
+
|
|
196
|
+
Write your query into the state blob at `searchBind`, then POST `searchAction`'s `name` with that state, exactly like any other dispatch. Read `candidates` off the returned `vm`, pick the item you want, then write its `value` into `bind` and dispatch your real action.
|
|
197
|
+
|
|
198
|
+
```json
|
|
199
|
+
{ "name": "search-agents", "state": { "form": { "assigneeQuery": "sal" } } }
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
- **An empty query is a legitimate query.** There is no minimum-length gate. An empty query may legitimately return most-recently-used candidates rather than nothing.
|
|
203
|
+
- **`searchAction` is an ordinary action, with no special cadence.** A browser user fires it by pressing **Enter** in the field (typing alone dispatches nothing — there is no debounce and no live-query behavior). For you this changes nothing: it is one POST like any other, whenever you decide to ask.
|
|
204
|
+
- **`blocking` on a `searchAction` means what it means everywhere else** — see *Non-blocking actions* above. It is a client-side hint that never rides the POST payload, so as a wire-driving agent you dispatch normally regardless of its value.
|
|
205
|
+
|
|
206
|
+
### `allowCustom` — invented values are declared, never inferred
|
|
207
|
+
|
|
208
|
+
`allowCustom: true` means the field accepts a value that isn't one of the offered candidates; omitted/`false` means only offered candidates commit. Choosing an existing record and inventing a new tag are different acts, so the control **declares** which it is doing rather than leaving you to infer it from behavior.
|
|
209
|
+
|
|
210
|
+
An invented value is **just a value whose label equals itself** — there is no bare string and no union anywhere. `allowCustom: true` + no `candidates` + labels omitted **is** a free-form tags input, with no special case. So the shapes above are all you ever have to parse.
|
|
211
|
+
|
|
212
|
+
### Two rules that will bite you if you assume otherwise
|
|
213
|
+
|
|
214
|
+
- 🚨 **Any cap is VISIBLE in the tree. Nothing truncates silently.** If a server caps its results, it says so in the tree (the app renders a `TextNode`, per the canonical *"Refine your filter — N matches, max is X"* pattern). You should never be handed a silently truncated list. The anti-pattern this exists to avoid is real: a surveyed platform applies a 15-result cap *post-authorization* behind a hard 250-row SQL ceiling, so in a large table an exact-match record can be **silently invisible**. If you see a cap message, narrow the query — do not conclude the record doesn't exist.
|
|
215
|
+
- 🚨 **The picker's filter is UX, never authorization.** What `candidates` offers is a search-scoping convenience, **not an access boundary** — do not infer authorization from it. The server authorizes in the action handler with the real auth context, exactly as every other VMS action does. A value absent from `candidates` is not thereby forbidden, and a value present in `candidates` is not thereby permitted.
|
|
216
|
+
|
|
148
217
|
## Files
|
|
149
218
|
|
|
150
219
|
File uploads use the multipart form above. One form entry per file input, keyed by the input's `name` attribute (from the corresponding node's `name` field in the tree). The file's binary content is the entry's value. JSON-body dispatch cannot carry files; use multipart.
|
package/dist/browser.d.ts
CHANGED
|
@@ -8,8 +8,12 @@ export declare class BrowserAdapter implements Adapter {
|
|
|
8
8
|
private sectionKeyCounter;
|
|
9
9
|
private fitsObservers;
|
|
10
10
|
private chartInstances;
|
|
11
|
+
private liveRegions;
|
|
12
|
+
private lookupOutsideHandlers;
|
|
13
|
+
private lookupOpenSnapshot;
|
|
11
14
|
private chartKeyCounter;
|
|
12
15
|
private chartKeysSeen;
|
|
16
|
+
private lookupKeysSeen;
|
|
13
17
|
constructor(container: HTMLElement);
|
|
14
18
|
render(vm: ViewNode, onAction: (action: ActionEvent) => void, stateAccess?: StateAccess): void;
|
|
15
19
|
navigate(url: string): void;
|
|
@@ -132,6 +136,44 @@ export declare class BrowserAdapter implements Adapter {
|
|
|
132
136
|
* render help + error text below it, wiring aria-describedby / aria-invalid.
|
|
133
137
|
* Runs on the main field path (the hidden + checkbox-FieldNode variants
|
|
134
138
|
* return before this). */
|
|
139
|
+
/**
|
|
140
|
+
* The lookup's two aria-live status regions for `key`, created ONCE and
|
|
141
|
+
* reused forever after (§7 items 8 + 12).
|
|
142
|
+
*
|
|
143
|
+
* 🚨 This is the `chartInstances` idiom, not a new mechanism: the nodes are
|
|
144
|
+
* DETACHED by render()'s innerHTML wipe, NOT destroyed, and field() re-appends
|
|
145
|
+
* these same objects on every render. That is the entire point — a screen
|
|
146
|
+
* reader's registration is held against the OBJECT, so a structurally
|
|
147
|
+
* identical replacement is a region it has never heard of, and announcements
|
|
148
|
+
* stop silently while the DOM still looks perfect.
|
|
149
|
+
*
|
|
150
|
+
* Created EMPTY, before any results exist (§7 item 8): creating an element and
|
|
151
|
+
* injecting its text in the same tick announces NOTHING, because there was no
|
|
152
|
+
* registered element to observe a change on.
|
|
153
|
+
*/
|
|
154
|
+
private lookupLiveRegions;
|
|
155
|
+
/**
|
|
156
|
+
* Announce `message` in `key`'s live region, IMMEDIATELY (§7 items 11 + 12).
|
|
157
|
+
*
|
|
158
|
+
* 🚨 NO DEBOUNCE, DELIBERATELY (21-11). This used to wait ~1400ms — GOV.UK's
|
|
159
|
+
* `statusDebounceMillis` — and that timer had exactly one job: the lookup
|
|
160
|
+
* searched on a ~300ms type-as-you-go cadence, so the region faced a
|
|
161
|
+
* PER-KEYSTROKE FIREHOSE, and on Safari/VoiceOver "typing echo can otherwise
|
|
162
|
+
* interrupt announcement of the aria live content". `searchAction` now fires on
|
|
163
|
+
* ENTER: one Enter, one announcement. The firehose is gone, so the tamer goes
|
|
164
|
+
* with it — and keeping it would mean an AT user waits 1.4 seconds to hear the
|
|
165
|
+
* answer to a question they explicitly asked, which is the opposite of the
|
|
166
|
+
* item-11 goal ("an async combobox silent during the fetch leaves AT users
|
|
167
|
+
* with no signal").
|
|
168
|
+
*
|
|
169
|
+
* ⇒ Do not re-add a debounce here without first re-adding the cadence that
|
|
170
|
+
* justified it. There isn't one.
|
|
171
|
+
*
|
|
172
|
+
* Alternates the two regions (§7 item 12) and clears the other, so identical
|
|
173
|
+
* consecutive messages still register as a change and are re-announced —
|
|
174
|
+
* writing the same text into one region twice is not a change, and is silence.
|
|
175
|
+
*/
|
|
176
|
+
private announceLookup;
|
|
135
177
|
private decorateField;
|
|
136
178
|
/** CheckboxNode (standalone, immediate-dispatch) — bound boolean; on toggle,
|
|
137
179
|
* write to state then dispatch the action name (if any). */
|