diff --git a/jest.config.js b/jest.config.js
index b0db96ebd..e4bb14d9b 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -13,8 +13,9 @@ config.transformIgnorePatterns = [
// tight and fails sporadically. Give all tests a bit more room.
config.testTimeout = 10000;
-// add svelte-jester
-config.transform["^.+\\.svelte$"] = "svelte-jester";
+// svelte-jester refuses to run in Jest's CJS mode, so use a custom client
+// compile + ESM->CJS transformer (see the tool for the rationale).
+config.transform["^.+\\.svelte$"] = path.resolve(__dirname, "./tools/jest-svelte-component.cjs");
// console.log(JSON.stringify(config, null, 4));
module.exports = config;
diff --git a/src/pat/contentbrowser/contentbrowser-selection.test.js b/src/pat/contentbrowser/contentbrowser-selection.test.js
new file mode 100644
index 000000000..ede08cf6d
--- /dev/null
+++ b/src/pat/contentbrowser/contentbrowser-selection.test.js
@@ -0,0 +1,69 @@
+import "./contentbrowser";
+import registry from "@patternslib/patternslib/src/core/registry";
+import utils from "@patternslib/patternslib/src/core/utils";
+
+const vocabularyUrl = "http://localhost/plone/@@getVocabulary";
+
+const item = (uid, title) => ({
+ UID: uid,
+ Title: title,
+ "@id": `http://localhost/plone/${uid}`,
+ path: `/plone/${uid}`,
+ portal_type: "Document",
+ getIcon: "",
+ is_folderish: false,
+ review_state: "published",
+ getURL: `http://localhost/plone/${uid}`,
+});
+
+function mockVocabulary(items) {
+ global.fetch = jest.fn(async () => ({
+ ok: true,
+ json: async () => ({ results: items, total: items.length }),
+ }));
+}
+
+async function mountField(value) {
+ const options = { vocabularyUrl };
+ document.body.innerHTML = `
+
+
+
+ `;
+ const input = document.getElementById("field");
+ const changes = [];
+ input.addEventListener("change", () => changes.push(input.value));
+ registry.scan(document.body);
+ await utils.timeout(1);
+ return { input, changes };
+}
+
+describe("Content Browser selection", () => {
+ afterEach(() => {
+ document.body.innerHTML = "";
+ delete global.fetch;
+ });
+
+ it("does not fire change while rendering the initial selection", async () => {
+ mockVocabulary([item("uid-1", "One"), item("uid-2", "Two")]);
+ const { input, changes } = await mountField("uid-1,uid-2");
+ await utils.timeout(1);
+
+ expect(document.querySelectorAll(".selected-item").length).toEqual(2);
+ expect(input.value).toEqual("uid-1,uid-2");
+ expect(changes).toEqual([]);
+ });
+
+ it("fires change when an item is unselected", async () => {
+ mockVocabulary([item("uid-1", "One"), item("uid-2", "Two")]);
+ const { input, changes } = await mountField("uid-1,uid-2");
+ await utils.timeout(1);
+
+ document.querySelector(".selected-item button[aria-label=remove]").click();
+ await utils.timeout(1);
+
+ expect(input.value).toEqual("uid-2");
+ expect(changes).toEqual(["uid-2"]);
+ });
+});
diff --git a/src/pat/contentbrowser/src/SelectedItems.svelte b/src/pat/contentbrowser/src/SelectedItems.svelte
index 026c454f8..babaf42cd 100644
--- a/src/pat/contentbrowser/src/SelectedItems.svelte
+++ b/src/pat/contentbrowser/src/SelectedItems.svelte
@@ -101,6 +101,12 @@
function setNodeValue(selectedUids) {
const node_val = selectedUids.join($config.separator);
+ if (selectedItemsNode.value === node_val) {
+ // Nothing changed — the initial selection being written back
+ // on mount must not look like an edit (pat-formunloadalert
+ // would otherwise warn on every page leave).
+ return;
+ }
selectedItemsNode.value = node_val;
selectedItemsNode.dispatchEvent(events.change_event());
}
diff --git a/tools/jest-svelte-component.cjs b/tools/jest-svelte-component.cjs
new file mode 100644
index 000000000..49373fdb3
--- /dev/null
+++ b/tools/jest-svelte-component.cjs
@@ -0,0 +1,38 @@
+// Jest transformer for Svelte 5 components (.svelte).
+//
+// svelte-jester refuses to run while Jest is in CJS mode, which is the mode the
+// Patternslib base jest config uses — so component-mount tests are otherwise
+// impossible here. Compile the component to client JS with the svelte compiler
+// and down-convert the emitted ESM to CommonJS so Jest's default runtime can
+// require it. Backported from master (tools/jest-svelte-component.cjs).
+const babel = require("@babel/core");
+const svelte = require("svelte/compiler");
+
+module.exports = {
+ process(source, filename) {
+ const compiled = svelte.compile(source, {
+ filename,
+ dev: true,
+ generate: "client",
+ }).js.code;
+
+ const cjs = babel.transformSync(compiled, {
+ filename,
+ babelrc: false,
+ configFile: false,
+ sourceMaps: "inline",
+ plugins: ["@babel/plugin-transform-modules-commonjs"],
+ });
+
+ return { code: cjs.code };
+ },
+
+ getCacheKey(source, filename) {
+ return require("crypto")
+ .createHash("md5")
+ .update(source)
+ .update(filename)
+ .update("svelte-component-v1")
+ .digest("hex");
+ },
+};