@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,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
+ };
@@ -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
- scripts: { handle: true, matchMode: 'smart' },
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();
@@ -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 };