@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/index.d.ts
CHANGED
|
@@ -281,18 +281,62 @@ export interface FormNode {
|
|
|
281
281
|
submitOnEnter?: boolean;
|
|
282
282
|
children: ViewNode[];
|
|
283
283
|
}
|
|
284
|
+
/**
|
|
285
|
+
* Phase 21 (LOOK-01) — one reference in a `lookup` / `lookup-multiple` field:
|
|
286
|
+
* an id, optionally the label to show for it, optionally what KIND of thing it
|
|
287
|
+
* is. Used by BOTH `FieldNode.selected` (what is currently chosen) and
|
|
288
|
+
* `FieldNode.candidates` (the current search results) — the same shape in both
|
|
289
|
+
* places, which is what lets a picked value and an invented one stay
|
|
290
|
+
* homogeneous (see `allowCustom`).
|
|
291
|
+
*
|
|
292
|
+
* `value` is the id and the only thing that ever round-trips. It is typed as a
|
|
293
|
+
* `string` deliberately — not `string | number` — so the wire stays
|
|
294
|
+
* byte-identical across backends (the same rationale as `min`/`max`/`step`
|
|
295
|
+
* above): a numeric id serialized by System.Text.Json and by `JSON.stringify`
|
|
296
|
+
* must produce the same bytes, and a union invites exactly that drift.
|
|
297
|
+
*
|
|
298
|
+
* `label` is OPTIONAL and MUST be **omitted when it equals `value`** — an
|
|
299
|
+
* option not set is simply absent, and a label that merely repeats the id
|
|
300
|
+
* carries no information. This is exactly the free-form-tag case: a tag is a
|
|
301
|
+
* value whose label is itself, so the label is absent. (Salesforce models the
|
|
302
|
+
* same rule: `displayValue` is null for a plain string field and populated
|
|
303
|
+
* only when it carries something `value` doesn't — a localization, a format,
|
|
304
|
+
* or a related record's name.)
|
|
305
|
+
*
|
|
306
|
+
* `type` is the polymorphic-reference tag, and it exists because an id alone is
|
|
307
|
+
* not an identity. Microsoft, on Dataverse's `_ownerid_value`, verbatim: the
|
|
308
|
+
* GUID *"doesn't tell you whether the owner of the record is a user or a
|
|
309
|
+
* team."* Set it when one field can reference more than one kind of record;
|
|
310
|
+
* **omit it for monomorphic references**, where it would say nothing the field
|
|
311
|
+
* doesn't already say.
|
|
312
|
+
*/
|
|
313
|
+
export interface LookupItem {
|
|
314
|
+
/** The id. The only half that round-trips. */
|
|
315
|
+
value: string;
|
|
316
|
+
/** Display text for `value`. Omitted = the label equals the value. */
|
|
317
|
+
label?: string;
|
|
318
|
+
/** What KIND of record `value` names. Omitted = a monomorphic reference. */
|
|
319
|
+
type?: string;
|
|
320
|
+
}
|
|
284
321
|
export interface FieldNode {
|
|
285
322
|
type: "field";
|
|
286
323
|
name: string;
|
|
287
|
-
inputType: "text" | "email" | "password" | "number" | "date" | "time" | "datetime-local" | "textarea" | "hidden" | "file" | "select" | "select-multiple" | "checkbox" | "code";
|
|
324
|
+
inputType: "text" | "email" | "password" | "number" | "date" | "time" | "datetime-local" | "textarea" | "hidden" | "file" | "select" | "select-multiple" | "checkbox" | "lookup" | "lookup-multiple" | "code";
|
|
288
325
|
/** Path into state where this input reads its current value and writes user
|
|
289
326
|
* changes (e.g. `fields.title`). REQUIRED for value-bearing inputs
|
|
290
327
|
* (text/email/password/number/date/time/datetime-local/textarea/select/
|
|
291
|
-
* select-multiple/checkbox/code) and OPTIONAL for
|
|
328
|
+
* select-multiple/checkbox/lookup/lookup-multiple/code) and OPTIONAL for
|
|
329
|
+
* `file` inputs — a file
|
|
292
330
|
* input's binary rides the multipart side channel (fileRegistry keyed on
|
|
293
331
|
* `name`), so omit `bind` on a file input to avoid writing a
|
|
294
332
|
* `{filename,size}` placeholder object into state (which breaks a
|
|
295
|
-
* string/string-map state slot on round-trip).
|
|
333
|
+
* string/string-map state slot on round-trip).
|
|
334
|
+
*
|
|
335
|
+
* For the lookup inputTypes this path holds the ID and NOTHING ELSE:
|
|
336
|
+
* `lookup` binds a `string` (one id), `lookup-multiple` binds a `string[]`
|
|
337
|
+
* (the ids). The human-readable label never lives here — it travels on
|
|
338
|
+
* `selected`, server→client only (see the `selected` doc). The id is state;
|
|
339
|
+
* the label is view. */
|
|
296
340
|
bind?: string;
|
|
297
341
|
label?: string;
|
|
298
342
|
placeholder?: string;
|
|
@@ -310,7 +354,27 @@ export interface FieldNode {
|
|
|
310
354
|
* `.vms-field--error`, the control's `aria-invalid="true"`, and wires the
|
|
311
355
|
* message into `aria-describedby`. The view-side complement to the
|
|
312
356
|
* response-level `rejected` channel — set it on the offending field when you
|
|
313
|
-
* build the tree. Omitted = no error shown.
|
|
357
|
+
* build the tree. Omitted = no error shown.
|
|
358
|
+
*
|
|
359
|
+
* **Lookup note (5.2.0, OPEN-5): a SEARCH failure reuses this slot** — if the
|
|
360
|
+
* directory query behind a `lookup` / `lookup-multiple` fails, put the message
|
|
361
|
+
* here. Do NOT swallow it: react-select actively discards fetch errors
|
|
362
|
+
* (`loader.then(callback, () => callback())`), which makes a dead backend
|
|
363
|
+
* indistinguishable from "no results" — a direct violation of principle 8
|
|
364
|
+
* (nothing important fails quietly), and no surveyed library has a
|
|
365
|
+
* search-error state at all. Reusing `error` closes that gap at zero wire
|
|
366
|
+
* cost, and §7 item 9 reserves the assertive channel for errors, so a genuine
|
|
367
|
+
* search failure is a correct fit for this slot's `role="alert"`.
|
|
368
|
+
*
|
|
369
|
+
* ⚠️ **The known, accepted wart:** this overloads the slot. "The server is
|
|
370
|
+
* down" is NOT "your input is invalid", so the `aria-invalid="true"` it sets
|
|
371
|
+
* on the combobox is semantically wrong for a search failure; and one slot
|
|
372
|
+
* with two meanings COLLIDES if a field has a live search failure and a
|
|
373
|
+
* pending validation error at once (last writer wins). Accepted for v1: the
|
|
374
|
+
* app owns which message it puts here, the collision needs both conditions
|
|
375
|
+
* simultaneously on the same field, and a distinct search-error slot is
|
|
376
|
+
* purely additive later. Recorded rather than hidden, so a future phase can
|
|
377
|
+
* promote it with the reasoning already written down. */
|
|
314
378
|
error?: string;
|
|
315
379
|
/** Hint/help text rendered below the control as `.vms-field__help` and wired
|
|
316
380
|
* into the control's `aria-describedby`. Omitted = no hint. */
|
|
@@ -330,6 +394,198 @@ export interface FieldNode {
|
|
|
330
394
|
value: string;
|
|
331
395
|
label: string;
|
|
332
396
|
}>;
|
|
397
|
+
/**
|
|
398
|
+
* LOOKUP INPUTS ONLY (Phase 21, LOOK-01). What is currently chosen, resolved
|
|
399
|
+
* to display text by the server.
|
|
400
|
+
*
|
|
401
|
+
* 🚨 **THE LOAD-BEARING INVARIANT: `selected` is VIEW, not STATE.** It travels
|
|
402
|
+
* **server→client ONLY**, is recomputed on every render, and is **NEVER
|
|
403
|
+
* authoritative and NEVER trusted coming back from the client.** `bind` holds
|
|
404
|
+
* the id and is the only authoritative thing. **Direction is the entire safety
|
|
405
|
+
* argument** — a client cannot forge a label into a handler because a client
|
|
406
|
+
* never sends one. Why, from our own principles: the view is a pure function
|
|
407
|
+
* of state; the id persists and round-trips (state), while the label is
|
|
408
|
+
* derived, server-owned, and recomputed every render (view). Putting the label
|
|
409
|
+
* in the bind is putting view into state, and it manufactures a
|
|
410
|
+
* cache-invalidation problem that every mature enterprise reference field
|
|
411
|
+
* designed away by storing the id alone — rename a user and every label on
|
|
412
|
+
* every referencing record changes with zero writes.
|
|
413
|
+
*
|
|
414
|
+
* 🚨 **`selected` and `candidates` are SEPARATE FIELDS ON PURPOSE, and the
|
|
415
|
+
* selected label is NEVER resolved from `candidates`.** Fusing them is the
|
|
416
|
+
* original sin. With an id-valued field, *"filter the candidate list"* and
|
|
417
|
+
* *"forget what's selected"* are **the same operation** — so a picker that
|
|
418
|
+
* resolves its label out of the candidate list renders a raw database id the
|
|
419
|
+
* moment a form loads with a value already set and no search has occurred
|
|
420
|
+
* (the cold-start case, which is the case that matters most). Ant Design ships
|
|
421
|
+
* this failure silently (`label: ... ?? item.value`); Zag chased it across
|
|
422
|
+
* four changelog entries and two years. Read the label from `selected` and
|
|
423
|
+
* only from `selected`.
|
|
424
|
+
*
|
|
425
|
+
* **Always an array**, including for single `lookup`, where it holds 0 or 1
|
|
426
|
+
* entries. This is deliberate and there is no precedent for it elsewhere in
|
|
427
|
+
* this file, so the reasoning lives here: a `T | T[]` union drifts across
|
|
428
|
+
* backends (it does not serialize byte-identically under both
|
|
429
|
+
* System.Text.Json and `JSON.stringify`), and the banked parity lesson is to
|
|
430
|
+
* prefer the shape that cannot drift over the shape that reads nicer.
|
|
431
|
+
*
|
|
432
|
+
* `selected[].value` duplicating `bind` is **accepted, deliberate
|
|
433
|
+
* redundancy** — keying by value is robust; positional parallel arrays are
|
|
434
|
+
* not.
|
|
435
|
+
*
|
|
436
|
+
* Omitted = nothing currently selected.
|
|
437
|
+
*/
|
|
438
|
+
selected?: LookupItem[];
|
|
439
|
+
/**
|
|
440
|
+
* LOOKUP INPUTS ONLY (Phase 21, LOOK-01). The current search results — what
|
|
441
|
+
* the popup listbox offers. Feeds the popup and **nothing else**. **NEVER the
|
|
442
|
+
* source of a selected label** (see `selected`).
|
|
443
|
+
*
|
|
444
|
+
* 🚨 **ORDER IS MEANINGFUL APP DATA. The renderer presents candidates AS
|
|
445
|
+
* GIVEN — it sorts nothing, dedupes nothing, and truncates nothing.** The app
|
|
446
|
+
* decides what comes back and in what order; **relevance ordering is the
|
|
447
|
+
* SERVER's judgment, never the widget's.** This is universal in mature
|
|
448
|
+
* pickers: Salesforce's picker `searchType` **defaults to `Recent`**, and
|
|
449
|
+
* Dynamics shows the five most-recently-used rows plus five favourites,
|
|
450
|
+
* explicitly NOT filtered by the search term. A renderer that "helpfully"
|
|
451
|
+
* alphabetized for tidiness would **silently destroy a server-side ranking
|
|
452
|
+
* with no way for the app to stop it** — a real consumer sorts candidates by
|
|
453
|
+
* recency-weighted mention frequency in their own provider handler, and that
|
|
454
|
+
* ranking is the whole product. (Scope: this governs the PRESENTATION of
|
|
455
|
+
* `candidates`. It is not a ban on the renderer having logic — deduping
|
|
456
|
+
* `bind` on commit in `lookup-multiple` is a state write about the user's own
|
|
457
|
+
* accumulated selection, and is correct.)
|
|
458
|
+
*
|
|
459
|
+
* 🚨 **Any cap MUST be VISIBLE in the tree. Nothing truncates silently.**
|
|
460
|
+
* There is no wire field for a cap: the app renders a `TextNode` saying so —
|
|
461
|
+
* *"Refine your filter — N matches, max is X"*, the canonical table-workflow
|
|
462
|
+
* pattern. The anti-pattern is ServiceNow's 15-result cap applied post-ACL
|
|
463
|
+
* behind a hard 250-row SQL ceiling, where **an exact-match record can be
|
|
464
|
+
* silently invisible** in a large table. A cap the user cannot see is a
|
|
465
|
+
* correctness bug wearing a performance knob's clothes.
|
|
466
|
+
*
|
|
467
|
+
* 🚨 **The picker's filter is UX, NEVER authorization.** Narrowing what is
|
|
468
|
+
* *offered* is not a security boundary, and a filter that looks like one is
|
|
469
|
+
* precisely what gets trusted by mistake. ServiceNow says it outright: *"To
|
|
470
|
+
* restrict what data specific users can access, use ACLs not reference
|
|
471
|
+
* qualifiers."* Salesforce runs two separate layers for exactly this reason —
|
|
472
|
+
* a metadata `lookupFilter` enforced server-side on save, versus the
|
|
473
|
+
* component's UI-only `filter`. **The server authorizes in the action
|
|
474
|
+
* handler, with the real auth context, exactly as every other VMS action
|
|
475
|
+
* does.** Omitting a record from `candidates` hides it from the dropdown and
|
|
476
|
+
* from nothing else — a client that already knows an id can still put it in
|
|
477
|
+
* `bind`, so the handler is the only thing standing between a user and a
|
|
478
|
+
* record they may not touch.
|
|
479
|
+
*
|
|
480
|
+
* Omitted = no results to offer.
|
|
481
|
+
*/
|
|
482
|
+
candidates?: LookupItem[];
|
|
483
|
+
/**
|
|
484
|
+
* LOOKUP INPUTS ONLY (Phase 21, LOOK-04). Path into state where the typed
|
|
485
|
+
* query lives, so the server can see it and the view stays a pure function of
|
|
486
|
+
* state. Separate from `bind`, which holds the id — the query and the
|
|
487
|
+
* selection are different facts and never share a slot.
|
|
488
|
+
*
|
|
489
|
+
* Required for a working search: with a `searchAction` but no `searchBind`,
|
|
490
|
+
* the query is dispatched but the server can never read what was typed — a
|
|
491
|
+
* silently dead search that renders perfectly and returns nothing forever. The
|
|
492
|
+
* browser warns `[vms:lookup-no-searchbind]`.
|
|
493
|
+
*
|
|
494
|
+
* Keystrokes write here immediately (the query is state); **Enter** dispatches
|
|
495
|
+
* `searchAction`. That is the same cadence `TableNode.filterAction` uses.
|
|
496
|
+
*
|
|
497
|
+
* 🚨 The query is what the user TYPED. It is **not** the display text: an
|
|
498
|
+
* input showing the selected label (a form loaded with a reference already
|
|
499
|
+
* set) holds a label, not a query, and the renderer does not flush it here.
|
|
500
|
+
* Clearing the box clears the query and reveals the label again — clearing the
|
|
501
|
+
* SEARCH TEXT is not clearing the SELECTION (only `bind` holds that).
|
|
502
|
+
*
|
|
503
|
+
* Omitted = the query is not round-tripped.
|
|
504
|
+
*/
|
|
505
|
+
searchBind?: string;
|
|
506
|
+
/**
|
|
507
|
+
* LOOKUP INPUTS ONLY (Phase 21). Dispatched **on ENTER**, as an **ordinary
|
|
508
|
+
* action** — the same cadence `TableNode.filterAction` uses, and the same one
|
|
509
|
+
* `action` above uses. Keystrokes write `searchBind` and dispatch nothing;
|
|
510
|
+
* there is **no debounce** and **no live-query lane**.
|
|
511
|
+
*
|
|
512
|
+
* 🚨 **`blocking` means exactly what it means everywhere else, and the
|
|
513
|
+
* framework NEVER sets it.** Your `ActionEvent` is dispatched as you declared
|
|
514
|
+
* it — omit `blocking` (the default, blocking/serialized lane) unless you have
|
|
515
|
+
* a specific reason not to.
|
|
516
|
+
*
|
|
517
|
+
* **Leaving it blocking is the recommended default, and it is a correctness
|
|
518
|
+
* property, not a preference:** a blocking action is serialized by the shell's
|
|
519
|
+
* dispatch guard (a second action cannot dispatch while a round trip is in
|
|
520
|
+
* flight), so a stale search response can never land after — and clobber — a
|
|
521
|
+
* newer action. Opting into `blocking: false` means *this response may be
|
|
522
|
+
* discarded, may arrive out of order, and may coexist with another in flight*;
|
|
523
|
+
* that is yours to choose, and yours to handle.
|
|
524
|
+
*
|
|
525
|
+
* 🚨 **Do NOT combine with {@link FieldNode.allowCustom} — it is UNSUPPORTED
|
|
526
|
+
* in v1 and warns `[vms:lookup-ambiguous-enter]`.** One Enter cannot both
|
|
527
|
+
* invent a value and run a search. The two supported shapes are: this field
|
|
528
|
+
* WITHOUT `allowCustom` (a directory/reference picker — Enter searches,
|
|
529
|
+
* arrow+Enter accepts a candidate), or `allowCustom` WITHOUT `searchAction`
|
|
530
|
+
* (a free-form tags field — Enter invents). Declaring both is ignored in
|
|
531
|
+
* favour of the search, loudly.
|
|
532
|
+
*
|
|
533
|
+
* ⚠️ **`searchAction` OCCUPIES Enter, so {@link FieldNode.action} is
|
|
534
|
+
* unreachable on a lookup that declares one.** This is a deliberate
|
|
535
|
+
* limitation, not a bug: Enter is this control's only dispatch key and the
|
|
536
|
+
* search owns it — there is no second Enter to give `action`, and inventing a
|
|
537
|
+
* second submit gesture is a keybinding no combobox pattern sanctions. **On a
|
|
538
|
+
* searching lookup, put the submit on a `ButtonNode`.**
|
|
539
|
+
*
|
|
540
|
+
* **There is NO minimum-character gate**, deliberately. **An EMPTY query is a
|
|
541
|
+
* legitimate query and IS dispatched**, so an app may answer it with
|
|
542
|
+
* most-recently-used candidates rather than nothing (Salesforce's picker
|
|
543
|
+
* `searchType` defaults to `Recent`).
|
|
544
|
+
*
|
|
545
|
+
* Omitted = no search; the field is a plain id input.
|
|
546
|
+
*/
|
|
547
|
+
searchAction?: ActionEvent;
|
|
548
|
+
/**
|
|
549
|
+
* LOOKUP INPUTS ONLY (Phase 21, LOOK-06). The **declared** custom-entry axis:
|
|
550
|
+
* may the user commit a value that isn't one of the offered candidates?
|
|
551
|
+
*
|
|
552
|
+
* **Never inferred from behavior.** *Choosing somebody to mention* is very
|
|
553
|
+
* different from *inventing a new tag* — different ACTS sharing one widget —
|
|
554
|
+
* so the control DECLARES which it is doing rather than leaving it to be
|
|
555
|
+
* guessed from what the user typed.
|
|
556
|
+
*
|
|
557
|
+
* An invented value stays a homogeneous {@link LookupItem}, **never a bare
|
|
558
|
+
* string**, so no `LookupItem | string` union ever arises. MUI's `multiple +
|
|
559
|
+
* freeSolo` yields exactly that heterogeneous union — forcing every consumer
|
|
560
|
+
* to branch on `typeof`, and their own docs warn it *"may cause type
|
|
561
|
+
* mismatch."* We never admit a bare string, so it cannot happen here. A
|
|
562
|
+
* free-form tag is simply a value whose label equals itself (and is therefore
|
|
563
|
+
* omitted — see {@link LookupItem}).
|
|
564
|
+
*
|
|
565
|
+
* ⇒ `allowCustom: true` + no `candidates` + labels omitted **is a free-form
|
|
566
|
+
* tags input, with NO special case in the renderer.** This supersedes the
|
|
567
|
+
* separately-designed `inputType: "tags"` proposal.
|
|
568
|
+
*
|
|
569
|
+
* 🚨 **Do NOT combine with {@link FieldNode.searchAction} — it is UNSUPPORTED
|
|
570
|
+
* in v1 and warns `[vms:lookup-ambiguous-enter]`.** Type "urgent", press
|
|
571
|
+
* Enter: invent the tag, or search for it? Both are legitimate readings of the
|
|
572
|
+
* same keystroke, and no precedence serves both (invent-first starves the
|
|
573
|
+
* search; search-first starves invention forever) — that there is no good
|
|
574
|
+
* ordering is the tell that the shape is wrong, so v1 does not guess.
|
|
575
|
+
* Suggestions on a tags field are deferred, exactly as the parked `tags`
|
|
576
|
+
* design already deferred them. Declaring both ignores `allowCustom`.
|
|
577
|
+
*
|
|
578
|
+
* Whether a given value was picked or invented is **server-decidable** — the
|
|
579
|
+
* server produced every candidate it ever offered, so it can test the id
|
|
580
|
+
* against its own id space. There is deliberately no wire marker for
|
|
581
|
+
* provenance (no `__isNew__`): react-select needs one because it is
|
|
582
|
+
* client-only and has no server to ask; we have a server. The explicitness
|
|
583
|
+
* this decision demands is satisfied by `allowCustom` being a declared axis on
|
|
584
|
+
* the node — the app declares the act — not by a per-value flag.
|
|
585
|
+
*
|
|
586
|
+
* Omitted = false (custom entries rejected; only offered candidates commit).
|
|
587
|
+
*/
|
|
588
|
+
allowCustom?: boolean;
|
|
333
589
|
/** Optional language hint for `inputType: "code"`. Emitted as a class
|
|
334
590
|
* (`vms-field--code-{language}`) so apps can attach a syntax-highlighter
|
|
335
591
|
* library (CodeMirror, Monaco, etc.) — the framework only ships
|
package/dist/server.js
CHANGED
|
@@ -107,6 +107,12 @@ function collectActions(node, enclosingForm, out) {
|
|
|
107
107
|
const field = node;
|
|
108
108
|
if (field.action)
|
|
109
109
|
recordAction(field.action, enclosingForm, out);
|
|
110
|
+
// Phase 21 (LOOK-01) — a lookup's debounced search is a real dispatch to a
|
|
111
|
+
// real handler, so its name participates in uniqueness like every other
|
|
112
|
+
// dispatch site. `action` and `searchAction` are independent: a lookup may
|
|
113
|
+
// carry both (Enter commits, typing searches).
|
|
114
|
+
if (field.searchAction)
|
|
115
|
+
recordAction(field.searchAction, enclosingForm, out);
|
|
110
116
|
return;
|
|
111
117
|
}
|
|
112
118
|
case "checkbox": {
|
package/dist/tui.js
CHANGED
|
@@ -1254,6 +1254,26 @@ function FormView({ node, ctx }) {
|
|
|
1254
1254
|
// B3 ships single-select; select-multiple semantics
|
|
1255
1255
|
// are deferred (no native OpenTUI multi-select
|
|
1256
1256
|
// widget; would need a custom focusable list — B5).
|
|
1257
|
+
// lookup / lookup-multiple → <text label> + <input value=the ID at `bind`> +
|
|
1258
|
+
// a <text> line listing the `selected` labels
|
|
1259
|
+
// (label ?? value, per the wire's "label omitted
|
|
1260
|
+
// = label equals value" rule). Both inputTypes get
|
|
1261
|
+
// the SAME layout; lookup-multiple joins its
|
|
1262
|
+
// selected labels with ", ".
|
|
1263
|
+
// 🚨 The debounced `searchAction`, the popup
|
|
1264
|
+
// listbox, the chips, the roving tabindex, and the
|
|
1265
|
+
// aria-live region are DELIBERATELY NOT ported.
|
|
1266
|
+
// This is the ARCHITECTURE WORKING AS DESIGNED,
|
|
1267
|
+
// not a gap: `bind` holds the id and nothing else,
|
|
1268
|
+
// so — per the lookup design of record §6 — "an
|
|
1269
|
+
// agent sets `bind` to the id and never touches the
|
|
1270
|
+
// search UI. It does not need to know the label."
|
|
1271
|
+
// A terminal user does exactly what an agent does.
|
|
1272
|
+
// Everything omitted here is a browser/DOM concept
|
|
1273
|
+
// with no OpenTUI analog (the a11y spec is a
|
|
1274
|
+
// screen-reader contract that means nothing in a
|
|
1275
|
+
// terminal). Same honest-degradation precedent as
|
|
1276
|
+
// select-multiple → single-select above.
|
|
1257
1277
|
// checkbox → <text "[x] label" or "[ ] label"> — decorative
|
|
1258
1278
|
// for B3 (toggle interactivity in B5).
|
|
1259
1279
|
// file → <text "{label}: [file: …]"> placeholder —
|
|
@@ -1327,6 +1347,27 @@ function FieldView({ node, ctx }) {
|
|
|
1327
1347
|
handleSubmit(next);
|
|
1328
1348
|
} }, `${node.name}::wire::${wireValue}`)] }));
|
|
1329
1349
|
}
|
|
1350
|
+
// ── lookup / lookup-multiple: honest degradation to a bound id input ───
|
|
1351
|
+
// See the per-inputType layout contract in the header comment above for WHY
|
|
1352
|
+
// this is a complete implementation for the terminal rather than a stub: the
|
|
1353
|
+
// id is the only thing that round-trips, and typing it directly is precisely
|
|
1354
|
+
// the agent-drivable path the design of record §6 describes. No search
|
|
1355
|
+
// machinery is ported (no debounce, no searchAction dispatch, no popup, no
|
|
1356
|
+
// chips, no live region) — those are DOM concepts, and the a11y baseline they
|
|
1357
|
+
// implement is a screen-reader contract with no terminal meaning.
|
|
1358
|
+
if (node.inputType === "lookup" || node.inputType === "lookup-multiple") {
|
|
1359
|
+
// Read the display text from `selected` and ONLY from `selected` — never
|
|
1360
|
+
// from `candidates`. That is the load-bearing invariant of the whole
|
|
1361
|
+
// primitive (the label is view, server-owned, recomputed every render;
|
|
1362
|
+
// `candidates` is the search result set and says nothing about what is
|
|
1363
|
+
// currently chosen). `label ?? value` implements the wire rule that a label
|
|
1364
|
+
// equal to its value is omitted — a free-form tag shows as its own id.
|
|
1365
|
+
const selectedLabels = (node.selected ?? []).map((s) => s.label ?? s.value);
|
|
1366
|
+
const selectedLine = selectedLabels.length > 0 ? selectedLabels.join(", ") : "(none selected)";
|
|
1367
|
+
return (_jsxs("box", { flexDirection: "column", gap: 0, children: [_jsx("text", { fg: "#888888", children: label }), _jsx("input", { value: currentValue, placeholder: node.placeholder ?? "", focused: focused, onInput: (v) => ctx.setFieldValue(node.name, v), onSubmit: (v) => {
|
|
1368
|
+
handleSubmit(typeof v === "string" ? v : undefined);
|
|
1369
|
+
} }, `${node.name}::wire::${wireValue}`), _jsxs("text", { fg: "#888888", children: ["Selected: ", selectedLine] })] }));
|
|
1370
|
+
}
|
|
1330
1371
|
// ── checkbox: decorative glyph for B3 (toggle wiring is B5) ───────────
|
|
1331
1372
|
if (node.inputType === "checkbox") {
|
|
1332
1373
|
// Wire contract: checkbox-typed field's value is "true" | "false".
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ashley-shrok/viewmodel-shell",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.2.0",
|
|
4
4
|
"description": "A server-driven UI framework where the wire format is structured enough that agents can build full-stack apps without ever opening a browser and all UI tests are pure unit tests with no browser runtime. Server returns a JSON tree of typed nodes; a thin TypeScript adapter renders it to DOM. Backend-agnostic \u2014 a .NET reference backend ships with the repo, but any language can produce the JSON contract.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/styles/default.css
CHANGED
|
@@ -574,6 +574,231 @@ select.vms-field__input { cursor: pointer; padding-right: var(--vms-space-xl);
|
|
|
574
574
|
overflow: auto;
|
|
575
575
|
}
|
|
576
576
|
|
|
577
|
+
/* Lookup / reference picker (FieldNode inputType="lookup" / "lookup-multiple",
|
|
578
|
+
Phase 21) — an editable combobox whose listbox popup floats over the page.
|
|
579
|
+
Built entirely from the existing .vms-field__* tokens: no new colors, no new
|
|
580
|
+
custom properties, so every theme recolors it automatically. */
|
|
581
|
+
.vms-field--lookup { position: relative; }
|
|
582
|
+
.vms-field__popup {
|
|
583
|
+
/* Anchored to .vms-field--lookup's positioning context, floating over what
|
|
584
|
+
follows so the popup never reflows the form when it opens. */
|
|
585
|
+
position: absolute;
|
|
586
|
+
top: 100%;
|
|
587
|
+
left: 0;
|
|
588
|
+
right: 0;
|
|
589
|
+
z-index: 20;
|
|
590
|
+
margin-top: var(--vms-space-2xs);
|
|
591
|
+
max-height: 16rem;
|
|
592
|
+
overflow-y: auto;
|
|
593
|
+
background: var(--vms-surface);
|
|
594
|
+
border: 1px solid var(--vms-border);
|
|
595
|
+
border-radius: var(--vms-radius);
|
|
596
|
+
/* A literal rgba, matching the toast overlay's shadow (the house precedent
|
|
597
|
+
for a floating surface) — there is no --vms-shadow token, and inventing
|
|
598
|
+
one would mean touching all 12 themes for a drop shadow. */
|
|
599
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.24);
|
|
600
|
+
color-scheme: var(--vms-color-scheme);
|
|
601
|
+
}
|
|
602
|
+
/* The popup is rendered ALWAYS and hidden when closed (aria-controls must stay
|
|
603
|
+
valid while hidden). This rule is LOAD-BEARING: the UA's [hidden] default is
|
|
604
|
+
display:none, but the block above would otherwise win on specificity and the
|
|
605
|
+
popup would be permanently visible. */
|
|
606
|
+
.vms-field__popup[hidden] { display: none; }
|
|
607
|
+
/* The lookup's aria-live status regions and its assistive hint: available to
|
|
608
|
+
assistive tech, invisible on screen. Deliberately NOT display:none and NOT
|
|
609
|
+
visibility:hidden — both REMOVE the element from the accessibility tree, so a
|
|
610
|
+
live region styled that way is never announced and the hint is never read.
|
|
611
|
+
This is the standard visually-hidden clip; it must stay clipped-but-rendered. */
|
|
612
|
+
.vms-field__live {
|
|
613
|
+
position: absolute;
|
|
614
|
+
width: 1px;
|
|
615
|
+
height: 1px;
|
|
616
|
+
margin: -1px;
|
|
617
|
+
padding: 0;
|
|
618
|
+
border: 0;
|
|
619
|
+
overflow: hidden;
|
|
620
|
+
clip: rect(0 0 0 0);
|
|
621
|
+
clip-path: inset(50%);
|
|
622
|
+
white-space: nowrap;
|
|
623
|
+
}
|
|
624
|
+
.vms-field__option {
|
|
625
|
+
padding: var(--vms-space-xs) var(--vms-space-md);
|
|
626
|
+
font-size: var(--vms-text-md);
|
|
627
|
+
line-height: var(--vms-control-line);
|
|
628
|
+
color: var(--vms-text);
|
|
629
|
+
cursor: pointer;
|
|
630
|
+
/* §7 item 33 (reflow) — option text WRAPS rather than truncating: a
|
|
631
|
+
candidate is a person's or record's name, not a status word, and at high
|
|
632
|
+
magnification a truncated option is unreadable. The border delimits a
|
|
633
|
+
wrapped option so it never reads as two separate suggestions (GOV.UK's 12x
|
|
634
|
+
tester: "the words just looked like separate suggestions that didn't make
|
|
635
|
+
much sense"). */
|
|
636
|
+
overflow-wrap: anywhere;
|
|
637
|
+
border-bottom: 1px solid var(--vms-border);
|
|
638
|
+
}
|
|
639
|
+
.vms-field__option:last-child { border-bottom: none; }
|
|
640
|
+
.vms-field__option:hover { background: var(--vms-surface-2); }
|
|
641
|
+
/* The active (aria-activedescendant) option. DOM focus stays in the input, so
|
|
642
|
+
:focus cannot express this — the highlight must be driven off aria-selected,
|
|
643
|
+
which is the same fact the live region announces. */
|
|
644
|
+
.vms-field__option[aria-selected="true"] {
|
|
645
|
+
background: var(--vms-accent-glow);
|
|
646
|
+
color: var(--vms-text);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/* ── lookup chips (Phase 21, LOOK-03; BOTH modes since 21-14 / D2a) ──
|
|
650
|
+
The selection, rendered OUTSIDE the combobox as chip(s) — for `lookup` AND
|
|
651
|
+
`lookup-multiple`, from ONE implementation. The only difference between the
|
|
652
|
+
two is arity (single replaces on pick, multi appends), which lives in
|
|
653
|
+
browser.ts and has no expression here at all: a chip is a chip.
|
|
654
|
+
|
|
655
|
+
🚨 We diverge from SLDS deliberately and it is worth knowing why, because the
|
|
656
|
+
survey says the opposite: Salesforce renders single-select's selection INSIDE
|
|
657
|
+
the input and ships no pill element for single at all. 21-13 followed them,
|
|
658
|
+
and the operator found that model has NOWHERE TO CLICK TO TYPE — the pill was
|
|
659
|
+
the input, so clicking in just appended to the name. Their model is coherent
|
|
660
|
+
given clear-then-search; ours is coherent given always-typeable. (There is no
|
|
661
|
+
`.vms-field--lookup-selected` any more; do not resurrect it.)
|
|
662
|
+
|
|
663
|
+
Structure is role=list/listitem + a real <button> (design §7 item 24) — see
|
|
664
|
+
browser.ts. This is presentation only; nothing here is on the wire.
|
|
665
|
+
|
|
666
|
+
⚠️ CONTRAST: the chip's fg/bg pair is NOT covered by the fixed 13-pair
|
|
667
|
+
check:aa-contrast gate, so it is hand-measured across the shipped default +
|
|
668
|
+
all 12 themes: 10.63:1 worst (dark-*), 13.60:1 (light-*), vs a 4.5:1 text bar.
|
|
669
|
+
Single-select reuses this exact pair rather than inventing a token, so it adds
|
|
670
|
+
no new pair to measure. Re-measure if --_chip-tone ever stops being
|
|
671
|
+
--vms-text. */
|
|
672
|
+
.vms-field__chips {
|
|
673
|
+
display: flex;
|
|
674
|
+
flex-wrap: wrap;
|
|
675
|
+
gap: var(--vms-space-2xs);
|
|
676
|
+
/* Reset the <ul>. NOTE this is exactly why browser.ts sets role="list"
|
|
677
|
+
EXPLICITLY: `list-style: none` strips the implicit list/listitem roles in
|
|
678
|
+
Safari/VoiceOver, so a styled <ul> silently stops being a list — the
|
|
679
|
+
accessibility regression is caused BY this line and repaired in the
|
|
680
|
+
renderer. Do not "clean up" that seemingly-redundant role. */
|
|
681
|
+
list-style: none;
|
|
682
|
+
margin: 0;
|
|
683
|
+
padding: 0;
|
|
684
|
+
}
|
|
685
|
+
.vms-field__chip {
|
|
686
|
+
/* The .vms-badge private-tone technique (default.css, .vms-badge): ONE private
|
|
687
|
+
custom property holds the working color and the fill, text and border all
|
|
688
|
+
derive from it, so EVERY THEME RECOLORS AUTOMATICALLY — --vms-text and
|
|
689
|
+
--vms-surface are theme-owned. Retoning a chip would be one line. */
|
|
690
|
+
--_chip-tone: var(--vms-text);
|
|
691
|
+
display: inline-flex;
|
|
692
|
+
align-items: center;
|
|
693
|
+
/* Hug content in ANY parent layout. Carried over from .vms-badge's comment
|
|
694
|
+
(written in 5.1.2 for exactly this failure mode) because the chip hits it
|
|
695
|
+
IDENTICALLY: a chip is semantically inline, but as a flex item it is
|
|
696
|
+
BLOCKIFIED (inline-flex → flex) and the chip row's align-items would then
|
|
697
|
+
stretch it. A definite cross-size opts out of that stretch —
|
|
698
|
+
align-items:stretch only stretches items whose cross size is `auto`.
|
|
699
|
+
Deliberately NOT align-self:start, which would top-anchor the chip since a
|
|
700
|
+
child's align-self beats the parent's align-items. */
|
|
701
|
+
width: fit-content;
|
|
702
|
+
max-width: 100%;
|
|
703
|
+
gap: var(--vms-space-2xs);
|
|
704
|
+
font-size: var(--vms-text-xs);
|
|
705
|
+
font-weight: 600;
|
|
706
|
+
/* Not the badge's `1`: a chip's text WRAPS (below), and line-height:1 makes
|
|
707
|
+
wrapped lines collide. */
|
|
708
|
+
line-height: 1.35;
|
|
709
|
+
/* Tighter on the right — the remove button carries its own hit area. */
|
|
710
|
+
padding: 0.2em 0.3em 0.2em 0.6em;
|
|
711
|
+
border-radius: 999px;
|
|
712
|
+
/* 🚨 DELIBERATELY NOT `white-space: nowrap` — the ONE thing not copied from
|
|
713
|
+
.vms-badge, and the easiest to reintroduce by "consistency". Design §7 item
|
|
714
|
+
33: chip text must WRAP WITHOUT TRUNCATION. A badge is a short status word;
|
|
715
|
+
a chip holds a PERSON'S NAME. GOV.UK's 12x-magnification tester on the
|
|
716
|
+
truncation failure: "Some larger country names may take over two lines. It
|
|
717
|
+
wasn't always clear to him that this had happened — the words just looked
|
|
718
|
+
like separate suggestions that didn't make much sense." The pill border-
|
|
719
|
+
radius + the surface knockout are what delimit a wrapped chip so it never
|
|
720
|
+
reads as two. (The popup's options carry the same rule and their own
|
|
721
|
+
delimiting border — see .vms-field__option.) */
|
|
722
|
+
overflow-wrap: anywhere;
|
|
723
|
+
/* The polarity-adaptive knockout — the house technique, used 18x in this file.
|
|
724
|
+
Mixing TOWARD --vms-surface (never toward white/black) auto-adapts to light
|
|
725
|
+
AND dark themes with ZERO per-theme deepening.
|
|
726
|
+
⚠️ PROVISIONAL MIX RATIO — 12%, not the badge's 16%, and Plan 21-09's
|
|
727
|
+
hand-check owns the final number. Two reasons it must not simply inherit
|
|
728
|
+
16%: (1) the fg/bg pair here is a NEW one the fixed 13-pair check:aa-contrast
|
|
729
|
+
gate does NOT auto-cover; (2) the badge's 16% was tuned for a small badge on
|
|
730
|
+
a PAGE surface. This is deliberately conservative — the tone is --vms-text
|
|
731
|
+
(near-maximal contrast against a 12% tint of ITSELF in the surface), not the
|
|
732
|
+
badge's --vms-text-muted, because a chip carries CONTENT (a name) rather
|
|
733
|
+
than muted status. The chip's background is opaque, so the field's input
|
|
734
|
+
backdrop does not enter the contrast pair. */
|
|
735
|
+
background: color-mix(in srgb, var(--_chip-tone) 12%, var(--vms-surface));
|
|
736
|
+
color: var(--_chip-tone);
|
|
737
|
+
border: 1px solid transparent;
|
|
738
|
+
}
|
|
739
|
+
/* §7 item 31 — the two-step Backspace's ARMED chip: the first press highlights
|
|
740
|
+
the last chip rather than deleting it. This is the VISUAL half only; the
|
|
741
|
+
announcement is the half that matters (browser.ts), because an arm that were
|
|
742
|
+
only a CSS class would leave an AT user pressing Backspace, hearing nothing,
|
|
743
|
+
pressing again, and deleting something they were never told about. Border, not
|
|
744
|
+
a fill swap, so it cannot disturb the contrast pair above. */
|
|
745
|
+
.vms-field__chip--armed { border-color: var(--_chip-tone); }
|
|
746
|
+
.vms-field__chip-remove {
|
|
747
|
+
display: inline-flex;
|
|
748
|
+
align-items: center;
|
|
749
|
+
justify-content: center;
|
|
750
|
+
/* Never let the button shrink or wrap away from its label — the chip's text
|
|
751
|
+
wraps; its remove affordance must not. */
|
|
752
|
+
flex: none;
|
|
753
|
+
align-self: flex-start;
|
|
754
|
+
width: 1.4em;
|
|
755
|
+
height: 1.4em;
|
|
756
|
+
padding: 0;
|
|
757
|
+
border: 0;
|
|
758
|
+
border-radius: 999px;
|
|
759
|
+
background: transparent;
|
|
760
|
+
/* Inherit the chip's tone rather than re-deriving it, so a retoned chip keeps
|
|
761
|
+
a consistent remove button for free. */
|
|
762
|
+
color: inherit;
|
|
763
|
+
font-family: inherit;
|
|
764
|
+
font-size: 1.1em;
|
|
765
|
+
line-height: 1;
|
|
766
|
+
cursor: pointer;
|
|
767
|
+
}
|
|
768
|
+
.vms-field__chip-remove:hover {
|
|
769
|
+
background: color-mix(in srgb, var(--_chip-tone) 22%, transparent);
|
|
770
|
+
}
|
|
771
|
+
/* The chip is INTERACTIVE where the badge is inert, so its remove button needs a
|
|
772
|
+
visible focus ring: it is a REAL focusable button (§7 item 26 gives the chips a
|
|
773
|
+
roving tabindex precisely so DOM focus lands here), and it is reachable by
|
|
774
|
+
keyboard alone. :focus-visible, so a mouse click doesn't ring it.
|
|
775
|
+
|
|
776
|
+
🚨 DELIBERATELY --_chip-tone, NOT --vms-accent (which is what
|
|
777
|
+
.vms-field__input:focus uses, and what this rule used before Plan 21-09's
|
|
778
|
+
hand-check). Measured, not assumed: --vms-accent clears the 3:1 non-text bar
|
|
779
|
+
(SC 1.4.11) against --vms-surface on all 13 targets, but only BARELY on
|
|
780
|
+
light-amber/light-green/light-teal (3.34 / 3.23 / 3.28). The chip's fill is a
|
|
781
|
+
TINTED surface, and that tint eats the entire remaining margin — the accent
|
|
782
|
+
ring measures 2.63 / 2.54 / 2.58 against the chip fill on those three themes:
|
|
783
|
+
a FAILING focus indicator on a keyboard-only affordance.
|
|
784
|
+
|
|
785
|
+
The mix ratio is NOT the lever, which is the non-obvious part: the ring still
|
|
786
|
+
fails at a 4% tint and only reaches 3.10:1 at 2% — a chip with no visible fill
|
|
787
|
+
at all. No chip worth rendering can rescue the accent here.
|
|
788
|
+
|
|
789
|
+
--_chip-tone is the structural fix and needs no per-theme rule: it is the same
|
|
790
|
+
polarity-adaptive knockout pair as the chip's TEXT (mixing toward
|
|
791
|
+
--vms-surface), so it inherits that pair's measured headroom — 10.63:1 worst
|
|
792
|
+
(dark-*), 13.60:1 (light-*), vs a 3:1 bar. It is also what the chip's own
|
|
793
|
+
private-tone technique already does everywhere else: .vms-field__chip--armed
|
|
794
|
+
borders with it and this button's `color` inherits it. The accent ring was the
|
|
795
|
+
outlier, not the convention. Re-measure if --_chip-tone ever stops being
|
|
796
|
+
--vms-text. */
|
|
797
|
+
.vms-field__chip-remove:focus-visible {
|
|
798
|
+
outline: 2px solid var(--_chip-tone);
|
|
799
|
+
outline-offset: 1px;
|
|
800
|
+
}
|
|
801
|
+
|
|
577
802
|
/* Form-collected checkbox (FieldNode inputType="checkbox") — inline label. */
|
|
578
803
|
.vms-field--checkbox { flex-direction: row; align-items: center; gap: var(--vms-space-xs); }
|
|
579
804
|
.vms-field--checkbox .vms-field__input {
|