@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.
- package/NOTICE +19 -0
- package/README.md +2 -0
- package/_headers +2 -0
- package/all.js +24 -0
- package/clay-data.js +16 -0
- package/clay-dom.js +18 -0
- package/clay-events.js +28 -0
- package/clay-options.js +27 -0
- package/clay-ui.js +29 -0
- package/clay-utils.js +18 -0
- package/clay.js +17 -5
- package/package.json +11 -1
- package/sap.js +16 -0
- package/src/dom/all.js +440 -0
- package/src/dom/dom-helpers.js +218 -0
- package/src/dom/form-data.js +62 -0
- package/src/dom/insert-style-tag.js +109 -0
- package/src/dom/nearest.js +97 -0
- package/src/dom/pipe.js +1 -0
- package/src/events/index.js +22 -0
- package/src/events/onclickaway.js +27 -0
- package/src/events/onclickchildren.js +12 -0
- package/src/events/onclone.js +54 -0
- package/src/events/onmutation.js +54 -0
- package/src/events/onpagemutation.js +37 -0
- package/src/events/onrender.js +32 -0
- package/src/lib/mutation.js +26 -9
- package/src/lib/on-load.js +9 -0
- package/src/loader-logic.js +2 -1
- package/src/loader.js +2 -0
- package/src/options/options.js +253 -0
- package/src/plugins/demo.js +124 -0
- package/src/sync/live-sync.js +12 -1
- package/src/sync/merge-tags.js +36 -0
- package/src/ui/dialogs.js +206 -0
- package/src/ui/index.js +37 -0
- package/src/ui/modal.js +884 -0
- package/src/ui/toast.js +428 -0
- package/src/utils/copy-to-clipboard.js +27 -0
- package/src/utils/debounce.js +23 -0
- package/src/utils/index.js +10 -0
- package/src/utils/slugify.js +20 -0
- package/src/vendor/hyper-morph.vendor.js +2 -1
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Option Visibility
|
|
3
|
+
*
|
|
4
|
+
* Shows/hides elements based on `option:` and `option-not:` attributes.
|
|
5
|
+
*
|
|
6
|
+
* SYNTAX:
|
|
7
|
+
* option:name="value" - Show when ancestor has name="value"
|
|
8
|
+
* show-when:name="value" - Same as option: (the canonical, sapjs-aligned spelling)
|
|
9
|
+
* option:name="a|b|c" - Show when ancestor has name="a" OR "b" OR "c"
|
|
10
|
+
* option:name="" - Show when ancestor has name="" (empty value)
|
|
11
|
+
* option:name="|saved" - Show when ancestor has name="" OR name="saved"
|
|
12
|
+
* hide-when:name="value" - Inverse of show-when: hide WHEN ancestor has name="value"
|
|
13
|
+
* option-not:name="value" - Show when ancestor has name attr but ≠ "value"
|
|
14
|
+
* option-not:name="a|b" - Show when ancestor has name attr but ≠ "a" AND ≠ "b"
|
|
15
|
+
*
|
|
16
|
+
* show-when:/hide-when: are honored identically here and in sapjs, so visibility
|
|
17
|
+
* authored against either library just works. option:/option-not: stay permanent
|
|
18
|
+
* aliases.
|
|
19
|
+
*
|
|
20
|
+
* EXAMPLES:
|
|
21
|
+
* <div view="kanban">
|
|
22
|
+
* <button option:view="kanban">Visible when view=kanban</button>
|
|
23
|
+
* <button option:view="list">Hidden</button>
|
|
24
|
+
* </div>
|
|
25
|
+
*
|
|
26
|
+
* The ancestor carries the plain attribute (view="kanban"); descendants opt in with
|
|
27
|
+
* option:view / option-not:view. editmode/pageowner are stamped on <html> by clay.js,
|
|
28
|
+
* so option:/show-when:editmode is an edit-mode bridge that sapjs honors too.
|
|
29
|
+
*
|
|
30
|
+
* <div savestatus="error">
|
|
31
|
+
* <span option:savestatus="saved|error">Visible (matches error)</span>
|
|
32
|
+
* <span option-not:savestatus="saving">Visible (error ≠ saving)</span>
|
|
33
|
+
* </div>
|
|
34
|
+
*
|
|
35
|
+
* HOW IT WORKS:
|
|
36
|
+
* Uses a single conditional-hide rule per pattern. Elements get `display: none !important`
|
|
37
|
+
* ONLY when they are NOT inside a matching ancestor scope. When the ancestor condition
|
|
38
|
+
* IS met, the hide rule doesn't match, so the author's original display value
|
|
39
|
+
* (flex, grid, block, etc.) applies naturally — no recovery needed.
|
|
40
|
+
*
|
|
41
|
+
* BROWSER SUPPORT:
|
|
42
|
+
* Requires `:is()` and `:not()` with selector lists (~96% of browsers, 2021+).
|
|
43
|
+
* Falls back gracefully - elements remain visible if unsupported.
|
|
44
|
+
*
|
|
45
|
+
* TRADEOFFS:
|
|
46
|
+
* - Pro: Pure CSS after generation, zero JS overhead for toggling
|
|
47
|
+
* - Pro: No @layer or revert-layer — works with any author CSS (layered or unlayered)
|
|
48
|
+
* - Pro: One rule per pattern instead of two
|
|
49
|
+
* - Con: Pipe character `|` cannot be used as a literal value (reserved as OR delimiter)
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
import Mutation from "../lib/mutation.js";
|
|
53
|
+
import insertStyles from "../dom/insert-style-tag.js";
|
|
54
|
+
|
|
55
|
+
const STYLE_NAME = 'option-visibility';
|
|
56
|
+
|
|
57
|
+
// The visibility prefixes, longest-disambiguating first. show-when/option are the
|
|
58
|
+
// "show when match" pair; hide-when is its inverse; option-not is "present but not
|
|
59
|
+
// match". All four compile to ancestor-attribute CSS below.
|
|
60
|
+
const OPTION_PREFIXES = [
|
|
61
|
+
{ prefix: 'option-not', kind: 'show-not' },
|
|
62
|
+
{ prefix: 'option', kind: 'show' },
|
|
63
|
+
{ prefix: 'show-when', kind: 'show' },
|
|
64
|
+
{ prefix: 'hide-when', kind: 'hide' },
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Parse an option:/option-not:/show-when:/hide-when: attribute into a pattern object.
|
|
69
|
+
* Pure function for easy testing.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} attrName - Attribute name (e.g., 'show-when:editmode', 'option-not:status')
|
|
72
|
+
* @param {string} attrValue - Attribute value (e.g., 'true', 'a|b|c')
|
|
73
|
+
* @returns {Object|null} Pattern object or null if not a valid visibility attribute
|
|
74
|
+
*/
|
|
75
|
+
export function parseOptionAttribute(attrName, attrValue) {
|
|
76
|
+
let match = null;
|
|
77
|
+
for (const p of OPTION_PREFIXES) {
|
|
78
|
+
if (attrName.startsWith(p.prefix + ':')) { match = p; break; }
|
|
79
|
+
}
|
|
80
|
+
if (!match) return null;
|
|
81
|
+
|
|
82
|
+
const name = attrName.slice(match.prefix.length + 1);
|
|
83
|
+
const rawValue = attrValue;
|
|
84
|
+
// Split by pipe, keep empty strings (they match empty attribute values)
|
|
85
|
+
const values = rawValue.split('|');
|
|
86
|
+
|
|
87
|
+
return { name, rawValue, values, kind: match.kind, prefix: match.prefix, negated: match.kind === 'show-not' };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const optionVisibility = {
|
|
91
|
+
debug: false,
|
|
92
|
+
_started: false,
|
|
93
|
+
_unsubscribe: null,
|
|
94
|
+
|
|
95
|
+
log(...args) {
|
|
96
|
+
if (this.debug) console.log('[OptionVisibility]', ...args);
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Find all unique option:/option-not: patterns using XPath
|
|
101
|
+
* Returns array of { name, rawValue, values, negated }
|
|
102
|
+
*/
|
|
103
|
+
findOptionAttributes() {
|
|
104
|
+
const patterns = new Map();
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const snapshot = document.evaluate(
|
|
108
|
+
'//*[@*[starts-with(name(), "option:") or starts-with(name(), "option-not:") or starts-with(name(), "show-when:") or starts-with(name(), "hide-when:")]]',
|
|
109
|
+
document.documentElement,
|
|
110
|
+
null,
|
|
111
|
+
XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE,
|
|
112
|
+
null
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
for (let i = 0; i < snapshot.snapshotLength; i++) {
|
|
116
|
+
const el = snapshot.snapshotItem(i);
|
|
117
|
+
for (const attr of el.attributes) {
|
|
118
|
+
const pattern = parseOptionAttribute(attr.name, attr.value);
|
|
119
|
+
if (!pattern) continue;
|
|
120
|
+
|
|
121
|
+
// key includes the prefix so each authored spelling gets its own rule
|
|
122
|
+
// (option:tab and show-when:tab target different attributes, same scope).
|
|
123
|
+
const key = `${pattern.prefix}:${pattern.name}=${pattern.rawValue}`;
|
|
124
|
+
if (!patterns.has(key)) {
|
|
125
|
+
patterns.set(key, pattern);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
} catch (error) {
|
|
130
|
+
this.log('XPath error, falling back to empty', error);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return [...patterns.values()];
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Generate CSS rules for conditional hiding.
|
|
138
|
+
*
|
|
139
|
+
* Each pattern produces a single rule that hides the element ONLY when
|
|
140
|
+
* it's NOT inside a matching ancestor scope. When the condition IS met,
|
|
141
|
+
* the rule doesn't match, so the author's original display value applies.
|
|
142
|
+
*/
|
|
143
|
+
generateCSS(patterns) {
|
|
144
|
+
if (!patterns.length) return '';
|
|
145
|
+
|
|
146
|
+
return patterns.map((pattern) => {
|
|
147
|
+
const { name, rawValue, values } = pattern;
|
|
148
|
+
// Tolerate the legacy {negated} pattern shape so external callers keep working.
|
|
149
|
+
const kind = pattern.kind || (pattern.negated ? 'show-not' : 'show');
|
|
150
|
+
const prefix = pattern.prefix || (kind === 'show-not' ? 'option-not' : kind === 'hide' ? 'hide-when' : 'option');
|
|
151
|
+
const safeName = CSS.escape(name);
|
|
152
|
+
const safeRawValue = CSS.escape(rawValue);
|
|
153
|
+
const safePrefix = CSS.escape(prefix);
|
|
154
|
+
const attrSelector = `[${safePrefix}\\:${safeName}="${safeRawValue}"]`;
|
|
155
|
+
|
|
156
|
+
// The "matching scope": an ancestor (or self) carries name="value".
|
|
157
|
+
const self = values.map(v => `[${safeName}="${CSS.escape(v)}"]`);
|
|
158
|
+
const desc = values.map(v => `[${safeName}="${CSS.escape(v)}"] *`);
|
|
159
|
+
const matchScope = [...self, ...desc].join(',');
|
|
160
|
+
|
|
161
|
+
if (kind === 'hide') {
|
|
162
|
+
// hide-when: hide WHEN inside a matching scope — the inverse selector shape.
|
|
163
|
+
return `${attrSelector}:is(${matchScope}){display:none!important}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let scopeSelectors;
|
|
167
|
+
if (kind === 'show-not') {
|
|
168
|
+
// option-not: active when ancestor has attr but NOT any of the values
|
|
169
|
+
const notList = values.map(v => `[${safeName}="${CSS.escape(v)}"]`).join(',');
|
|
170
|
+
const scope = `[${safeName}]:not(${notList})`;
|
|
171
|
+
scopeSelectors = `${scope},${scope} *`;
|
|
172
|
+
} else {
|
|
173
|
+
// option / show-when: active when ancestor has ANY of the values
|
|
174
|
+
scopeSelectors = matchScope;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return `${attrSelector}:not(:is(${scopeSelectors})){display:none!important}`;
|
|
178
|
+
}).join('');
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Update the style element with current rules
|
|
183
|
+
*/
|
|
184
|
+
update() {
|
|
185
|
+
try {
|
|
186
|
+
const attributes = this.findOptionAttributes();
|
|
187
|
+
const css = this.generateCSS(attributes);
|
|
188
|
+
insertStyles(STYLE_NAME, css, (style) => {
|
|
189
|
+
style.setAttribute('clay', 'no-watch');
|
|
190
|
+
});
|
|
191
|
+
this.log(`Generated ${attributes.length} rules`);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
console.error('[OptionVisibility] Error generating rules:', error);
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
start() {
|
|
198
|
+
if (this._started) return;
|
|
199
|
+
|
|
200
|
+
if (document.readyState === 'loading') {
|
|
201
|
+
document.addEventListener('DOMContentLoaded', () => this.start(), { once: true });
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
this._started = true;
|
|
206
|
+
|
|
207
|
+
this.update();
|
|
208
|
+
|
|
209
|
+
// selectorFilter only triggers on option:/option-not: attribute changes (new patterns).
|
|
210
|
+
// Ancestor attribute changes (e.g., editmode="true" -> "false") are handled
|
|
211
|
+
// automatically by the browser - CSS rules re-evaluate when attributes change.
|
|
212
|
+
this._unsubscribe = Mutation.onAnyChange({
|
|
213
|
+
debounce: 200,
|
|
214
|
+
selectorFilter: el => [...el.attributes].some(attr =>
|
|
215
|
+
attr.name.startsWith('option:') || attr.name.startsWith('option-not:') ||
|
|
216
|
+
attr.name.startsWith('show-when:') || attr.name.startsWith('hide-when:')
|
|
217
|
+
),
|
|
218
|
+
omitChangeDetails: true,
|
|
219
|
+
require: 'observed',
|
|
220
|
+
pausable: false
|
|
221
|
+
}, () => this.update());
|
|
222
|
+
|
|
223
|
+
this.log('Started');
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
stop() {
|
|
227
|
+
if (!this._started) return;
|
|
228
|
+
|
|
229
|
+
this._started = false;
|
|
230
|
+
|
|
231
|
+
if (this._unsubscribe) {
|
|
232
|
+
this._unsubscribe();
|
|
233
|
+
this._unsubscribe = null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const style = document.querySelector(`style[data-name="${STYLE_NAME}"]`);
|
|
237
|
+
if (style) style.remove();
|
|
238
|
+
|
|
239
|
+
this.log('Stopped');
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
// vendor-compat mirror: sap reads window.hyperclay.optionVisibility at runtime.
|
|
244
|
+
window.hyperclay = window.hyperclay || {};
|
|
245
|
+
window.hyperclay.optionVisibility = optionVisibility;
|
|
246
|
+
|
|
247
|
+
export default optionVisibility;
|
|
248
|
+
|
|
249
|
+
export function init() {
|
|
250
|
+
optionVisibility.start();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
init();
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Browser-backed demo mode: the page saves into the visitor's localStorage
|
|
2
|
+
// instead of a server, and restores itself on the next load. Setup:
|
|
3
|
+
//
|
|
4
|
+
// <html autosave demo-key="my-page-v1"> <!-- both attributes optional -->
|
|
5
|
+
// <script>window.clayEditMode = true;</script> <!-- before the clay.js tag -->
|
|
6
|
+
// <script src="https://clayjs.com/clay.js?plugins=demo"></script>
|
|
7
|
+
//
|
|
8
|
+
// The fetch shim answers POST /_/save the way a clayjs server would, so the
|
|
9
|
+
// whole real pipeline (savestatus, events, autosave, ⌘S) runs unchanged. The
|
|
10
|
+
// stored body comes from the clay:snapshot-ready clone (pre-strip, the same
|
|
11
|
+
// source live-sync morphs from), not the POSTed payload: the payload has
|
|
12
|
+
// richclay's runtime state stripped, and morphing toward it would disable the
|
|
13
|
+
// live editors. richclay chrome and squire artifacts are dropped from storage
|
|
14
|
+
// because richclay recreates them. The shim answers after a short delay:
|
|
15
|
+
// localStorage is instant, and the saving state only shows past save.js's
|
|
16
|
+
// 500ms debounce, so an instant answer would keep it invisible.
|
|
17
|
+
//
|
|
18
|
+
// The storage key includes <html demo-key> (fallback: the page's pathname).
|
|
19
|
+
// Bump the attribute whenever the page's authored content changes, or
|
|
20
|
+
// returning visitors' old saved bodies morph over the new page.
|
|
21
|
+
|
|
22
|
+
import { morph } from "../vendor/hyper-morph.vendor.js";
|
|
23
|
+
|
|
24
|
+
const KEY = "clay:demo:" +
|
|
25
|
+
(document.documentElement.getAttribute("demo-key") || window.location.pathname);
|
|
26
|
+
const LATENCY_MS = 800;
|
|
27
|
+
const CHROME_SELECTOR = [
|
|
28
|
+
"[save-remove]",
|
|
29
|
+
"[data-richclay-toolbar]",
|
|
30
|
+
"[data-richclay-menu]",
|
|
31
|
+
"[data-richclay-dialog]",
|
|
32
|
+
"[data-richclay-live]",
|
|
33
|
+
"[data-richclay-float]",
|
|
34
|
+
"#squire-selection-start",
|
|
35
|
+
"#squire-selection-end",
|
|
36
|
+
".squire-image-resize-container",
|
|
37
|
+
].join(",");
|
|
38
|
+
|
|
39
|
+
let failNextSave = false;
|
|
40
|
+
let snapshotBody = null;
|
|
41
|
+
|
|
42
|
+
document.addEventListener("clay:snapshot-ready", (event) => {
|
|
43
|
+
snapshotBody = event.detail.documentElement.querySelector("body")?.outerHTML || null;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
function postedHtml(raw) {
|
|
47
|
+
const text = typeof raw === "string" ? raw : "";
|
|
48
|
+
try {
|
|
49
|
+
const envelope = JSON.parse(text);
|
|
50
|
+
if (envelope && typeof envelope === "object") {
|
|
51
|
+
return envelope.snapshotHtml || envelope.content || text;
|
|
52
|
+
}
|
|
53
|
+
} catch {}
|
|
54
|
+
return text;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const realFetch = window.fetch.bind(window);
|
|
58
|
+
|
|
59
|
+
function isSaveUrl(input) {
|
|
60
|
+
const target = typeof input === "string" ? input : (input && input.url) || "";
|
|
61
|
+
try {
|
|
62
|
+
const path = new URL(target, window.location.href).pathname;
|
|
63
|
+
return path === "/_/save" || path.startsWith("/_/save/");
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
window.fetch = (url, options = {}) => {
|
|
70
|
+
const method = (options.method || (url && url.method) || "GET").toUpperCase();
|
|
71
|
+
if (method !== "POST" || !isSaveUrl(url)) {
|
|
72
|
+
return realFetch(url, options);
|
|
73
|
+
}
|
|
74
|
+
return new Promise((resolve) => {
|
|
75
|
+
setTimeout(() => {
|
|
76
|
+
if (failNextSave) {
|
|
77
|
+
failNextSave = false;
|
|
78
|
+
resolve(new Response(JSON.stringify({ msg: "Server not responding", msgType: "error" }), {
|
|
79
|
+
status: 503,
|
|
80
|
+
headers: { "Content-Type": "application/json" },
|
|
81
|
+
}));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const html = snapshotBody || postedHtml(options.body);
|
|
85
|
+
const doc = new DOMParser().parseFromString(html, "text/html");
|
|
86
|
+
doc.querySelectorAll(CHROME_SELECTOR).forEach((node) => node.remove());
|
|
87
|
+
try {
|
|
88
|
+
localStorage.setItem(KEY, doc.body.outerHTML);
|
|
89
|
+
} catch {
|
|
90
|
+
resolve(new Response(JSON.stringify({ msg: "Couldn't save in this browser (storage is full or blocked)", msgType: "error" }), {
|
|
91
|
+
status: 507,
|
|
92
|
+
headers: { "Content-Type": "application/json" },
|
|
93
|
+
}));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
resolve(new Response(JSON.stringify({ msg: "Saved in your browser", msgType: "success" }), {
|
|
97
|
+
status: 200,
|
|
98
|
+
headers: { "Content-Type": "application/json" },
|
|
99
|
+
}));
|
|
100
|
+
}, LATENCY_MS);
|
|
101
|
+
});
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
async function restore() {
|
|
105
|
+
const saved = localStorage.getItem(KEY);
|
|
106
|
+
if (!saved) return;
|
|
107
|
+
const doc = new DOMParser().parseFromString(saved, "text/html");
|
|
108
|
+
await morph(document.body, doc.body, {
|
|
109
|
+
morphStyle: "outerHTML",
|
|
110
|
+
ignoreActiveValue: true,
|
|
111
|
+
scripts: { handle: true, matchMode: "smart" },
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const ready = restore();
|
|
116
|
+
|
|
117
|
+
export const demo = {
|
|
118
|
+
key: KEY,
|
|
119
|
+
failNext() { failNextSave = true; },
|
|
120
|
+
reset() {
|
|
121
|
+
localStorage.removeItem(KEY);
|
|
122
|
+
window.location.reload();
|
|
123
|
+
},
|
|
124
|
+
};
|
package/src/sync/live-sync.js
CHANGED
|
@@ -39,6 +39,7 @@ import { HyperMorph, morph } from "../vendor/hyper-morph.vendor.js";
|
|
|
39
39
|
import Mutation from "../lib/mutation.js";
|
|
40
40
|
import { isSnapshotRemoved } from "../lib/region-policy.js";
|
|
41
41
|
import { isEditMode } from "../core/is-edit-mode.js";
|
|
42
|
+
import { mergeTagRecognizers } from "./merge-tags.js";
|
|
42
43
|
|
|
43
44
|
class LiveSync {
|
|
44
45
|
constructor() {
|
|
@@ -639,7 +640,17 @@ class LiveSync {
|
|
|
639
640
|
morphStyle: 'outerHTML',
|
|
640
641
|
ignoreActiveValue: true,
|
|
641
642
|
head: { style: 'merge' },
|
|
642
|
-
|
|
643
|
+
// mergeBase: mergeable script tags ([merge] + rules tags) three-way
|
|
644
|
+
// merge against the last synced state instead of being clobbered by
|
|
645
|
+
// the incoming save; lastHtml is exactly that base (set after every
|
|
646
|
+
// own save and every applied morph). Null on the first update →
|
|
647
|
+
// two-way merge, which still keeps local-only keys.
|
|
648
|
+
scripts: {
|
|
649
|
+
handle: true,
|
|
650
|
+
matchMode: 'smart',
|
|
651
|
+
mergeBase: this.lastHtml,
|
|
652
|
+
mergeTags: mergeTagRecognizers
|
|
653
|
+
},
|
|
643
654
|
key,
|
|
644
655
|
callbacks: { afterNodeMorphed }
|
|
645
656
|
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* merge-tags.js — extra mergeable script-tag recognizers for hyper-morph.
|
|
3
|
+
*
|
|
4
|
+
* hyper-morph's built-in recognizer handles `merge="<name>"` tags with
|
|
5
|
+
* relaxed JSON. This adds the Hyperclay rules-tag family (hypercms and
|
|
6
|
+
* hyper-html-api): `script[data-rules-name][data-rules-version]`, whose
|
|
7
|
+
* bodies use the rules dialect (unquoted selector barewords become strings).
|
|
8
|
+
* Identity is the version plus the sorted name-token list, so token order
|
|
9
|
+
* never matters and differing schema versions never pair. Parsing goes
|
|
10
|
+
* through hyper-morph's parseRulesRelaxed, the canonical port of the
|
|
11
|
+
* engine's parseRelaxed, so the tags merge exactly as they load.
|
|
12
|
+
*
|
|
13
|
+
* Pass as `scripts: { mergeTags: mergeTagRecognizers }` on morph configs.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { HyperMorph } from "../vendor/hyper-morph.vendor.js";
|
|
17
|
+
|
|
18
|
+
export const mergeTagRecognizers = [
|
|
19
|
+
{
|
|
20
|
+
match: (el) => el.hasAttribute("data-rules-name"),
|
|
21
|
+
identity: (el) => {
|
|
22
|
+
const names = (el.getAttribute("data-rules-name") || "")
|
|
23
|
+
.split(/\s+/)
|
|
24
|
+
.filter(Boolean)
|
|
25
|
+
.sort()
|
|
26
|
+
.join(" ");
|
|
27
|
+
if (!names) return null;
|
|
28
|
+
return (
|
|
29
|
+
"rules:" + (el.getAttribute("data-rules-version") || "") + ":" + names
|
|
30
|
+
);
|
|
31
|
+
},
|
|
32
|
+
parse: HyperMorph.parseRulesRelaxed,
|
|
33
|
+
},
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
export default mergeTagRecognizers;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import themodal from "./modal.js";
|
|
2
|
+
import onDomReady from "../lib/dom-ready.js";
|
|
3
|
+
import toast from "./toast.js";
|
|
4
|
+
import copyToClipboard from "../utils/copy-to-clipboard.js";
|
|
5
|
+
|
|
6
|
+
const CLOSE_BUTTON_SVG = `<svg viewBox="0 0 134 134" fill="none" xmlns="http://www.w3.org/2000/svg"><path class="micromodal__close-bg" d="M132 132.5 1 1.5h131v131Z" /><path class="micromodal__close-x" fill-rule="evenodd" clip-rule="evenodd" d="M0 0h3v1.5h1.5V3H6v1.5h1.5V6H9v1.5h1.5V9H12v1.5h1.5V12H15v1.5h1.5V15H18v1.5h1.5V18H21v1.5h1.5V21H24v1.5h1.5V24H27v1.5h1.5V27H30v1.5h1.5V30H33v1.5h1.5V33H36v1.5h1.5V36H39v1.5h1.5V39H42v1.5h1.5V42H45v1.5h1.5V45H48v1.5h1.5V48H51v1.5h1.5V51H54v1.5h1.5V54H57v1.5h1.5V57H60v1.5h1.5V60H63v1.5h1.5V63H66v1.5h1.5V66H69v1.5h1.5V69H72v1.5h1.5V72H75v1.5h1.5V75H78v1.5h1.5V78H81v1.5h1.5V81H84v1.5h1.5V84H87v1.5h1.5V87H90v1.5h1.5V90H93v1.5h1.5V93H96v1.5h1.5V96H99v1.5h1.5V99h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v3h-3V132H129v-1.5h-1.5V129H126v-1.5h-1.5V126H123v-1.5h-1.5V123H120v-1.5h-1.5V120H117v-1.5h-1.5V117H114v-1.5h-1.5V114H111v-1.5h-1.5V111H108v-1.5h-1.5V108H105v-1.5h-1.5V105H102v-1.5h-1.5V102H99v-1.5h-1.5V99H96v-1.5h-1.5V96H93v-1.5h-1.5V93H90v-1.5h-1.5V90H87v-1.5h-1.5V87H84v-1.5h-1.5V84H81v-1.5h-1.5V81H78v-1.5h-1.5V78H75v-1.5h-1.5V75H72v-1.5h-1.5V72H69v-1.5h-1.5V69H66v-1.5h-1.5V66H63v-1.5h-1.5V63H60v-1.5h-1.5V60H57v-1.5h-1.5V57H54v-1.5h-1.5V54H51v-1.5h-1.5V51H48v-1.5h-1.5V48H45v-1.5h-1.5V45H42v-1.5h-1.5V42H39v-1.5h-1.5V39H36v-1.5h-1.5V36H33v-1.5h-1.5V33H30v-1.5h-1.5V30H27v-1.5h-1.5V27H24v-1.5h-1.5V24H21v-1.5h-1.5V21H18v-1.5h-1.5V18H15v-1.5h-1.5V15H12v-1.5h-1.5V12H9v-1.5H7.5V9H6V7.5H4.5V6H3V4.5H1.5V3H0V0ZM108.8 22h5.2v5.1h-2.6v2.6H109v2.6h-2.6v2.6h-2.6v2.5h-2.6v5.2h2.6V45h2.6v2.6h2.6v2.6h2.5v2.6h2.6V58h-5.1v-2.6h-2.6V53h-2.6v-2.6h-2.6v-2.6h-2.5v-2.6h-5.2v2.6H91v2.6h-2.6v2.6h-2.6v2.5h-2.6V58H78v-5.1h2.6v-2.6H83v-2.6h2.6v-2.6h2.6v-2.5h2.6v-5.2h-2.6V35h-2.6v-2.6h-2.6v-2.6h-2.5v-2.6H78V22h5.2v2.6h2.5V27h2.6v2.6h2.6v2.6h2.5v2.6h5.2v-2.6h2.5v-2.6h2.6v-2.6h2.6v-2.5h2.5V22Z" /></svg>`;
|
|
7
|
+
const CONFIRM_BUTTON_SVG = `<div style="width: 28px;"><svg viewBox="0 0 60 33" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M34.5 0.75H43.5V5.25H49V9.75H54.5V14.25H60V18.75H54.5V23.25H49V27.75H43.5V32.25H34.5V27.75H40V23.25H45.5V18.75H0V14.25H45.5V9.75H40V5.25H34.5V0.75Z" fill="white"/></svg></div>`;
|
|
8
|
+
|
|
9
|
+
function createModal(promptText, yesCallback, extraContent = "", includeInput = false, defaultValue = "") {
|
|
10
|
+
const inputHtml = includeInput
|
|
11
|
+
? `<div><input class="micromodal__input" type="text" value="${defaultValue}" required></div>`
|
|
12
|
+
: "";
|
|
13
|
+
|
|
14
|
+
themodal.html = `<div>
|
|
15
|
+
<div class="micromodal__heading">${promptText}</div>
|
|
16
|
+
${inputHtml}
|
|
17
|
+
${extraContent}
|
|
18
|
+
</div>`;
|
|
19
|
+
themodal.closeHtml = CLOSE_BUTTON_SVG;
|
|
20
|
+
themodal.yes = CONFIRM_BUTTON_SVG;
|
|
21
|
+
|
|
22
|
+
const promise = new Promise((resolve, reject) => {
|
|
23
|
+
themodal.onYes(() => {
|
|
24
|
+
let promptResult;
|
|
25
|
+
if (includeInput) {
|
|
26
|
+
promptResult = document.querySelector(".micromodal__input").value;
|
|
27
|
+
if (!promptResult) return false; // keep modal open on empty input
|
|
28
|
+
}
|
|
29
|
+
// Run the validation callback synchronously so a throw can keep the
|
|
30
|
+
// modal open (callers rely on this — e.g. delete-site confirms by
|
|
31
|
+
// throwing when the typed name doesn't match).
|
|
32
|
+
if (yesCallback) {
|
|
33
|
+
try {
|
|
34
|
+
yesCallback(promptResult);
|
|
35
|
+
} catch (err) {
|
|
36
|
+
toast(err.message || 'An error occurred', 'error');
|
|
37
|
+
return false; // keep modal open, user can retry
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// Defer resolve so downstream .then() handlers don't fire inside this
|
|
41
|
+
// modal's onYes loop — themodal is a singleton, and chained ask()/
|
|
42
|
+
// consent() calls need a clean themodal to set up their state.
|
|
43
|
+
setTimeout(() => resolve(promptResult), 0);
|
|
44
|
+
return true; // allow modal to close
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
themodal.onNo = () => {
|
|
48
|
+
setTimeout(reject, 0);
|
|
49
|
+
};
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
themodal.open();
|
|
53
|
+
|
|
54
|
+
setTimeout(() => {
|
|
55
|
+
const modalContainer = document.querySelector('.micromodal-parent');
|
|
56
|
+
if (modalContainer) {
|
|
57
|
+
modalContainer.addEventListener('click', (event) => {
|
|
58
|
+
const btn = event.target.closest('[data-copy]');
|
|
59
|
+
if (btn) {
|
|
60
|
+
copyToClipboard(btn.dataset.copy);
|
|
61
|
+
toast('Copied', 'success');
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}, 0);
|
|
66
|
+
|
|
67
|
+
// Fire-and-forget callers (e.g. consent(msg, cb) with no await) don't consume
|
|
68
|
+
// the reject path; dismissal now rejects, so swallow it here to avoid an
|
|
69
|
+
// unhandled rejection. Awaiters still observe the rejection via their await.
|
|
70
|
+
promise.catch(() => {});
|
|
71
|
+
|
|
72
|
+
return promise;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Public API functions
|
|
76
|
+
export function ask(promptText, yesCallback, defaultValue = "", extraContent = "") {
|
|
77
|
+
return createModal(promptText, yesCallback, extraContent, true, defaultValue);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function consent(promptText, yesCallback, extraContent = "") {
|
|
81
|
+
return createModal(promptText, yesCallback, extraContent, false);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Display an informational modal with a title and optional content paragraphs
|
|
86
|
+
* @param {string} promptText - The title/heading text
|
|
87
|
+
* @param {...string} content - Additional content paragraphs (variadic)
|
|
88
|
+
* @returns {Promise} Resolves when user confirms, rejects on close
|
|
89
|
+
*/
|
|
90
|
+
export function tell(promptText, ...content) {
|
|
91
|
+
const contentHtml = content.length > 0
|
|
92
|
+
? content.map(c => `<div class="micromodal__tell-content">${c}</div>`).join("")
|
|
93
|
+
: "";
|
|
94
|
+
|
|
95
|
+
themodal.html = `<div class="micromodal__tell">
|
|
96
|
+
<div class="micromodal__tell-title">${promptText}</div>
|
|
97
|
+
${contentHtml}
|
|
98
|
+
</div>`;
|
|
99
|
+
themodal.closeHtml = CLOSE_BUTTON_SVG;
|
|
100
|
+
themodal.yes = CONFIRM_BUTTON_SVG;
|
|
101
|
+
|
|
102
|
+
const promise = new Promise((resolve, reject) => {
|
|
103
|
+
themodal.onYes(() => {
|
|
104
|
+
setTimeout(resolve, 0);
|
|
105
|
+
return true;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
themodal.onNo = () => {
|
|
109
|
+
setTimeout(reject, 0);
|
|
110
|
+
};
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
themodal.open();
|
|
114
|
+
|
|
115
|
+
// See createModal: swallow the reject for fire-and-forget tell() callers;
|
|
116
|
+
// awaiters still observe it via their await.
|
|
117
|
+
promise.catch(() => {});
|
|
118
|
+
|
|
119
|
+
return promise;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Display a modal with a code snippet and copy functionality
|
|
124
|
+
* @param {string} title - The modal heading
|
|
125
|
+
* @param {string} content - The code to display
|
|
126
|
+
* @param {string} extraContent - Optional raw HTML rendered below the copy button.
|
|
127
|
+
* Callers style their own container; use `<div class="snippet-warning">…</div>`
|
|
128
|
+
* for the standard yellow-bordered warning box.
|
|
129
|
+
*/
|
|
130
|
+
export function snippet(title, content, extraContent = '') {
|
|
131
|
+
|
|
132
|
+
// Create the modal content with copy button
|
|
133
|
+
const modalContent = `
|
|
134
|
+
<div class="snippet-code-block">
|
|
135
|
+
<pre>${content}</pre>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<button type="button" class="micromodal__secondary-btn copy-snippet-btn" style="margin-bottom: 14px;">copy</button>
|
|
139
|
+
|
|
140
|
+
${extraContent || ''}
|
|
141
|
+
`;
|
|
142
|
+
|
|
143
|
+
// Use the existing modal system
|
|
144
|
+
themodal.html = `<div>
|
|
145
|
+
<div class="micromodal__heading">${title}</div>
|
|
146
|
+
${modalContent}
|
|
147
|
+
</div>`;
|
|
148
|
+
|
|
149
|
+
themodal.closeHtml = CLOSE_BUTTON_SVG;
|
|
150
|
+
themodal.yes = '';
|
|
151
|
+
|
|
152
|
+
const promise = new Promise((resolve) => {
|
|
153
|
+
// Local copy function
|
|
154
|
+
const handleCopy = function(event) {
|
|
155
|
+
if (event.target.closest('.copy-snippet-btn')) {
|
|
156
|
+
copyToClipboard(content);
|
|
157
|
+
toast('Copied to clipboard!', 'success');
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
// Add event listener to the modal container after it opens
|
|
162
|
+
setTimeout(() => {
|
|
163
|
+
const modalContainer = document.querySelector('.micromodal-parent');
|
|
164
|
+
if (modalContainer) {
|
|
165
|
+
modalContainer.addEventListener('click', handleCopy);
|
|
166
|
+
}
|
|
167
|
+
}, 0);
|
|
168
|
+
|
|
169
|
+
themodal.onYes(() => {
|
|
170
|
+
// Clean up the event listener synchronously — the DOM may be torn down
|
|
171
|
+
// before our deferred resolve fires otherwise.
|
|
172
|
+
const modalContainer = document.querySelector('.micromodal-parent');
|
|
173
|
+
if (modalContainer) {
|
|
174
|
+
modalContainer.removeEventListener('click', handleCopy);
|
|
175
|
+
}
|
|
176
|
+
setTimeout(resolve, 0);
|
|
177
|
+
return true;
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
themodal.onNo = () => {
|
|
181
|
+
const modalContainer = document.querySelector('.micromodal-parent');
|
|
182
|
+
if (modalContainer) {
|
|
183
|
+
modalContainer.removeEventListener('click', handleCopy);
|
|
184
|
+
}
|
|
185
|
+
setTimeout(resolve, 0);
|
|
186
|
+
};
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
themodal.open();
|
|
190
|
+
|
|
191
|
+
return promise;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Auto-initialize - cleanup any leftover modal elements
|
|
195
|
+
export function init() {
|
|
196
|
+
onDomReady(() => {
|
|
197
|
+
const micromodalParentElem = document.querySelector(".micromodal-parent");
|
|
198
|
+
if (micromodalParentElem) {
|
|
199
|
+
micromodalParentElem.remove();
|
|
200
|
+
document.body.style.overflow = "";
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Auto-init when module is imported
|
|
206
|
+
init();
|
package/src/ui/index.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import toast, { toastPersistent } from "./toast.js";
|
|
2
|
+
import themodal from "./modal.js";
|
|
3
|
+
import { ask, consent, tell, snippet } from "./dialogs.js";
|
|
4
|
+
|
|
5
|
+
// Attach the public surface explicitly (§2.3). We do NOT rely on toast.js /
|
|
6
|
+
// dialogs.js evaluation side effects — their window auto-exports are stripped.
|
|
7
|
+
const clay = (window.clay = window.clay || {});
|
|
8
|
+
clay.toast = toast;
|
|
9
|
+
clay.toastPersistent = toastPersistent;
|
|
10
|
+
clay.ask = ask;
|
|
11
|
+
clay.confirm = consent; // hyperclayjs `consent` → clayjs `clay.confirm`
|
|
12
|
+
clay.tell = tell;
|
|
13
|
+
clay.snippet = snippet;
|
|
14
|
+
clay.modal = themodal;
|
|
15
|
+
|
|
16
|
+
// Toast globals are a carve-out because core's live-sync soft-reads exactly these
|
|
17
|
+
// names. Only set them when absent, honoring "already have a toast library? keep yours".
|
|
18
|
+
if (typeof window.toast === "undefined") window.toast = toast;
|
|
19
|
+
if (typeof window.toastPersistent === "undefined") window.toastPersistent = toastPersistent;
|
|
20
|
+
|
|
21
|
+
// vendor-compat mirror: sap reads window.hyperclay.consent (the legacy name for
|
|
22
|
+
// clay.confirm). Never CREATE window.hyperclay here: sap's mutation bridge treats
|
|
23
|
+
// any truthy window.hyperclay as "a hub is coming" and waits forever for it. Core
|
|
24
|
+
// creates the object (with Mutation) and fires clay:mutation-ready; attach then.
|
|
25
|
+
function mirrorConsent() {
|
|
26
|
+
if (window.hyperclay && !window.hyperclay.consent) window.hyperclay.consent = consent;
|
|
27
|
+
}
|
|
28
|
+
if (window.hyperclay) mirrorConsent();
|
|
29
|
+
else document.addEventListener("clay:mutation-ready", mirrorConsent, { once: true });
|
|
30
|
+
|
|
31
|
+
// Automatic save feedback. clay:save-saving stays deliberately silent (a toast for
|
|
32
|
+
// a sub-second transient is noise). Keep your own toast lib? The events are public.
|
|
33
|
+
document.addEventListener("clay:save-saved", () => toast("Saved", "success"));
|
|
34
|
+
document.addEventListener("clay:save-error", () => toastPersistent("Couldn't save", "error"));
|
|
35
|
+
document.addEventListener("clay:save-offline", () => toastPersistent("Offline, not saved", "warning"));
|
|
36
|
+
|
|
37
|
+
export { toast, toastPersistent, ask, consent, tell, snippet, themodal };
|