@panphora/clayjs 0.1.0 → 0.2.0

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.
@@ -0,0 +1,62 @@
1
+ // easily convert a form into a JS object
2
+ function getDataFromForm(container) {
3
+ const formData = {};
4
+
5
+ // Helper function to process a single element
6
+ const processElement = (elem) => {
7
+ const name = elem.getAttribute('name');
8
+ const value = elem.value || elem.getAttribute('value');
9
+
10
+ // Skip elements without a name or with a disabled attribute
11
+ if (!name || elem.disabled) return;
12
+
13
+ // Handle different element types
14
+ switch (elem.type) {
15
+ case 'checkbox':
16
+ if (!formData[name]) {
17
+ formData[name] = [];
18
+ }
19
+ if (elem.checked) {
20
+ formData[name].push(value);
21
+ }
22
+ break;
23
+ case 'radio':
24
+ if (elem.checked) {
25
+ formData[name] = value;
26
+ }
27
+ break;
28
+ case 'select-multiple':
29
+ formData[name] = Array.from(elem.selectedOptions, option => option.value);
30
+ break;
31
+ case 'button':
32
+ case 'submit':
33
+ case 'reset':
34
+ // Only include buttons if they have both name and value attributes
35
+ if (name && value) {
36
+ formData[name] = value;
37
+ }
38
+ break;
39
+ default:
40
+ formData[name] = value;
41
+ }
42
+ };
43
+
44
+ // If container is a form, use elements property
45
+ if (container instanceof HTMLFormElement) {
46
+ Array.from(container.elements).forEach(processElement);
47
+ }
48
+ // Otherwise, process container itself and then query for elements with name attribute
49
+ else {
50
+ // Process container element if it has a name attribute
51
+ if (container.hasAttribute('name')) {
52
+ processElement(container);
53
+ }
54
+ // Process all child elements with name attributes
55
+ const elements = container.querySelectorAll('[name]');
56
+ elements.forEach(processElement);
57
+ }
58
+
59
+ return formData;
60
+ }
61
+
62
+ export default getDataFromForm;
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Insert styles into the document (inline CSS or external stylesheet).
3
+ *
4
+ * With a persistent DOM (i.e. clayjs), we need a way to update styles.
5
+ * This function reuses existing elements when possible:
6
+ * - Inline styles: matches by data-name, reuses if content matches
7
+ * - External stylesheets: matches by normalized full URL path
8
+ *
9
+ * This ensures:
10
+ * - No DOM churn: existing elements are reused when content/path matches
11
+ * - No duplicates: removes any duplicate style/link elements
12
+ * - Callback always runs: attributes can be updated on existing elements
13
+ *
14
+ * WHY REUSE IN-PLACE:
15
+ * In a persistent DOM (clayjs), removing and re-adding elements changes their
16
+ * position and surrounding whitespace. This causes snapshot diffs even when content
17
+ * is identical, triggering false "unsaved changes" warnings. Reusing existing
18
+ * elements preserves DOM structure for stable snapshots.
19
+ *
20
+ * Usage:
21
+ * insertStyles('/path/to/file.css') // External stylesheet
22
+ * insertStyles('/path/to/file.css', (link) => { ... }) // With callback
23
+ * insertStyles('my-styles', '.foo { ... }') // Inline CSS
24
+ * insertStyles('my-styles', '.foo { ... }', (style) => { ... }) // With callback
25
+ */
26
+ function insertStyles(nameOrHref, cssOrCallback, callback) {
27
+ if (typeof cssOrCallback === 'string') {
28
+ // Inline style: insertStyles('my-styles', '.foo { ... }', optionalCallback)
29
+ const name = nameOrHref;
30
+ const css = cssOrCallback;
31
+ const existingStyles = [...document.querySelectorAll(`style[data-name="${name}"]`)];
32
+
33
+ // If exact match exists, just update attributes via callback and return it
34
+ const exactMatch = existingStyles.find(el => el.textContent === css);
35
+ if (exactMatch) {
36
+ if (callback) callback(exactMatch);
37
+ // Remove any duplicates
38
+ existingStyles.filter(el => el !== exactMatch).forEach(el => el.remove());
39
+ return exactMatch;
40
+ }
41
+
42
+ // Update first existing style in-place, or create new one
43
+ let style;
44
+ if (existingStyles.length > 0) {
45
+ style = existingStyles[0];
46
+ style.textContent = css;
47
+ if (callback) callback(style);
48
+ // Remove duplicates
49
+ existingStyles.slice(1).forEach(el => el.remove());
50
+ } else {
51
+ style = document.createElement('style');
52
+ style.dataset.name = name;
53
+ style.textContent = css;
54
+ if (callback) callback(style);
55
+ document.head.appendChild(style);
56
+ }
57
+
58
+ return style;
59
+ }
60
+
61
+ // External stylesheet: insertStyles('/path/to/file.css', optionalCallback)
62
+ const href = nameOrHref;
63
+ const cb = typeof cssOrCallback === 'function' ? cssOrCallback : undefined;
64
+
65
+ // Helper to get base URL without query params (for comparison)
66
+ const getBaseUrl = (url) => {
67
+ try {
68
+ const parsed = new URL(url, window.location.href);
69
+ return parsed.origin + parsed.pathname;
70
+ } catch {
71
+ return url;
72
+ }
73
+ };
74
+
75
+ // Normalize href to full URL path (without query params) for comparison
76
+ const normalizedHref = getBaseUrl(href);
77
+
78
+ // Find all links with matching normalized path (ignoring query params like ?v=)
79
+ const existingLinks = [...document.querySelectorAll('link[rel="stylesheet"]')]
80
+ .filter(el => {
81
+ try {
82
+ return getBaseUrl(el.getAttribute('href')) === normalizedHref;
83
+ } catch {
84
+ return false;
85
+ }
86
+ });
87
+
88
+ // If match exists, just update attributes via callback and return it
89
+ if (existingLinks.length > 0) {
90
+ const link = existingLinks[0];
91
+ if (cb) cb(link);
92
+ // Remove any duplicates
93
+ existingLinks.slice(1).forEach(el => el.remove());
94
+ return link;
95
+ }
96
+
97
+ // Create new link element
98
+ const link = document.createElement('link');
99
+ link.rel = 'stylesheet';
100
+ link.href = href;
101
+ if (cb) cb(link);
102
+ document.head.appendChild(link);
103
+
104
+ return link;
105
+ }
106
+
107
+ export { insertStyles };
108
+ export { insertStyles as insertStyleTag }; // backwards-compat alias
109
+ export default insertStyles;
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Searches for elements matching a CSS selector by exploring the DOM tree outward from a starting point,
3
+ * checking nearby elements first before moving to more distant parts of the document.
4
+ *
5
+ * Unlike `element.closest()` which only searches ancestors, this explores the DOM tree in a
6
+ * unique pattern designed to find nearby elements in the visual layout:
7
+ *
8
+ * TRAVERSAL ORDER (for each level):
9
+ * 1. Current element
10
+ * 2. All children of current element (deeply)
11
+ * 3. All previous siblings (right-to-left), exploring each fully with descendants
12
+ * 4. All next siblings (left-to-right), exploring each fully with descendants
13
+ * 5. Move to parent and repeat from step 1
14
+ *
15
+ * KEY FEATURES:
16
+ * - Global visited cache prevents revisiting nodes (big performance boost)
17
+ * - Searches "outward" from start position, checking nearby elements first
18
+ * - Explores each sibling's entire subtree before moving to next sibling
19
+ * - Continues up the ancestor chain until document.body
20
+ *
21
+ * USE CASES:
22
+ * - Finding related UI elements that might be siblings or cousins
23
+ * - Locating the "next" instance of something in reading order
24
+ * - Finding nearby form fields, buttons, or other interactive elements
25
+ *
26
+ * @param {Element} startElem - Starting element for the search
27
+ * @param {string} selector - CSS selector to match
28
+ * @param {Function} elementFoundReturnValue - Transform function for the found element
29
+ * @returns {*} Transformed element if found, null otherwise
30
+ */
31
+ export default function nearest (startElem, selector, elementFoundReturnValue = x => x) {
32
+ const visited = new Set();
33
+
34
+ // Check node and its descendants using BFS
35
+ function checkDeep(root) {
36
+ if (!root || visited.has(root)) return null;
37
+
38
+ const queue = [root];
39
+ const localVisited = new Set(); // Prevent cycles within this BFS
40
+
41
+ while (queue.length > 0) {
42
+ const node = queue.shift();
43
+ if (!node || localVisited.has(node) || visited.has(node)) continue;
44
+
45
+ visited.add(node);
46
+ localVisited.add(node);
47
+
48
+ if (node.matches(selector)) {
49
+ return elementFoundReturnValue(node);
50
+ }
51
+
52
+ queue.push(...node.children);
53
+ }
54
+ return null;
55
+ }
56
+
57
+ // Check siblings in a direction
58
+ function checkSiblings(start, direction) {
59
+ let sibling = start[direction];
60
+ while (sibling) {
61
+ const result = checkDeep(sibling);
62
+ if (result) return result;
63
+ sibling = sibling[direction];
64
+ }
65
+ return null;
66
+ }
67
+
68
+ // Main traversal
69
+ // check current → children → siblings → move up
70
+ let current = startElem;
71
+
72
+ while (current) {
73
+ // Check current node (shallow)
74
+ if (!visited.has(current)) {
75
+ visited.add(current);
76
+ if (current.matches(selector)) {
77
+ return elementFoundReturnValue(current);
78
+ }
79
+ }
80
+
81
+ // Check children deeply
82
+ for (const child of current.children) {
83
+ const result = checkDeep(child);
84
+ if (result) return result;
85
+ }
86
+
87
+ // Check siblings deeply
88
+ let result = checkSiblings(current, 'previousElementSibling') ||
89
+ checkSiblings(current, 'nextElementSibling');
90
+ if (result) return result;
91
+
92
+ // Move up to parent
93
+ current = current.parentElement;
94
+ }
95
+
96
+ return null;
97
+ }
@@ -0,0 +1 @@
1
+ export default (...fns) => x => fns.reduce((v, f) => f(v), x);
@@ -0,0 +1,22 @@
1
+ // Events module - combines all event attribute handlers
2
+ import { init as initOnclickaway } from './onclickaway.js';
3
+ import { init as initOnclickchildren } from './onclickchildren.js';
4
+ import { init as initOnclone } from './onclone.js';
5
+ import { init as initOnmutation } from './onmutation.js';
6
+ import { init as initOnpagemutation } from './onpagemutation.js';
7
+ import { init as initOnrender } from './onrender.js';
8
+
9
+ function init() {
10
+ initOnclickaway();
11
+ initOnclickchildren();
12
+ initOnclone();
13
+ initOnmutation();
14
+ initOnpagemutation();
15
+ initOnrender();
16
+ }
17
+
18
+ // Auto-init when module is imported
19
+ init();
20
+
21
+ export { init };
22
+ export default init;
@@ -0,0 +1,27 @@
1
+ function init () {
2
+
3
+ // the code inside `onclickaway` attributes will be executed whenever that element is NOT clicked
4
+ document.addEventListener('click', function(event) {
5
+ const elementsWithOnClickAway = document.querySelectorAll('[onclickaway]');
6
+
7
+ elementsWithOnClickAway.forEach(element => {
8
+ let targetElement = event.target; // clicked element
9
+
10
+ do {
11
+ if (targetElement === element) {
12
+ // Click inside, do nothing
13
+ return;
14
+ }
15
+ // Go up the DOM
16
+ targetElement = targetElement.parentNode;
17
+ } while (targetElement);
18
+
19
+ // Click outside the element, execute onclickaway
20
+ new Function(element.getAttribute('onclickaway')).call(element);
21
+ });
22
+ });
23
+
24
+ }
25
+
26
+ export { init };
27
+ export default init;
@@ -0,0 +1,12 @@
1
+ function init() {
2
+ document.addEventListener('click', function(event) {
3
+ const parent = event.target.closest('[onclickchildren]');
4
+ if (parent && event.target !== parent) {
5
+ const directChild = event.composedPath().find(el => el.parentNode === parent);
6
+ new Function(parent.getAttribute('onclickchildren')).call(directChild);
7
+ }
8
+ });
9
+ }
10
+
11
+ export { init };
12
+ export default init;
@@ -0,0 +1,54 @@
1
+ function init() {
2
+ // Bail if already patched (idempotence)
3
+ if (Node.prototype.__hyperclayOnclone) {
4
+ return;
5
+ }
6
+
7
+ const originalCloneNode = Node.prototype.cloneNode;
8
+
9
+ // Store original for idempotence check
10
+ Node.prototype.__hyperclayOnclone = originalCloneNode;
11
+
12
+ Node.prototype.cloneNode = function(deep) {
13
+ const clonedNode = originalCloneNode.call(this, deep);
14
+
15
+ if (clonedNode.nodeType === Node.ELEMENT_NODE) {
16
+ if (!window.__preventOnclone) {
17
+ processOnclone(clonedNode);
18
+ clonedNode.querySelectorAll('[onclone]').forEach(processOnclone);
19
+ }
20
+
21
+ // Patch textareas: the persist module writes live values to data-value
22
+ // on every keystroke (because writing textContent on a focused textarea
23
+ // destroys cursor/scroll). Shift data-value into textContent on the
24
+ // clone so consumers get the current value without special handling.
25
+ if (deep) {
26
+ const textareas = clonedNode.tagName === 'TEXTAREA'
27
+ ? [clonedNode]
28
+ : clonedNode.querySelectorAll('textarea[data-value]');
29
+ textareas.forEach(ta => {
30
+ const val = ta.getAttribute('data-value');
31
+ if (val !== null) {
32
+ ta.textContent = val;
33
+ ta.removeAttribute('data-value');
34
+ }
35
+ });
36
+ }
37
+ }
38
+
39
+ return clonedNode;
40
+ };
41
+
42
+ function processOnclone(element) {
43
+ const oncloneCode = element.getAttribute('onclone');
44
+ if (oncloneCode) {
45
+ try {
46
+ new Function(oncloneCode).call(element);
47
+ } catch (error) {
48
+ console.error('Error executing onclone:', error);
49
+ }
50
+ }
51
+ }
52
+ }
53
+ export { init };
54
+ export default init;
@@ -0,0 +1,54 @@
1
+ /*
2
+ [onmutation] - Trigger code when this element or its children change
3
+
4
+ Usage:
5
+ <div onmutation="console.log('My content changed')">
6
+ <span contenteditable>Edit me</span>
7
+ </div>
8
+
9
+ Unlike [onglobalmutation]/[onpagemutation] which fires on ANY DOM change,
10
+ [onmutation] only fires when the element itself or its descendants mutate.
11
+ */
12
+ import Mutation from "../lib/mutation.js";
13
+
14
+ async function executeMutation(element) {
15
+ try {
16
+ const code = element.getAttribute('onmutation');
17
+ if (!code) return;
18
+ const asyncFn = new Function(`return (async function() { ${code} })`)();
19
+ await asyncFn.call(element);
20
+ } catch (error) {
21
+ console.error('Error in onmutation execution:', error);
22
+ }
23
+ }
24
+
25
+ function init() {
26
+ // Source from the single shared observer instead of one MutationObserver per
27
+ // [onmutation] element. For each change, fire every [onmutation] element whose
28
+ // subtree it belongs to: walk up from the element whose subtree actually
29
+ // changed — the still-attached parent for add/remove (the removed node is
30
+ // detached), otherwise the changed element itself — and run any [onmutation]
31
+ // ancestor, exactly as a per-element subtree observer would. An element's own
32
+ // insertion is its parent's childList change, so (correctly) doesn't fire it.
33
+ //
34
+ // require:'observed' makes it region-aware (skips no-watch / extension noise),
35
+ // which the old per-element observers were not. It stays PAUSABLE (the
36
+ // default): a live-sync morph must NOT fire the hook, or the hook's own DOM
37
+ // writes would autosave → broadcast → morph the other tab → fire the same hook
38
+ // → broadcast back, looping forever. Skipping morphs is safe because the other
39
+ // tab already ran this identical hook, so the morph carries the result.
40
+ Mutation.onAnyChange({ require: 'observed' }, (changes) => {
41
+ const toFire = new Set();
42
+ for (const change of changes) {
43
+ let el = (change.type === 'add' || change.type === 'remove') ? change.parent : change.element;
44
+ while (el && el.nodeType === 1) {
45
+ if (el.hasAttribute('onmutation')) toFire.add(el);
46
+ el = el.parentElement;
47
+ }
48
+ }
49
+ toFire.forEach(executeMutation);
50
+ });
51
+ }
52
+
53
+ export { init };
54
+ export default init;
@@ -0,0 +1,37 @@
1
+ /*
2
+ [onpagemutation] / [onglobalmutation] - Trigger code when ANY element on the page changes
3
+
4
+ Usage:
5
+ <span onglobalmutation="this.textContent = All('li').length">0</span>
6
+ <span onpagemutation="this.textContent = All('li').length">0</span>
7
+
8
+ Both attributes are equivalent - onglobalmutation is the preferred name for clarity.
9
+ */
10
+ import Mutation from "../lib/mutation.js";
11
+
12
+ function init() {
13
+ const executeGlobalMutation = async element => {
14
+ try {
15
+ // Support both onglobalmutation and onpagemutation (legacy)
16
+ const code = element.getAttribute('onglobalmutation') || element.getAttribute('onpagemutation');
17
+ const asyncFn = new Function(`return (async function() { ${code} })`)();
18
+ await asyncFn.call(element);
19
+ } catch (error) {
20
+ console.error('Error in onglobalmutation/onpagemutation execution:', error);
21
+ }
22
+ };
23
+
24
+ // Pausable (the default): a live-sync morph must NOT trigger these hooks, or
25
+ // their DOM writes would autosave → broadcast → morph the other tab → fire the
26
+ // same hook → broadcast back, looping forever. Safe to skip morphs because the
27
+ // other tab already ran the identical hook and the morph carries the result.
28
+ Mutation.onAnyChange({
29
+ debounce: 200,
30
+ omitChangeDetails: true,
31
+ require: 'observed'
32
+ }, () => {
33
+ document.querySelectorAll('[onglobalmutation], [onpagemutation]').forEach(executeGlobalMutation);
34
+ });
35
+ }
36
+ export { init };
37
+ export default init;
@@ -0,0 +1,32 @@
1
+ import Mutation from "../lib/mutation.js";
2
+ import onLoad from "../lib/on-load.js";
3
+
4
+ function init() {
5
+ const executeRender = async (element) => {
6
+ try {
7
+ const code = element.getAttribute('onrender');
8
+ const asyncFn = new Function(`return (async function() { ${code} })`)();
9
+ await asyncFn.call(element);
10
+ } catch (error) {
11
+ console.error('Error in onrender execution:', error);
12
+ }
13
+ };
14
+
15
+ // Execute onrender on page load
16
+ onLoad(() => {
17
+ document.querySelectorAll('[onrender]').forEach(executeRender);
18
+ });
19
+
20
+ // Execute onrender when new elements are added
21
+ Mutation.onAddElement({
22
+ selectorFilter: "[onrender]",
23
+ debounce: 200,
24
+ require: 'observed',
25
+ pausable: false
26
+ }, (changes) => {
27
+ changes.forEach(({ element }) => executeRender(element));
28
+ });
29
+ }
30
+
31
+ export { init };
32
+ export default init;
@@ -72,7 +72,7 @@ import { isUserDrivenNow, markUserDriven } from './user-gesture.js';
72
72
 
73
73
  const dummyElem = document.createElement("div");
74
74
 
75
- const Mutation = {
75
+ const localMutation = {
76
76
  _callbacks: {
77
77
  anyChange: [],
78
78
  addOrRemove: [],
@@ -707,13 +707,30 @@ const Mutation = {
707
707
  }
708
708
  };
709
709
 
710
- // Signal consumers (e.g. hypercms ?cms=true auto-open) that Mutation is ready,
711
- // so they can react instead of polling. Wrapped so a dispatch failure can never
712
- // break the install.
713
- try {
714
- document.dispatchEvent(new CustomEvent('clay:mutation-ready', { detail: { Mutation } }));
715
- // vendor-compat: hypercms's readiness fast path listens for the legacy name
716
- document.dispatchEvent(new CustomEvent('hyperclay:mutation-ready', { detail: { Mutation } }));
717
- } catch {}
710
+ // Realm-global singleton, keyed by identity not module URL. A satellite bootstrap
711
+ // that imports this module from a different origin (apex vs www, clayjs.com vs
712
+ // jsdelivr, version-pinned URLs) evaluates a second module instance; without this,
713
+ // each would create its own MutationObserver and silently double-observe. The
714
+ // first instance to evaluate publishes its hub on window.__clayMutation; every
715
+ // later instance adopts it and never creates a second observer.
716
+ const existingHub = typeof window !== 'undefined' && window.__clayMutation;
717
+ const Mutation = existingHub || localMutation;
718
+
719
+ if (typeof window !== 'undefined' && !existingHub) {
720
+ window.__clayMutation = Mutation;
721
+ // Publish the vendor-compat mirror BEFORE the readiness dispatch: sap's mutation
722
+ // bridge reads window.hyperclay.Mutation when hyperclay:mutation-ready fires, so
723
+ // the hub must already be in place or sap keeps its own native observer.
724
+ window.hyperclay = window.hyperclay || {};
725
+ window.hyperclay.Mutation = Mutation;
726
+ // Signal consumers (e.g. hypercms ?cms=true auto-open, sap's late-hub listener)
727
+ // that Mutation is ready, so they can react instead of polling. Wrapped so a
728
+ // dispatch failure can never break the install.
729
+ try {
730
+ document.dispatchEvent(new CustomEvent('clay:mutation-ready', { detail: { Mutation } }));
731
+ // vendor-compat: hypercms's readiness fast path listens for the legacy name
732
+ document.dispatchEvent(new CustomEvent('hyperclay:mutation-ready', { detail: { Mutation } }));
733
+ } catch {}
734
+ }
718
735
 
719
736
  export default Mutation;
@@ -0,0 +1,9 @@
1
+ function onLoad (callback) {
2
+ if (document.readyState === "complete") {
3
+ callback();
4
+ } else {
5
+ window.addEventListener("load", callback);
6
+ }
7
+ }
8
+
9
+ export default onLoad;
@@ -21,9 +21,10 @@ export const PLUGIN_PATHS = {
21
21
  sortable: { path: "plugins/sortable.js", editOnly: true, default: false },
22
22
  undo: { path: "plugins/undo.js", editOnly: true, default: false },
23
23
  cms: { path: "vendor/hypercms.vendor.js", editOnly: false, default: false },
24
+ demo: { path: "plugins/demo.js", editOnly: false, default: false },
24
25
  };
25
26
 
26
- const PLUGIN_ORDER = ["richclay", "indicator", "sortable", "undo", "cms", "sync"];
27
+ const PLUGIN_ORDER = ["richclay", "indicator", "sortable", "undo", "cms", "sync", "demo"];
27
28
 
28
29
  function parseCsv(params, key, enabled, apply) {
29
30
  const raw = params.get(key);
package/src/loader.js CHANGED
@@ -108,6 +108,8 @@ function attachPluginMember(path, mod) {
108
108
  } else if (path === "vendor/hypercms.vendor.js") {
109
109
  clay.cms = mod.cms || mod.default;
110
110
  window.hyperclay.hypercms = clay.cms;
111
+ } else if (path === "plugins/demo.js") {
112
+ clay.demo = mod.demo;
111
113
  } else if (path === "vendor/richclay.vendor.js") {
112
114
  window.hyperclay.RichClay = mod.RichClay || mod.default;
113
115
  }