@ashley-shrok/viewmodel-shell 6.1.0 → 6.2.1
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 +62 -14
- package/dist/index.d.ts +39 -2
- package/package.json +2 -2
- package/styles/default.css +8 -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
|
|
@@ -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;
|
|
@@ -864,8 +871,38 @@ export interface TableNode {
|
|
|
864
871
|
}
|
|
865
872
|
export interface CopyButtonNode {
|
|
866
873
|
type: "copy-button";
|
|
867
|
-
/** 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. */
|
|
868
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;
|
|
869
906
|
/** Label shown on the button before copying. Adapter default: "Copy". */
|
|
870
907
|
label?: string;
|
|
871
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.1
|
|
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.1",
|
|
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
|
@@ -286,6 +286,14 @@ body:has(.vms-page--fill) { margin: 0; }
|
|
|
286
286
|
}
|
|
287
287
|
.vms-page--sidebar > .vms-page__title,
|
|
288
288
|
.vms-section--sidebar > .vms-section__heading { flex: 0 0 100%; }
|
|
289
|
+
/* Flex items default to min-width: auto, so a child whose MIN-content is wider than
|
|
290
|
+
its flex track (e.g. a plain section wrapping a wide/nowrap TableNode) can't shrink
|
|
291
|
+
and forces the flex line to wrap — rail alone on line 1, main dropped below with dead
|
|
292
|
+
space. min-width: 0 lets the child shrink to its track and the inner overflow
|
|
293
|
+
container (.vms-table-wrapper{overflow-x:auto}, long strings) scroll instead. Mirrors
|
|
294
|
+
the split/cards min-width:0 rule above, which was NOT applied to sidebar (#17). */
|
|
295
|
+
.vms-page--sidebar > *,
|
|
296
|
+
.vms-section--sidebar > * { min-width: 0; }
|
|
289
297
|
|
|
290
298
|
/* ── Layout preset: row (D-30 / 1.11.0) — left-aligned wrapping horizontal row;
|
|
291
299
|
children hug their content (no forced grow), wrap to the next line on overflow.
|