- Sync app, public, scripts, test and examples from the framework's component-showcase: redesigned component demos, marketing blocks under /blocks, template and preview galleries, distinct Timeline/Section variants, Chat Bubble receipts, a top-placement Drawer demo, and the playground code panel fix (objects in array props no longer blank out). - Standalone adaptations kept: the generator and tests read component-reference.json from the published @wrnexus/ui, and tests use the published @wrnexus/compiler and @wrnexus/syntax. - Dependencies: every @wrnexus/* package to its latest release (ui 0.8.88, styles 0.8.26, core 0.8.16, cli 0.8.72, ...), happy-dom for the playground tests, Tailwind 4.3; bun.lock and package-lock.json refreshed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
491 lines
18 KiB
JavaScript
491 lines
18 KiB
JavaScript
/* global AbortController, DOMParser, HTMLFormElement, HTMLInputElement, HTMLSelectElement, HTMLTextAreaElement, MutationObserver, Node, URL, URLSearchParams, clearTimeout, document, fetch, navigator, setTimeout, window */
|
|
|
|
(() => {
|
|
const SELECTOR = "[data-playground]";
|
|
const DESIGN_DEFAULTS = {
|
|
style: "default",
|
|
palette: "violet",
|
|
mode: "system",
|
|
font: "jakarta",
|
|
scale: "default",
|
|
};
|
|
const htmlPolicy = window.trustedTypes?.createPolicy("wrnexus-playground", {
|
|
createHTML: (value) => value,
|
|
});
|
|
let request;
|
|
let timer;
|
|
|
|
// Preview props are refreshed by the server when a control changes. Avoid a
|
|
// second parent-to-child prop write during hydration, which reconstructs
|
|
// nested composite mounts without their server-rendered children.
|
|
function preservePreviewProps(preview) {
|
|
const root = Array.from(preview.children).find((node) => node.tagName !== "SCRIPT");
|
|
for (const attribute of Array.from(root?.attributes ?? [])) {
|
|
if (attribute.name.startsWith("data-wrn-prop-bind-")) root.removeAttribute(attribute.name);
|
|
}
|
|
}
|
|
document.querySelectorAll("[data-playground-preview]").forEach(preservePreviewProps);
|
|
|
|
function storedDesign() {
|
|
const root = document.documentElement.dataset;
|
|
const preference = (name) => {
|
|
const entry = document.cookie.split("; ").find((part) => part.startsWith(`wrn-ui-${name}=`));
|
|
return entry ? decodeURIComponent(entry.slice(entry.indexOf("=") + 1)) : "";
|
|
};
|
|
return {
|
|
style: preference("style") || root.uiStyle || DESIGN_DEFAULTS.style,
|
|
palette: preference("palette") || root.uiPalette || DESIGN_DEFAULTS.palette,
|
|
mode: preference("mode") || root.uiMode || DESIGN_DEFAULTS.mode,
|
|
font: preference("font") || root.uiFont || DESIGN_DEFAULTS.font,
|
|
scale: preference("scale") || root.uiScale || DESIGN_DEFAULTS.scale,
|
|
};
|
|
}
|
|
|
|
function setCookie(name, value) {
|
|
document.cookie = `${name}=${encodeURIComponent(value)};path=/;max-age=31536000;samesite=lax`;
|
|
}
|
|
|
|
function applyDesign(settings, persist = true) {
|
|
const root = document.documentElement;
|
|
root.dataset.uiStyle = settings.style;
|
|
root.dataset.uiPalette = settings.palette;
|
|
root.dataset.uiMode = settings.mode;
|
|
root.dataset.uiFont = settings.font;
|
|
root.dataset.uiScale = settings.scale;
|
|
|
|
const dark =
|
|
settings.mode === "dark" ||
|
|
(settings.mode === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
|
if (window.wrnTheme) window.wrnTheme.set(dark ? "dark" : "light");
|
|
else {
|
|
const theme = dark ? "dark" : "light";
|
|
root.dataset.theme = theme;
|
|
const stylesheet = document.querySelector("link[data-wrnexus-theme]");
|
|
if (stylesheet) {
|
|
const href = stylesheet.getAttribute("href");
|
|
if (href)
|
|
stylesheet.setAttribute("href", href.replace(/(\/theme\/)[^/]+\//, `$1${theme}/`));
|
|
}
|
|
}
|
|
|
|
for (const button of document.querySelectorAll("[data-design-mode]")) {
|
|
button.setAttribute(
|
|
"aria-pressed",
|
|
String(button.dataset.designMode === (dark ? "dark" : "light")),
|
|
);
|
|
}
|
|
for (const control of document.querySelectorAll("[data-design-setting]")) {
|
|
control.value = settings[control.dataset.designSetting] || "";
|
|
}
|
|
if (persist) {
|
|
setCookie("wrn-ui-style", settings.style);
|
|
setCookie("wrn-ui-palette", settings.palette);
|
|
setCookie("wrn-ui-mode", settings.mode);
|
|
setCookie("wrn-ui-font", settings.font);
|
|
setCookie("wrn-ui-scale", settings.scale);
|
|
setCookie("wrn-theme", dark ? "dark" : "light");
|
|
}
|
|
}
|
|
|
|
let design = storedDesign();
|
|
applyDesign(design, false);
|
|
function syncDirectory() {
|
|
const directory = document.querySelector(".docs-directory");
|
|
if (!directory) return;
|
|
let active;
|
|
for (const link of directory.querySelectorAll("a[href]")) {
|
|
const matches =
|
|
new URL(link.href, window.location.origin).pathname.replace(/\/$/, "") ===
|
|
window.location.pathname.replace(/\/$/, "");
|
|
if (matches) {
|
|
link.setAttribute("aria-current", "page");
|
|
active = link;
|
|
} else link.removeAttribute("aria-current");
|
|
}
|
|
if (active) {
|
|
const bounds = directory.getBoundingClientRect();
|
|
const item = active.getBoundingClientRect();
|
|
if (item.top < bounds.top || item.bottom > bounds.bottom)
|
|
directory.scrollTop += item.top - bounds.top - directory.clientHeight / 2;
|
|
}
|
|
}
|
|
syncDirectory();
|
|
window.addEventListener("wrnexus:navigated", syncDirectory);
|
|
window.addEventListener("popstate", syncDirectory);
|
|
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
|
|
if (design.mode === "system") applyDesign(design);
|
|
});
|
|
|
|
function sourceAttribute(value) {
|
|
return String(value)
|
|
.replaceAll("&", "&")
|
|
.replaceAll('"', """)
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">");
|
|
}
|
|
|
|
function updateCode(playground) {
|
|
const form = playground.querySelector("[data-playground-form]");
|
|
const code = playground.querySelector("[data-playground-code]");
|
|
if (!(form instanceof HTMLFormElement) || !code) return;
|
|
|
|
const component = playground.getAttribute("data-playground-component") || "Component";
|
|
const publicTag = playground.getAttribute("data-playground-public-tag") === "true";
|
|
const hasSlot = playground.getAttribute("data-playground-has-slot") === "true";
|
|
const attributes = [];
|
|
const declarations = [];
|
|
|
|
for (const field of form.elements) {
|
|
if (
|
|
!(
|
|
field instanceof HTMLInputElement ||
|
|
field instanceof HTMLTextAreaElement ||
|
|
field instanceof HTMLSelectElement
|
|
) ||
|
|
!field.name.startsWith("pg_")
|
|
)
|
|
continue;
|
|
|
|
const name = field.name.slice(3);
|
|
if (field.hasAttribute("data-playground-json")) {
|
|
try {
|
|
const data = JSON.parse(field.value);
|
|
declarations.push(` state ${name} = ${JSON.stringify(data, null, 2)}`);
|
|
attributes.push(`${name}="{${name}}"`);
|
|
continue;
|
|
} catch { /* Keep invalid JSON visible for correction. */ }
|
|
}
|
|
if (field instanceof HTMLInputElement && field.type === "checkbox") {
|
|
attributes.push(`${name}="${field.checked}"`);
|
|
} else if (field.value !== "") {
|
|
const value = field.hasAttribute("data-playground-json")
|
|
? `'${field.value.replaceAll("'", "\\'")}'`
|
|
: `"${field.value.replaceAll('"', '\\"')}"`;
|
|
attributes.push(`${name}=${value}`);
|
|
}
|
|
}
|
|
|
|
const tag = publicTag ? component : "div";
|
|
const mount = publicTag ? [] : [`data-component="${sourceAttribute(component)}"`];
|
|
const lines = [...mount, ...attributes].map((attribute) => ` ${attribute}`).join("\n");
|
|
const opening = lines ? `<${tag}\n${lines}` : `<${tag}`;
|
|
let source = hasSlot
|
|
? `${opening}>\n <!-- Add slot content here -->\n</${tag}>`
|
|
: `${opening}\n/>`;
|
|
if (declarations.length) source = `page ${component.replace(/[^A-Za-z0-9]/g, "")}Example {\n${declarations.join("\n")}\n view {\n${source}\n }\n}`;
|
|
writeCode(code, source);
|
|
}
|
|
|
|
/*
|
|
* The reactive runtime treats "{expr}" inside a text node as an
|
|
* interpolation, so a sample like { "label": "Mon" } written with
|
|
* textContent was evaluated and rendered as an empty string. Putting each
|
|
* brace in its own <span> means no single text node ever holds a {...}
|
|
* pair -- the same markup the generator uses for the server-rendered code.
|
|
*/
|
|
function writeCode(code, source) {
|
|
const fragment = document.createDocumentFragment();
|
|
for (const part of source.split(/([{}])/)) {
|
|
if (!part) continue;
|
|
if (part === "{" || part === "}") {
|
|
const brace = document.createElement("span");
|
|
brace.textContent = part;
|
|
fragment.append(brace);
|
|
} else {
|
|
fragment.append(document.createTextNode(part));
|
|
}
|
|
}
|
|
code.replaceChildren(fragment);
|
|
}
|
|
|
|
function valuesFrom(form) {
|
|
const params = new URLSearchParams();
|
|
let valid = true;
|
|
|
|
for (const field of form.elements) {
|
|
if (!(
|
|
field instanceof HTMLInputElement ||
|
|
field instanceof HTMLTextAreaElement ||
|
|
field instanceof HTMLSelectElement
|
|
))
|
|
continue;
|
|
if (!field.name) continue;
|
|
|
|
const error = field.closest(".playground-field")?.querySelector("[data-playground-error]");
|
|
if (field.hasAttribute("data-playground-json")) {
|
|
try {
|
|
JSON.parse(field.value);
|
|
field.removeAttribute("aria-invalid");
|
|
if (error) error.textContent = "";
|
|
} catch {
|
|
field.setAttribute("aria-invalid", "true");
|
|
if (error) error.textContent = "Enter valid JSON";
|
|
valid = false;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
params.set(field.name, field.type === "checkbox" ? String(field.checked) : field.value);
|
|
}
|
|
|
|
return valid ? params : null;
|
|
}
|
|
|
|
async function update(playground) {
|
|
const form = playground.querySelector("[data-playground-form]");
|
|
const preview = playground.querySelector("[data-playground-preview]");
|
|
const status = playground.querySelector("[data-playground-status]");
|
|
if (!(form instanceof HTMLFormElement) || !preview || !status) return;
|
|
|
|
const params = valuesFrom(form);
|
|
if (!params) {
|
|
status.textContent = "Fix the highlighted JSON value.";
|
|
return;
|
|
}
|
|
|
|
request?.abort();
|
|
request = new AbortController();
|
|
const url = new URL(window.location.href);
|
|
for (const key of [...url.searchParams.keys()]) {
|
|
if (key.startsWith("pg_")) url.searchParams.delete(key);
|
|
}
|
|
for (const [key, value] of params) url.searchParams.set(key, value);
|
|
|
|
playground.setAttribute("data-updating", "true");
|
|
status.textContent = "Updating preview…";
|
|
|
|
try {
|
|
const response = await fetch(url, {
|
|
headers: { "x-wrnexus-playground": "1" },
|
|
signal: request.signal,
|
|
});
|
|
if (!response.ok) throw new Error(`Preview request failed (${response.status})`);
|
|
const responseHtml = await response.text();
|
|
const parsed = new DOMParser().parseFromString(
|
|
htmlPolicy ? htmlPolicy.createHTML(responseHtml) : responseHtml,
|
|
"text/html",
|
|
);
|
|
const next = parsed.querySelector("[data-playground-preview]");
|
|
if (!next) throw new Error("Preview markup was not returned");
|
|
|
|
window.__wrnexusDisposeBehaviors?.(preview);
|
|
preview.replaceChildren(
|
|
...Array.from(
|
|
parsed.querySelectorAll('script[type="application/json"][data-wrn-controls]'),
|
|
(node) => document.importNode(node, true),
|
|
),
|
|
...Array.from(next.childNodes, (node) => document.importNode(node, true)),
|
|
);
|
|
preservePreviewProps(preview);
|
|
window.__wrnexusHydrateScopes?.(preview);
|
|
window.__wrnexusHydrateCsrFetches?.(preview);
|
|
window.wrnTheme?.bind?.(document);
|
|
bindPlaygroundEvents(document);
|
|
status.textContent = "Preview updated";
|
|
} catch (error) {
|
|
if (error?.name !== "AbortError") {
|
|
status.textContent = error instanceof Error ? error.message : "Unable to update preview";
|
|
}
|
|
} finally {
|
|
playground.removeAttribute("data-updating");
|
|
}
|
|
}
|
|
|
|
const outputBindings = new WeakMap();
|
|
const legacyObservedOutputs = new Set();
|
|
|
|
function formatOutputPayload(payload) {
|
|
if (payload === undefined) return "No output payload";
|
|
if (typeof payload === "string") return payload;
|
|
try {
|
|
return JSON.stringify(payload, null, 2);
|
|
} catch {
|
|
return String(payload);
|
|
}
|
|
}
|
|
|
|
function renderOutput(owner, outputName, payload) {
|
|
const log = owner.querySelector("[data-playground-event-log]");
|
|
if (!log) return;
|
|
log.replaceChildren();
|
|
const heading = document.createElement("strong");
|
|
heading.textContent = `@${outputName}`;
|
|
const output = document.createElement("pre");
|
|
output.textContent = formatOutputPayload(payload);
|
|
log.append(heading, output);
|
|
}
|
|
|
|
function bindDirectOutput(host, owner, outputName) {
|
|
let byName = outputBindings.get(host);
|
|
if (!byName) {
|
|
byName = new Map();
|
|
outputBindings.set(host, byName);
|
|
}
|
|
if (byName.has(outputName)) return;
|
|
const registry = (host.__wrnexusOutputHandlers ??= new Map());
|
|
const handlers =
|
|
(registry instanceof Map ? registry.get(outputName) : registry[outputName]) ?? new Set();
|
|
const handler = (payload) => renderOutput(owner, outputName, payload);
|
|
handlers.add(handler);
|
|
if (registry instanceof Map) registry.set(outputName, handlers);
|
|
else registry[outputName] = handlers;
|
|
byName.set(outputName, handler);
|
|
}
|
|
|
|
const initializedCode = new WeakSet();
|
|
function bindPlaygroundEvents(root = document) {
|
|
for (const playground of root.querySelectorAll?.(SELECTOR) ?? []) {
|
|
if (!initializedCode.has(playground)) { initializedCode.add(playground); updateCode(playground); }
|
|
const outputNames = (playground.getAttribute("data-playground-events") || "")
|
|
.split(",")
|
|
.map((name) => name.trim())
|
|
.filter(Boolean);
|
|
const host = playground.querySelector(
|
|
"[data-playground-preview] [data-wrn-scope], [data-playground-preview] [data-ui-component], [data-playground-preview] [data-component]",
|
|
);
|
|
for (const outputName of outputNames) {
|
|
if (host) bindDirectOutput(host, playground, outputName);
|
|
// Legacy CustomEvent observation is retained only for compatibility-mode components.
|
|
if (legacyObservedOutputs.has(outputName)) continue;
|
|
legacyObservedOutputs.add(outputName);
|
|
document.addEventListener(outputName, (legacyEvent) => {
|
|
const owner = legacyEvent.target?.closest?.(SELECTOR);
|
|
if (!owner) return;
|
|
const declaredOutputs = (owner.getAttribute("data-playground-events") || "").split(",");
|
|
if (!declaredOutputs.includes(outputName)) return;
|
|
renderOutput(owner, outputName, legacyEvent.detail);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
bindPlaygroundEvents();
|
|
new MutationObserver(() => bindPlaygroundEvents(document)).observe(
|
|
document.getElementById("app") || document.body,
|
|
{ childList: true, subtree: true },
|
|
);
|
|
|
|
function schedule(playground) {
|
|
updateCode(playground);
|
|
clearTimeout(timer);
|
|
timer = setTimeout(() => update(playground), 180);
|
|
}
|
|
|
|
document.addEventListener("input", (event) => {
|
|
const form = event.target.closest?.("[data-playground-form]");
|
|
const playground = form?.closest(SELECTOR);
|
|
if (playground) schedule(playground);
|
|
});
|
|
|
|
document.addEventListener("change", (event) => {
|
|
const form = event.target.closest?.("[data-playground-form]");
|
|
const playground = form?.closest(SELECTOR);
|
|
if (playground) schedule(playground);
|
|
});
|
|
|
|
document.addEventListener("reset", (event) => {
|
|
const playground = event.target.closest?.("[data-playground-form]")?.closest(SELECTOR);
|
|
if (playground)
|
|
setTimeout(() => {
|
|
updateCode(playground);
|
|
update(playground);
|
|
}, 0);
|
|
});
|
|
|
|
document.addEventListener("click", async (event) => {
|
|
if (event.target.closest?.("[data-design-mode]")) {
|
|
design.mode = event.target.closest("[data-design-mode]").dataset.designMode;
|
|
applyDesign(design);
|
|
return;
|
|
}
|
|
const designToggle = event.target.closest?.("[data-design-panel-toggle]");
|
|
if (designToggle) {
|
|
document.documentElement.toggleAttribute("data-design-panel-open");
|
|
return;
|
|
}
|
|
|
|
if (event.target.closest?.("[data-design-reset]")) {
|
|
design = { ...DESIGN_DEFAULTS };
|
|
applyDesign(design);
|
|
return;
|
|
}
|
|
|
|
const copyTarget = event.target.closest?.("[data-copy-target]");
|
|
if (copyTarget) {
|
|
const target = document.querySelector(copyTarget.dataset.copyTarget);
|
|
if (target) { try { await navigator.clipboard.writeText(target.textContent || ""); copyTarget.textContent = "Copied"; } catch { copyTarget.textContent = "Select code to copy"; } }
|
|
return;
|
|
}
|
|
const copyText = event.target.closest?.("[data-copy-text]");
|
|
if (copyText) {
|
|
try {
|
|
await navigator.clipboard.writeText(copyText.dataset.copyText || "");
|
|
copyText.setAttribute("data-copied", "true");
|
|
setTimeout(() => copyText.removeAttribute("data-copied"), 1200);
|
|
} catch {
|
|
// Clipboard access may be blocked in an insecure local context.
|
|
}
|
|
return;
|
|
}
|
|
|
|
const menuToggle = event.target.closest?.("[data-docs-menu-toggle]");
|
|
if (menuToggle) {
|
|
document.documentElement.toggleAttribute("data-docs-menu-open");
|
|
return;
|
|
}
|
|
|
|
if (event.target.closest?.("[data-docs-component-link]")) {
|
|
document.documentElement.removeAttribute("data-docs-menu-open");
|
|
}
|
|
|
|
const copy = event.target.closest?.("[data-playground-copy]");
|
|
if (!copy) return;
|
|
const playground = copy.closest(SELECTOR);
|
|
const code = playground?.querySelector("[data-playground-code]");
|
|
const status = playground?.querySelector("[data-playground-status]");
|
|
if (!code || !status) return;
|
|
|
|
try {
|
|
await navigator.clipboard.writeText(code.textContent || "");
|
|
status.textContent = "Component code copied";
|
|
const label = copy.lastChild;
|
|
if (label?.nodeType === Node.TEXT_NODE) label.textContent = "Copied";
|
|
setTimeout(() => {
|
|
if (label?.nodeType === Node.TEXT_NODE) label.textContent = "Copy";
|
|
}, 1400);
|
|
} catch {
|
|
status.textContent = "Copy failed. Select the code and copy it manually.";
|
|
}
|
|
});
|
|
|
|
document.addEventListener("input", (event) => {
|
|
const setting = event.target.closest?.("[data-design-setting]");
|
|
if (setting) {
|
|
design = { ...design, [setting.dataset.designSetting]: setting.value };
|
|
applyDesign(design);
|
|
return;
|
|
}
|
|
if (!event.target.matches?.("[data-docs-search]")) return;
|
|
const query = event.target.value.trim().toLowerCase();
|
|
const links = [...document.querySelectorAll("[data-docs-component-link]")];
|
|
let visible = 0;
|
|
for (const link of links) {
|
|
const matches = !query || link.textContent.toLowerCase().includes(query);
|
|
link.hidden = !matches;
|
|
if (matches) visible++;
|
|
}
|
|
for (const group of document.querySelectorAll(".docs-directory-group")) {
|
|
group.hidden = !group.querySelector("[data-docs-component-link]:not([hidden])");
|
|
}
|
|
const empty = document.querySelector("[data-docs-empty]");
|
|
if (empty) empty.hidden = visible > 0;
|
|
});
|
|
|
|
document.addEventListener("keydown", (event) => {
|
|
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
|
|
event.preventDefault();
|
|
document.querySelector("[data-docs-search]")?.focus();
|
|
}
|
|
});
|
|
})();
|