@nanobpm/urban 0.23.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.
|
@@ -721,6 +721,16 @@ function renderDataGrid(node) {
|
|
|
721
721
|
}
|
|
722
722
|
}
|
|
723
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
|
+
|
|
724
734
|
async function refresh() {
|
|
725
735
|
try {
|
|
726
736
|
const { rows } = await getJSON(dataUrl(p.data.source, p.data.table, activeFilter, p.data.orderBy));
|
|
@@ -732,15 +742,42 @@ function renderDataGrid(node) {
|
|
|
732
742
|
for (const k of [...detailNodes.keys()]) if (!live.has(k)) detailNodes.delete(k);
|
|
733
743
|
for (const k of [...expanded]) if (!live.has(k)) expanded.delete(k);
|
|
734
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
|
+
}
|
|
735
761
|
tbody.replaceChildren();
|
|
736
762
|
for (const row of rows) renderRow(row);
|
|
737
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
|
+
}
|
|
738
772
|
} catch (e) {
|
|
739
773
|
tbody.replaceChildren(el("tr", {}, el("td", { colspan: span }, String(e.message || e))));
|
|
740
774
|
}
|
|
741
775
|
}
|
|
742
776
|
document.addEventListener("pc:refresh", refresh);
|
|
743
|
-
|
|
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);
|
|
744
781
|
refresh();
|
|
745
782
|
return card;
|
|
746
783
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.23.
|
|
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 ?? "";
|
|
@@ -752,6 +752,16 @@ function renderDataGrid(node) {
|
|
|
752
752
|
}
|
|
753
753
|
}
|
|
754
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
|
+
|
|
755
765
|
async function refresh() {
|
|
756
766
|
try {
|
|
757
767
|
const { rows } = await getJSON(dataUrl(p.data.source, p.data.table, activeFilter, p.data.orderBy));
|
|
@@ -763,15 +773,42 @@ function renderDataGrid(node) {
|
|
|
763
773
|
for (const k of [...detailNodes.keys()]) if (!live.has(k)) detailNodes.delete(k);
|
|
764
774
|
for (const k of [...expanded]) if (!live.has(k)) expanded.delete(k);
|
|
765
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
|
+
}
|
|
766
792
|
tbody.replaceChildren();
|
|
767
793
|
for (const row of rows) renderRow(row);
|
|
768
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
|
+
}
|
|
769
803
|
} catch (e) {
|
|
770
804
|
tbody.replaceChildren(el("tr", {}, el("td", { colspan: span }, String(e.message || e))));
|
|
771
805
|
}
|
|
772
806
|
}
|
|
773
807
|
document.addEventListener("pc:refresh", refresh);
|
|
774
|
-
|
|
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);
|
|
775
812
|
refresh();
|
|
776
813
|
return card;
|
|
777
814
|
}
|