@nanobpm/urban 0.22.0 → 0.23.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.
|
@@ -361,6 +361,9 @@ table.pc-grid th { font-weight:600; opacity:.75; }
|
|
|
361
361
|
.pc-subform-title { font-weight:600; font-size:.85rem; margin-bottom:.4rem; }
|
|
362
362
|
.pc-prompt { font-size:.85rem; opacity:.8; margin-bottom:.4rem; white-space:pre-wrap; }
|
|
363
363
|
.pc-textarea { width:100%; min-height:4rem; padding:.5rem; border:1px solid var(--pc-edge); border-radius:.4rem; font:inherit; }
|
|
364
|
+
.pc-collapse-header { display:flex; align-items:center; gap:.5rem; width:100%; margin:0 0 .75rem; padding:0; background:transparent; border:0; color:inherit; font:inherit; font-size:1rem; font-weight:600; cursor:pointer; text-align:left; }
|
|
365
|
+
.pc-chevron-inline { opacity:.6; font-size:.75rem; width:1em; }
|
|
366
|
+
.pc-card-body[hidden] { display:none; }
|
|
364
367
|
`;
|
|
365
368
|
// The schema-driven browser renderer (ADR 0042 §3). Plain ES module string served at
|
|
366
369
|
// /app/runtime.js — it does NOT ship Craft.js (authoring is console-side only). It
|
|
@@ -718,6 +721,16 @@ function renderDataGrid(node) {
|
|
|
718
721
|
}
|
|
719
722
|
}
|
|
720
723
|
|
|
724
|
+
// Is the user actively typing inside this grid? tbody.replaceChildren() in refresh()
|
|
725
|
+
// detaches the subtree, and detaching the node that holds document.activeElement blurs
|
|
726
|
+
// it — so an open detail form (where you answer an escalation) loses focus every
|
|
727
|
+
// refreshMs even though #103 preserves its half-typed value. Used to skip the poll.
|
|
728
|
+
const editingInGrid = () => {
|
|
729
|
+
const a = document.activeElement;
|
|
730
|
+
if (!a || !tbody.contains(a)) return false;
|
|
731
|
+
return a.tagName === "INPUT" || a.tagName === "TEXTAREA" || a.tagName === "SELECT" || a.isContentEditable;
|
|
732
|
+
};
|
|
733
|
+
|
|
721
734
|
async function refresh() {
|
|
722
735
|
try {
|
|
723
736
|
const { rows } = await getJSON(dataUrl(p.data.source, p.data.table, activeFilter, p.data.orderBy));
|
|
@@ -729,26 +742,125 @@ function renderDataGrid(node) {
|
|
|
729
742
|
for (const k of [...detailNodes.keys()]) if (!live.has(k)) detailNodes.delete(k);
|
|
730
743
|
for (const k of [...expanded]) if (!live.has(k)) expanded.delete(k);
|
|
731
744
|
}
|
|
745
|
+
// Capture focus + caret before the DOM swap. replaceChildren() detaches (and so blurs)
|
|
746
|
+
// the reused detail node; re-appending it restores its value but not its focus. If the
|
|
747
|
+
// very same node is re-attached (an open, still-present row), put the caret back.
|
|
748
|
+
const active = document.activeElement;
|
|
749
|
+
const keepFocus = !!active && tbody.contains(active);
|
|
750
|
+
let selStart = null, selEnd = null, savedRange = null;
|
|
751
|
+
if (keepFocus) {
|
|
752
|
+
if (active.isContentEditable) {
|
|
753
|
+
// contenteditable has no selectionStart — its caret lives in the document
|
|
754
|
+
// selection. The reused node is re-attached, so the cloned range still points
|
|
755
|
+
// at live containers and can be restored after focus.
|
|
756
|
+
try { const s = document.getSelection(); if (s && s.rangeCount) savedRange = s.getRangeAt(0).cloneRange(); } catch (e) { /* selection unavailable */ }
|
|
757
|
+
} else {
|
|
758
|
+
try { selStart = active.selectionStart; selEnd = active.selectionEnd; } catch (e) { /* non-text field */ }
|
|
759
|
+
}
|
|
760
|
+
}
|
|
732
761
|
tbody.replaceChildren();
|
|
733
762
|
for (const row of rows) renderRow(row);
|
|
734
763
|
if (!rows.length) tbody.append(el("tr", {}, el("td", { colspan: span }, "No rows")));
|
|
764
|
+
if (keepFocus && active.isConnected) {
|
|
765
|
+
active.focus();
|
|
766
|
+
if (savedRange) {
|
|
767
|
+
try { const s = document.getSelection(); s.removeAllRanges(); s.addRange(savedRange); } catch (e) { /* selection unavailable */ }
|
|
768
|
+
} else if (selStart != null && typeof active.setSelectionRange === "function") {
|
|
769
|
+
try { active.setSelectionRange(selStart, selEnd); } catch (e) { /* non-text field */ }
|
|
770
|
+
}
|
|
771
|
+
}
|
|
735
772
|
} catch (e) {
|
|
736
773
|
tbody.replaceChildren(el("tr", {}, el("td", { colspan: span }, String(e.message || e))));
|
|
737
774
|
}
|
|
738
775
|
}
|
|
739
776
|
document.addEventListener("pc:refresh", refresh);
|
|
740
|
-
|
|
777
|
+
// Skip the automatic poll while the user is typing in the grid — an explicit pc:refresh
|
|
778
|
+
// (e.g. after submitting an answer) still runs — so rows never shift under an in-progress
|
|
779
|
+
// answer and the detail input keeps focus across ticks.
|
|
780
|
+
if (p.refreshMs && p.refreshMs > 0) setInterval(() => { if (!editingInGrid()) refresh(); }, p.refreshMs);
|
|
741
781
|
refresh();
|
|
742
782
|
return card;
|
|
743
783
|
}
|
|
744
784
|
|
|
745
785
|
const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid };
|
|
746
786
|
|
|
787
|
+
// Durable per-node UI state. localStorage is keyed by the home page id + node
|
|
788
|
+
// id so two grids on the same page (or the same grid across pages) don't clash,
|
|
789
|
+
// and the collapsed state survives a full reload / new session — not just the
|
|
790
|
+
// refresh poll. Every access is guarded: private-mode or storage-disabled
|
|
791
|
+
// browsers throw on localStorage, and a UI preference must never break render.
|
|
792
|
+
function readCollapsed(key, dflt) {
|
|
793
|
+
try {
|
|
794
|
+
const v = localStorage.getItem(key);
|
|
795
|
+
return v === null ? dflt : v === "1";
|
|
796
|
+
} catch (e) {
|
|
797
|
+
return dflt;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
function writeCollapsed(key, val) {
|
|
801
|
+
try {
|
|
802
|
+
localStorage.setItem(key, val ? "1" : "0");
|
|
803
|
+
} catch (e) {
|
|
804
|
+
// Preference is best-effort; ignore storage failures.
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// Wrap a rendered node so its card can be collapsed from a clickable header.
|
|
809
|
+
// Opt-in via the node's "collapsible" prop; "defaultCollapsed" seeds the state
|
|
810
|
+
// the first time (before the user has toggled it). Generic across node types —
|
|
811
|
+
// the body (everything the renderer produced, minus its own <h2> title, which
|
|
812
|
+
// becomes the header label) is hidden as one unit.
|
|
813
|
+
function makeCollapsible(node, card) {
|
|
814
|
+
const props = node.props || {};
|
|
815
|
+
if (!props.collapsible) return card;
|
|
816
|
+
// Normalize to a card container. Card renderers already return
|
|
817
|
+
// <section class="pc-card"> — we unwrap those in place (lift out the <h2>
|
|
818
|
+
// title, reparent the rest into the body). A non-card renderer (e.g. text
|
|
819
|
+
// returning <p>/<h1>) is nested whole inside a fresh section instead:
|
|
820
|
+
// appending a <button>/<div> directly into a <p> is invalid markup the
|
|
821
|
+
// browser silently re-parents, breaking layout and click handling.
|
|
822
|
+
const isCard = card.tagName === "SECTION";
|
|
823
|
+
const container = isCard ? card : el("section", { class: "pc-card" });
|
|
824
|
+
const h2 = isCard && card.querySelector ? card.querySelector("h2") : null;
|
|
825
|
+
const titleText = props.title || (h2 ? h2.textContent : "") || "Section";
|
|
826
|
+
if (h2) h2.remove();
|
|
827
|
+
const body = el("div", { class: "pc-card-body" });
|
|
828
|
+
if (isCard) {
|
|
829
|
+
while (card.firstChild) body.append(card.firstChild);
|
|
830
|
+
} else {
|
|
831
|
+
body.append(card);
|
|
832
|
+
}
|
|
833
|
+
const chevron = el("span", { class: "pc-chevron-inline" }, "▾");
|
|
834
|
+
const header = el(
|
|
835
|
+
"button",
|
|
836
|
+
{ class: "pc-collapse-header", type: "button" },
|
|
837
|
+
chevron,
|
|
838
|
+
el("span", { class: "pc-collapse-title" }, titleText),
|
|
839
|
+
);
|
|
840
|
+
const storageKey = "pc:collapsed:" + HOME + ":" + (node.id || titleText);
|
|
841
|
+
let collapsed = readCollapsed(storageKey, !!props.defaultCollapsed);
|
|
842
|
+
function apply() {
|
|
843
|
+
body.hidden = collapsed;
|
|
844
|
+
chevron.textContent = collapsed ? "▸" : "▾";
|
|
845
|
+
header.setAttribute("aria-expanded", String(!collapsed));
|
|
846
|
+
}
|
|
847
|
+
header.addEventListener("click", () => {
|
|
848
|
+
collapsed = !collapsed;
|
|
849
|
+
writeCollapsed(storageKey, collapsed);
|
|
850
|
+
apply();
|
|
851
|
+
});
|
|
852
|
+
apply();
|
|
853
|
+
container.append(header, body);
|
|
854
|
+
return container;
|
|
855
|
+
}
|
|
856
|
+
|
|
747
857
|
async function main() {
|
|
748
858
|
try {
|
|
749
859
|
const doc = await getJSON("/app/pages/" + encodeURIComponent(HOME));
|
|
750
860
|
if (doc.title) document.title = doc.title;
|
|
751
|
-
root.replaceChildren(
|
|
861
|
+
root.replaceChildren(
|
|
862
|
+
...(doc.nodes || []).map((n) => makeCollapsible(n, (RENDERERS[n.type] || (() => el("div")))(n))),
|
|
863
|
+
);
|
|
752
864
|
} catch (e) {
|
|
753
865
|
root.replaceChildren(el("p", { class: "pc-msg err" }, "Failed to load page: " + String(e.message || e)));
|
|
754
866
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.1",
|
|
4
4
|
"description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -110,6 +110,26 @@ test("renderer preserves grid row-detail expansion across refreshes", async () =
|
|
|
110
110
|
assert.match(js, /v == null \? null : String\(v\)/);
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
+
test("renderer keeps the detail input focused across the refresh poll", async () => {
|
|
114
|
+
// Regression: #103 reused the open detail node so a half-typed answer survived, but
|
|
115
|
+
// tbody.replaceChildren() still detaches (and so blurs) that node, dropping the caret
|
|
116
|
+
// every refreshMs. The client now (1) skips the timer-driven poll while the user is
|
|
117
|
+
// editing in the grid, and (2) restores focus + selection across any refresh that runs.
|
|
118
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
119
|
+
const js = res.body ?? "";
|
|
120
|
+
// (1) The automatic poll is gated on not-editing; an explicit pc:refresh still runs.
|
|
121
|
+
assert.match(js, /const editingInGrid = \(\)\s*=>/);
|
|
122
|
+
assert.match(js, /setInterval\(\s*\(\)\s*=>\s*\{\s*if\s*\(\s*!editingInGrid\(\)\s*\)\s*refresh\(\)\s*;?\s*\}\s*,\s*p\.refreshMs\s*\)/);
|
|
123
|
+
// (2) Focus + caret are captured before replaceChildren() and restored to the reused node.
|
|
124
|
+
assert.match(js, /const keepFocus = !!active && tbody\.contains\(active\)/);
|
|
125
|
+
assert.match(js, /active\.selectionStart/);
|
|
126
|
+
assert.match(js, /if \(keepFocus && active\.isConnected\)/);
|
|
127
|
+
assert.match(js, /active\.setSelectionRange\(selStart, selEnd\)/);
|
|
128
|
+
// contenteditable carets are preserved via the document selection range.
|
|
129
|
+
assert.match(js, /active\.isContentEditable/);
|
|
130
|
+
assert.match(js, /s\.addRange\(savedRange\)/);
|
|
131
|
+
});
|
|
132
|
+
|
|
113
133
|
test("renderer wires a column's linkField to a new-tab anchor", async () => {
|
|
114
134
|
const res = await dispatch("GET", "/app/runtime.js");
|
|
115
135
|
const js = res.body ?? "";
|
|
@@ -138,6 +158,37 @@ test("the renderer honours numeric actionForm fields", async () => {
|
|
|
138
158
|
assert.match(js, /if \(!Number\.isFinite\(num\)\) continue;/);
|
|
139
159
|
});
|
|
140
160
|
|
|
161
|
+
test("renderer makes collapsible nodes persist their state across sessions", async () => {
|
|
162
|
+
// A node with `collapsible` gets a clickable header that hides/shows the card
|
|
163
|
+
// body, and the collapsed state is written to localStorage keyed by the home
|
|
164
|
+
// page id + node id so it survives a full reload / new session (not just the
|
|
165
|
+
// refresh poll). Storage access is guarded so private-mode browsers can't
|
|
166
|
+
// break render.
|
|
167
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
168
|
+
const js = res.body ?? "";
|
|
169
|
+
assert.match(js, /function makeCollapsible\(node, card\)/);
|
|
170
|
+
assert.match(js, /if \(!props\.collapsible\) return card;/);
|
|
171
|
+
// Seed from defaultCollapsed the first time, then from persisted state.
|
|
172
|
+
assert.match(js, /readCollapsed\(storageKey, !!props\.defaultCollapsed\)/);
|
|
173
|
+
// Namespaced, per-node storage key.
|
|
174
|
+
assert.match(js, /"pc:collapsed:" \+ HOME \+ ":" \+ \(node\.id/);
|
|
175
|
+
// Persist on toggle.
|
|
176
|
+
assert.match(js, /writeCollapsed\(storageKey, collapsed\)/);
|
|
177
|
+
// Every storage access is wrapped so a throwing localStorage can't break the UI.
|
|
178
|
+
assert.match(js, /localStorage\.getItem\(key\)/);
|
|
179
|
+
assert.match(js, /localStorage\.setItem\(key, val \? "1" : "0"\)/);
|
|
180
|
+
assert.match(js, /catch \(e\) \{\s*return dflt;/);
|
|
181
|
+
// And the wrapper is actually applied at the dispatch layer.
|
|
182
|
+
assert.match(js, /makeCollapsible\(n, \(RENDERERS\[n\.type\]/);
|
|
183
|
+
// Non-card renderer output (e.g. text -> <p>) is nested whole inside a fresh
|
|
184
|
+
// <section class="pc-card"> rather than having a <button>/<div> injected into
|
|
185
|
+
// it — so the feature is valid markup across every node type, not just cards.
|
|
186
|
+
assert.match(js, /const isCard = card\.tagName === "SECTION";/);
|
|
187
|
+
assert.match(js, /const container = isCard \? card : el\("section", \{ class: "pc-card" \}\)/);
|
|
188
|
+
assert.match(js, /body\.append\(card\)/);
|
|
189
|
+
assert.match(js, /container\.append\(header, body\);\s*return container;/);
|
|
190
|
+
});
|
|
191
|
+
|
|
141
192
|
test("GET /app/pages/<id> returns the page json, 404 for unknown", async () => {
|
|
142
193
|
const ok = await dispatch("GET", "/app/pages/home");
|
|
143
194
|
assert.equal(ok.status, 200);
|
|
@@ -391,6 +391,9 @@ table.pc-grid th { font-weight:600; opacity:.75; }
|
|
|
391
391
|
.pc-subform-title { font-weight:600; font-size:.85rem; margin-bottom:.4rem; }
|
|
392
392
|
.pc-prompt { font-size:.85rem; opacity:.8; margin-bottom:.4rem; white-space:pre-wrap; }
|
|
393
393
|
.pc-textarea { width:100%; min-height:4rem; padding:.5rem; border:1px solid var(--pc-edge); border-radius:.4rem; font:inherit; }
|
|
394
|
+
.pc-collapse-header { display:flex; align-items:center; gap:.5rem; width:100%; margin:0 0 .75rem; padding:0; background:transparent; border:0; color:inherit; font:inherit; font-size:1rem; font-weight:600; cursor:pointer; text-align:left; }
|
|
395
|
+
.pc-chevron-inline { opacity:.6; font-size:.75rem; width:1em; }
|
|
396
|
+
.pc-card-body[hidden] { display:none; }
|
|
394
397
|
`;
|
|
395
398
|
|
|
396
399
|
// The schema-driven browser renderer (ADR 0042 §3). Plain ES module string served at
|
|
@@ -749,6 +752,16 @@ function renderDataGrid(node) {
|
|
|
749
752
|
}
|
|
750
753
|
}
|
|
751
754
|
|
|
755
|
+
// Is the user actively typing inside this grid? tbody.replaceChildren() in refresh()
|
|
756
|
+
// detaches the subtree, and detaching the node that holds document.activeElement blurs
|
|
757
|
+
// it — so an open detail form (where you answer an escalation) loses focus every
|
|
758
|
+
// refreshMs even though #103 preserves its half-typed value. Used to skip the poll.
|
|
759
|
+
const editingInGrid = () => {
|
|
760
|
+
const a = document.activeElement;
|
|
761
|
+
if (!a || !tbody.contains(a)) return false;
|
|
762
|
+
return a.tagName === "INPUT" || a.tagName === "TEXTAREA" || a.tagName === "SELECT" || a.isContentEditable;
|
|
763
|
+
};
|
|
764
|
+
|
|
752
765
|
async function refresh() {
|
|
753
766
|
try {
|
|
754
767
|
const { rows } = await getJSON(dataUrl(p.data.source, p.data.table, activeFilter, p.data.orderBy));
|
|
@@ -760,26 +773,125 @@ function renderDataGrid(node) {
|
|
|
760
773
|
for (const k of [...detailNodes.keys()]) if (!live.has(k)) detailNodes.delete(k);
|
|
761
774
|
for (const k of [...expanded]) if (!live.has(k)) expanded.delete(k);
|
|
762
775
|
}
|
|
776
|
+
// Capture focus + caret before the DOM swap. replaceChildren() detaches (and so blurs)
|
|
777
|
+
// the reused detail node; re-appending it restores its value but not its focus. If the
|
|
778
|
+
// very same node is re-attached (an open, still-present row), put the caret back.
|
|
779
|
+
const active = document.activeElement;
|
|
780
|
+
const keepFocus = !!active && tbody.contains(active);
|
|
781
|
+
let selStart = null, selEnd = null, savedRange = null;
|
|
782
|
+
if (keepFocus) {
|
|
783
|
+
if (active.isContentEditable) {
|
|
784
|
+
// contenteditable has no selectionStart — its caret lives in the document
|
|
785
|
+
// selection. The reused node is re-attached, so the cloned range still points
|
|
786
|
+
// at live containers and can be restored after focus.
|
|
787
|
+
try { const s = document.getSelection(); if (s && s.rangeCount) savedRange = s.getRangeAt(0).cloneRange(); } catch (e) { /* selection unavailable */ }
|
|
788
|
+
} else {
|
|
789
|
+
try { selStart = active.selectionStart; selEnd = active.selectionEnd; } catch (e) { /* non-text field */ }
|
|
790
|
+
}
|
|
791
|
+
}
|
|
763
792
|
tbody.replaceChildren();
|
|
764
793
|
for (const row of rows) renderRow(row);
|
|
765
794
|
if (!rows.length) tbody.append(el("tr", {}, el("td", { colspan: span }, "No rows")));
|
|
795
|
+
if (keepFocus && active.isConnected) {
|
|
796
|
+
active.focus();
|
|
797
|
+
if (savedRange) {
|
|
798
|
+
try { const s = document.getSelection(); s.removeAllRanges(); s.addRange(savedRange); } catch (e) { /* selection unavailable */ }
|
|
799
|
+
} else if (selStart != null && typeof active.setSelectionRange === "function") {
|
|
800
|
+
try { active.setSelectionRange(selStart, selEnd); } catch (e) { /* non-text field */ }
|
|
801
|
+
}
|
|
802
|
+
}
|
|
766
803
|
} catch (e) {
|
|
767
804
|
tbody.replaceChildren(el("tr", {}, el("td", { colspan: span }, String(e.message || e))));
|
|
768
805
|
}
|
|
769
806
|
}
|
|
770
807
|
document.addEventListener("pc:refresh", refresh);
|
|
771
|
-
|
|
808
|
+
// Skip the automatic poll while the user is typing in the grid — an explicit pc:refresh
|
|
809
|
+
// (e.g. after submitting an answer) still runs — so rows never shift under an in-progress
|
|
810
|
+
// answer and the detail input keeps focus across ticks.
|
|
811
|
+
if (p.refreshMs && p.refreshMs > 0) setInterval(() => { if (!editingInGrid()) refresh(); }, p.refreshMs);
|
|
772
812
|
refresh();
|
|
773
813
|
return card;
|
|
774
814
|
}
|
|
775
815
|
|
|
776
816
|
const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid };
|
|
777
817
|
|
|
818
|
+
// Durable per-node UI state. localStorage is keyed by the home page id + node
|
|
819
|
+
// id so two grids on the same page (or the same grid across pages) don't clash,
|
|
820
|
+
// and the collapsed state survives a full reload / new session — not just the
|
|
821
|
+
// refresh poll. Every access is guarded: private-mode or storage-disabled
|
|
822
|
+
// browsers throw on localStorage, and a UI preference must never break render.
|
|
823
|
+
function readCollapsed(key, dflt) {
|
|
824
|
+
try {
|
|
825
|
+
const v = localStorage.getItem(key);
|
|
826
|
+
return v === null ? dflt : v === "1";
|
|
827
|
+
} catch (e) {
|
|
828
|
+
return dflt;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
function writeCollapsed(key, val) {
|
|
832
|
+
try {
|
|
833
|
+
localStorage.setItem(key, val ? "1" : "0");
|
|
834
|
+
} catch (e) {
|
|
835
|
+
// Preference is best-effort; ignore storage failures.
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// Wrap a rendered node so its card can be collapsed from a clickable header.
|
|
840
|
+
// Opt-in via the node's "collapsible" prop; "defaultCollapsed" seeds the state
|
|
841
|
+
// the first time (before the user has toggled it). Generic across node types —
|
|
842
|
+
// the body (everything the renderer produced, minus its own <h2> title, which
|
|
843
|
+
// becomes the header label) is hidden as one unit.
|
|
844
|
+
function makeCollapsible(node, card) {
|
|
845
|
+
const props = node.props || {};
|
|
846
|
+
if (!props.collapsible) return card;
|
|
847
|
+
// Normalize to a card container. Card renderers already return
|
|
848
|
+
// <section class="pc-card"> — we unwrap those in place (lift out the <h2>
|
|
849
|
+
// title, reparent the rest into the body). A non-card renderer (e.g. text
|
|
850
|
+
// returning <p>/<h1>) is nested whole inside a fresh section instead:
|
|
851
|
+
// appending a <button>/<div> directly into a <p> is invalid markup the
|
|
852
|
+
// browser silently re-parents, breaking layout and click handling.
|
|
853
|
+
const isCard = card.tagName === "SECTION";
|
|
854
|
+
const container = isCard ? card : el("section", { class: "pc-card" });
|
|
855
|
+
const h2 = isCard && card.querySelector ? card.querySelector("h2") : null;
|
|
856
|
+
const titleText = props.title || (h2 ? h2.textContent : "") || "Section";
|
|
857
|
+
if (h2) h2.remove();
|
|
858
|
+
const body = el("div", { class: "pc-card-body" });
|
|
859
|
+
if (isCard) {
|
|
860
|
+
while (card.firstChild) body.append(card.firstChild);
|
|
861
|
+
} else {
|
|
862
|
+
body.append(card);
|
|
863
|
+
}
|
|
864
|
+
const chevron = el("span", { class: "pc-chevron-inline" }, "▾");
|
|
865
|
+
const header = el(
|
|
866
|
+
"button",
|
|
867
|
+
{ class: "pc-collapse-header", type: "button" },
|
|
868
|
+
chevron,
|
|
869
|
+
el("span", { class: "pc-collapse-title" }, titleText),
|
|
870
|
+
);
|
|
871
|
+
const storageKey = "pc:collapsed:" + HOME + ":" + (node.id || titleText);
|
|
872
|
+
let collapsed = readCollapsed(storageKey, !!props.defaultCollapsed);
|
|
873
|
+
function apply() {
|
|
874
|
+
body.hidden = collapsed;
|
|
875
|
+
chevron.textContent = collapsed ? "▸" : "▾";
|
|
876
|
+
header.setAttribute("aria-expanded", String(!collapsed));
|
|
877
|
+
}
|
|
878
|
+
header.addEventListener("click", () => {
|
|
879
|
+
collapsed = !collapsed;
|
|
880
|
+
writeCollapsed(storageKey, collapsed);
|
|
881
|
+
apply();
|
|
882
|
+
});
|
|
883
|
+
apply();
|
|
884
|
+
container.append(header, body);
|
|
885
|
+
return container;
|
|
886
|
+
}
|
|
887
|
+
|
|
778
888
|
async function main() {
|
|
779
889
|
try {
|
|
780
890
|
const doc = await getJSON("/app/pages/" + encodeURIComponent(HOME));
|
|
781
891
|
if (doc.title) document.title = doc.title;
|
|
782
|
-
root.replaceChildren(
|
|
892
|
+
root.replaceChildren(
|
|
893
|
+
...(doc.nodes || []).map((n) => makeCollapsible(n, (RENDERERS[n.type] || (() => el("div")))(n))),
|
|
894
|
+
);
|
|
783
895
|
} catch (e) {
|
|
784
896
|
root.replaceChildren(el("p", { class: "pc-msg err" }, "Failed to load page: " + String(e.message || e)));
|
|
785
897
|
}
|