@ashley-shrok/viewmodel-shell 6.0.0 → 6.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser.js +64 -16
- package/dist/index.d.ts +47 -2
- package/package.json +2 -2
- package/styles/default.css +16 -0
package/dist/browser.js
CHANGED
|
@@ -832,6 +832,8 @@ export class BrowserAdapter {
|
|
|
832
832
|
const el = document.createElement("details");
|
|
833
833
|
el.className = `vms-section vms-section--collapsible${n.variant === "card" ? " vms-section--card" : ""}${n.tone ? ` vms-section--${n.tone}` : ""}${n.layout && n.layout !== "stack" ? ` vms-section--${n.layout}` : ""}${n.fill === true ? " vms-section--fill" : ""}${n.arrange ? ` vms-arrange--${n.arrange}` : ""}${n.align ? ` vms-align--${n.align}` : ""}${n.threshold ? ` vms-switch--${n.threshold}` : ""}${n.limit ? ` vms-switch-limit--${n.limit}` : ""}${n.minItem ? ` vms-cards-min--${n.minItem}` : ""}${n.alignSelf ? ` vms-self--${n.alignSelf}` : ""}${n.maxWidth ? ` vms-maxw--${n.maxWidth}` : ""}`;
|
|
834
834
|
el.dataset.sectionKey = finalKey;
|
|
835
|
+
if (n.id)
|
|
836
|
+
el.id = n.id; // addressable DOM id (e.g. CopyButtonNode.copyTargetId target)
|
|
835
837
|
// Initial render is always closed — the post-render restore loop in
|
|
836
838
|
// render() re-applies `open=true` for keys the user had open before.
|
|
837
839
|
const summary = document.createElement("summary");
|
|
@@ -856,6 +858,8 @@ export class BrowserAdapter {
|
|
|
856
858
|
const a = document.createElement("a");
|
|
857
859
|
a.className = `vms-section vms-section--linked${n.variant === "card" ? " vms-section--card" : ""}${n.tone ? ` vms-section--${n.tone}` : ""}${n.layout && n.layout !== "stack" ? ` vms-section--${n.layout}` : ""}${n.fill === true ? " vms-section--fill" : ""}${n.arrange ? ` vms-arrange--${n.arrange}` : ""}${n.align ? ` vms-align--${n.align}` : ""}${n.threshold ? ` vms-switch--${n.threshold}` : ""}${n.limit ? ` vms-switch-limit--${n.limit}` : ""}${n.minItem ? ` vms-cards-min--${n.minItem}` : ""}${n.alignSelf ? ` vms-self--${n.alignSelf}` : ""}${n.maxWidth ? ` vms-maxw--${n.maxWidth}` : ""}`;
|
|
858
860
|
a.href = n.link.url;
|
|
861
|
+
if (n.id)
|
|
862
|
+
a.id = n.id; // addressable DOM id (e.g. CopyButtonNode.copyTargetId target)
|
|
859
863
|
// Mirror LinkNode's external-attribute pattern (browser.ts ~line 666)
|
|
860
864
|
// byte-for-byte: target=_blank + rel=noopener noreferrer when external.
|
|
861
865
|
if (n.link.external) {
|
|
@@ -899,6 +903,8 @@ export class BrowserAdapter {
|
|
|
899
903
|
}
|
|
900
904
|
const el = document.createElement("section");
|
|
901
905
|
el.className = `vms-section${n.variant === "card" ? " vms-section--card" : ""}${n.tone ? ` vms-section--${n.tone}` : ""}${n.layout && n.layout !== "stack" ? ` vms-section--${n.layout}` : ""}${n.fill === true ? " vms-section--fill" : ""}${n.arrange ? ` vms-arrange--${n.arrange}` : ""}${n.align ? ` vms-align--${n.align}` : ""}${n.threshold ? ` vms-switch--${n.threshold}` : ""}${n.limit ? ` vms-switch-limit--${n.limit}` : ""}${n.minItem ? ` vms-cards-min--${n.minItem}` : ""}${n.alignSelf ? ` vms-self--${n.alignSelf}` : ""}${n.maxWidth ? ` vms-maxw--${n.maxWidth}` : ""}${n.action ? " vms-section--clickable" : ""}`;
|
|
906
|
+
if (n.id)
|
|
907
|
+
el.id = n.id; // addressable DOM id (e.g. CopyButtonNode.copyTargetId target)
|
|
902
908
|
// SectionNode.followTail — mark this as an append-only feed so render()'s
|
|
903
909
|
// snapshot/restore keeps its newest content in view (see render() + the
|
|
904
910
|
// FOLLOW_TAIL_STICK_THRESHOLD_PX constant). No CSS/class — the scroll comes
|
|
@@ -954,8 +960,8 @@ export class BrowserAdapter {
|
|
|
954
960
|
parent.appendChild(el);
|
|
955
961
|
}
|
|
956
962
|
list(n, parent, on) {
|
|
957
|
-
const ul = document.createElement("ul");
|
|
958
|
-
ul.className = "vms-list"
|
|
963
|
+
const ul = document.createElement(n.ordered ? "ol" : "ul");
|
|
964
|
+
ul.className = `vms-list${n.ordered ? " vms-list--ordered" : ""}`;
|
|
959
965
|
if (n.id)
|
|
960
966
|
ul.id = n.id;
|
|
961
967
|
this.kids(n.children, ul, on);
|
|
@@ -3274,25 +3280,67 @@ export class BrowserAdapter {
|
|
|
3274
3280
|
btn.type = "button";
|
|
3275
3281
|
btn.className = `vms-button${n.emphasis ? ` vms-button--${n.emphasis}` : ""}${n.tone ? ` vms-button--${n.tone}` : ""}${n.size ? ` vms-button--${n.size}` : ""}${n.width === "full" ? " vms-button--full" : ""}`;
|
|
3276
3282
|
btn.textContent = n.label ?? "Copy";
|
|
3277
|
-
|
|
3278
|
-
|
|
3283
|
+
const showCopied = () => {
|
|
3284
|
+
btn.textContent = n.copiedLabel ?? "Copied!";
|
|
3285
|
+
setTimeout(() => { btn.textContent = n.label ?? "Copy"; }, 1500);
|
|
3286
|
+
};
|
|
3287
|
+
// Plain-text copy (today's behavior, unchanged): async writeText with the
|
|
3288
|
+
// execCommand fallback for engines without the async Clipboard API.
|
|
3289
|
+
const copyPlain = (text) => {
|
|
3290
|
+
const write = navigator.clipboard?.writeText(text);
|
|
3279
3291
|
if (write) {
|
|
3280
|
-
write.then(() => {
|
|
3281
|
-
|
|
3282
|
-
setTimeout(() => { btn.textContent = n.label ?? "Copy"; }, 1500);
|
|
3283
|
-
}).catch(() => {
|
|
3284
|
-
if (legacyCopy(n.text)) {
|
|
3285
|
-
btn.textContent = n.copiedLabel ?? "Copied!";
|
|
3286
|
-
setTimeout(() => { btn.textContent = n.label ?? "Copy"; }, 1500);
|
|
3287
|
-
}
|
|
3288
|
-
});
|
|
3292
|
+
write.then(showCopied).catch(() => { if (legacyCopy(text))
|
|
3293
|
+
showCopied(); });
|
|
3289
3294
|
}
|
|
3290
3295
|
else {
|
|
3291
|
-
if (legacyCopy(
|
|
3292
|
-
|
|
3293
|
-
|
|
3296
|
+
if (legacyCopy(text))
|
|
3297
|
+
showCopied();
|
|
3298
|
+
}
|
|
3299
|
+
};
|
|
3300
|
+
btn.addEventListener("click", () => {
|
|
3301
|
+
// RICH COPY — resolve the two representations by precedence: copyTargetId > html > plain.
|
|
3302
|
+
let html = null;
|
|
3303
|
+
let plain = n.text;
|
|
3304
|
+
if (n.copyTargetId != null) {
|
|
3305
|
+
// Harvest route: lift both representations off the already-rendered region.
|
|
3306
|
+
const target = document.getElementById(n.copyTargetId);
|
|
3307
|
+
if (target) {
|
|
3308
|
+
html = target.outerHTML;
|
|
3309
|
+
plain = target.textContent ?? n.text;
|
|
3310
|
+
}
|
|
3311
|
+
else {
|
|
3312
|
+
// Fail LOUD — a copy target that resolves to nothing is a bug, not a
|
|
3313
|
+
// silent no-op — then still give the user the plain fallback.
|
|
3314
|
+
console.error(`[viewmodel-shell] CopyButtonNode.copyTargetId "${n.copyTargetId}" matched no ` +
|
|
3315
|
+
`element on the page; falling back to plain-text copy. The target must be a ` +
|
|
3316
|
+
`described region that emits that DOM id (e.g. SectionNode.id / ListNode.id).`);
|
|
3317
|
+
copyPlain(n.text);
|
|
3318
|
+
return;
|
|
3294
3319
|
}
|
|
3295
3320
|
}
|
|
3321
|
+
else if (n.html != null) {
|
|
3322
|
+
// Server-provided route: the formatted representation the server authored.
|
|
3323
|
+
html = n.html;
|
|
3324
|
+
}
|
|
3325
|
+
// No rich representation → today's plain-text write.
|
|
3326
|
+
if (html === null) {
|
|
3327
|
+
copyPlain(plain);
|
|
3328
|
+
return;
|
|
3329
|
+
}
|
|
3330
|
+
// Two representations at once via the async Clipboard API. A destination that
|
|
3331
|
+
// understands formatting takes text/html; a plain one takes text/plain. If the
|
|
3332
|
+
// API is unavailable or the write is rejected (permissions, older engine),
|
|
3333
|
+
// degrade to the plain representation so the button is never dead.
|
|
3334
|
+
if (navigator.clipboard?.write && typeof ClipboardItem === "function") {
|
|
3335
|
+
const item = new ClipboardItem({
|
|
3336
|
+
"text/html": new Blob([html], { type: "text/html" }),
|
|
3337
|
+
"text/plain": new Blob([plain], { type: "text/plain" }),
|
|
3338
|
+
});
|
|
3339
|
+
navigator.clipboard.write([item]).then(showCopied).catch(() => copyPlain(plain));
|
|
3340
|
+
}
|
|
3341
|
+
else {
|
|
3342
|
+
copyPlain(plain);
|
|
3343
|
+
}
|
|
3296
3344
|
});
|
|
3297
3345
|
parent.appendChild(btn);
|
|
3298
3346
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -154,7 +154,14 @@ export interface SectionNode {
|
|
|
154
154
|
alignSelf?: "start" | "center" | "end";
|
|
155
155
|
/** Bounded content-width cap — a CLOSED token set (NOT raw CSS, per P2), implemented with `max-inline-size` (writing-mode-safe). Fractional values are proportional to the container (half→50%, two-thirds→66.6667%, three-quarters→75%) — the chat-gutter case, scaling with width; `prose` caps at the readable measure (min(65ch,100%), the Tailwind `max-w-prose` / Every-Layout `--measure` cap). The section still shrinks to its content below the cap. Omitted = no class → no cap (today's full-width behavior) = byte-identical. Emits .vms-maxw--{value}. Closed union (CHILD-02). */
|
|
156
156
|
maxWidth?: "half" | "two-thirds" | "three-quarters" | "prose";
|
|
157
|
-
/** Optional stable
|
|
157
|
+
/** Optional stable identifier for the section. Two uses: (1) it is emitted as the
|
|
158
|
+
* rendered element's DOM `id`, which makes the section addressable — e.g. as a
|
|
159
|
+
* `CopyButtonNode.copyTargetId` harvest target; and (2) when `collapsible: true`
|
|
160
|
+
* it is the renderer's open-state preservation key. Provide when `heading` isn't
|
|
161
|
+
* unique within a page or is absent — otherwise the collapsible snapshot falls
|
|
162
|
+
* back to `heading ?? "vms-section-anon"`, disambiguated by per-render ordinal.
|
|
163
|
+
* DOM ids must be unique on a page (the same uniqueness the collapse-key already
|
|
164
|
+
* wants). Omitted = no DOM id emitted and the heading fallback is used for collapse. */
|
|
158
165
|
id?: string;
|
|
159
166
|
/** When true, the section renders as a native `<details>`/`<summary>` disclosure widget (closed by default). Aesthetic, client-side primitive — the open/closed state is DOM-local and the server does NOT round-trip it (same conceptual model as draft text values in unsubmitted form inputs). The browser adapter snapshots `<details>.open` before each re-render and restores it after, keyed by `id ?? heading ?? "vms-section-anon"` (disambiguated by per-render ordinal); a re-key drops the preserved state (the documented escape hatch for rare server-driven expansion). The summary label is the section's `heading`; a headingless collapsible section uses the fallback string `"Show details"`. If a section needs to start open, do not mark it collapsible. Omitted/false = today's `<section>` rendering, byte-identical. */
|
|
160
167
|
collapsible?: boolean;
|
|
@@ -233,6 +240,14 @@ export interface SectionNode {
|
|
|
233
240
|
export interface ListNode {
|
|
234
241
|
type: "list";
|
|
235
242
|
id?: string;
|
|
243
|
+
/** When true, this is an ORDERED list — renders `<ol>` (numbered) instead of
|
|
244
|
+
* `<ul>`. The semantic element gives real ordinal meaning (a11y: screen
|
|
245
|
+
* readers announce position/count; agent-legibility: the order is structural,
|
|
246
|
+
* not baked into item text). The visible "1." / "2." markers come from a CSS
|
|
247
|
+
* counter (`.vms-list--ordered`), not native list markers, because `.vms-list`
|
|
248
|
+
* is a flex column with `list-style:none` — so numbering survives the styled
|
|
249
|
+
* list-item layout. Omitted/false = `<ul>` (byte-identical to today). */
|
|
250
|
+
ordered?: boolean;
|
|
236
251
|
children: ViewNode[];
|
|
237
252
|
}
|
|
238
253
|
export interface ListItemNode {
|
|
@@ -856,8 +871,38 @@ export interface TableNode {
|
|
|
856
871
|
}
|
|
857
872
|
export interface CopyButtonNode {
|
|
858
873
|
type: "copy-button";
|
|
859
|
-
/** The string
|
|
874
|
+
/** The plain-text string written to the clipboard on click. Always the
|
|
875
|
+
* `text/plain` representation and the universal fallback: it is what a plain
|
|
876
|
+
* destination (a code box, a terminal, a plain-text field) receives, and what
|
|
877
|
+
* is copied when neither rich-copy route below applies. Required. */
|
|
860
878
|
text: string;
|
|
879
|
+
/** RICH COPY — harvest route (adapter-side, the default; no server authoring).
|
|
880
|
+
* The DOM id of an already-rendered region to copy. On click the adapter reads
|
|
881
|
+
* that element's rendered markup as the `text/html` representation and its plain
|
|
882
|
+
* text as `text/plain`, writing BOTH to the clipboard at once — so a paste into a
|
|
883
|
+
* formatted destination (email, doc) keeps its structure while a plain
|
|
884
|
+
* destination still gets clean text. The framework carries its look in
|
|
885
|
+
* class-keyed styling rather than inline, so the app's theme naturally falls away
|
|
886
|
+
* on paste and the *semantic structure* (headings, tables, bold, lists) survives —
|
|
887
|
+
* the desired outcome, achieved by the conventional native-copy mechanism (the
|
|
888
|
+
* destination sanitizes; the framework invents no cleaning pass). The target must
|
|
889
|
+
* be a described region carrying an emitted DOM id (e.g. `SectionNode.id`,
|
|
890
|
+
* `ListNode.id`); because ids only live on described nodes, this can never point
|
|
891
|
+
* at undescribed chrome, so the intent stays fully readable from the wire. Point
|
|
892
|
+
* it at a *content* region, not one containing this button. If the id resolves to
|
|
893
|
+
* no element the adapter fails LOUD (console error) and falls back to writing
|
|
894
|
+
* `text` — never a silent dead button. Absent = no harvest.
|
|
895
|
+
* Precedence: `copyTargetId` > `html` > plain `text`. */
|
|
896
|
+
copyTargetId?: string;
|
|
897
|
+
/** RICH COPY — server-provided route (opt-in). A ready-made formatted
|
|
898
|
+
* representation the server authored, written as `text/html` alongside `text`
|
|
899
|
+
* (`text/plain`). Reach for this only when the content to copy is NOT already
|
|
900
|
+
* rendered on the page (a cleaner or richer export than what's shown) — otherwise
|
|
901
|
+
* prefer `copyTargetId`, which authors nothing. This is a WRITE-ONLY clipboard
|
|
902
|
+
* export bound for a foreign destination; it never re-enters the rendered view
|
|
903
|
+
* (that would be decoration and is forbidden). Ignored when `copyTargetId` is set.
|
|
904
|
+
* Absent = no server-provided rich representation. */
|
|
905
|
+
html?: string;
|
|
861
906
|
/** Label shown on the button before copying. Adapter default: "Copy". */
|
|
862
907
|
label?: string;
|
|
863
908
|
/** Ephemeral label shown after a successful copy, reverts after ~1.5 s. Adapter default: "Copied!". */
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ashley-shrok/viewmodel-shell",
|
|
3
|
-
"version": "6.
|
|
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
|
|
3
|
+
"version": "6.2.0",
|
|
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 — a .NET reference backend ships with the repo, but any language can produce the JSON contract.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "ashley-shrok",
|
package/styles/default.css
CHANGED
|
@@ -1043,6 +1043,22 @@ select.vms-field__input { cursor: pointer; padding-right: var(--vms-space-xl);
|
|
|
1043
1043
|
.vms-list-item--success:hover { border-left-color: var(--vms-success); }
|
|
1044
1044
|
.vms-list-item--info:hover { border-left-color: var(--vms-info); }
|
|
1045
1045
|
|
|
1046
|
+
/* Ordered list — <ol>. `.vms-list` sets list-style:none for the styled
|
|
1047
|
+
flex-column layout, so native markers never show; a CSS counter supplies the
|
|
1048
|
+
number as the first flex child of each item (spaced by the item's own gap).
|
|
1049
|
+
tabular-nums + right-aligned min-width keeps multi-digit numbers aligned. */
|
|
1050
|
+
.vms-list--ordered { counter-reset: vms-li; }
|
|
1051
|
+
.vms-list--ordered > .vms-list-item { counter-increment: vms-li; }
|
|
1052
|
+
.vms-list--ordered > .vms-list-item::before {
|
|
1053
|
+
content: counter(vms-li) ".";
|
|
1054
|
+
flex: 0 0 auto;
|
|
1055
|
+
min-width: 1.5em;
|
|
1056
|
+
text-align: right;
|
|
1057
|
+
font-variant-numeric: tabular-nums;
|
|
1058
|
+
font-weight: 600;
|
|
1059
|
+
color: var(--vms-text-muted);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1046
1062
|
/* Selection state for master-detail / list-detail patterns (D-27). The
|
|
1047
1063
|
currently-selected row. Stronger than the semantic hints (selection is
|
|
1048
1064
|
the primary state) and stable through hover. Themable: uses only seam
|