hyperclayjs 1.22.1 → 1.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.
package/README.md CHANGED
@@ -59,8 +59,8 @@ import 'hyperclayjs/presets/standard.js';
59
59
  |--------|------|-------------|
60
60
  | autosave | 1.4KB | Auto-save on DOM changes |
61
61
  | edit-mode | 1.8KB | Toggle edit mode on hyperclay on/off |
62
- | edit-mode-helpers | 8.3KB | Admin-only functionality: [edit-mode-input], [edit-mode-resource], [edit-mode-onclick] |
63
- | option-visibility | 7.8KB | Dynamic show/hide based on ancestor state with option:attribute="value" |
62
+ | edit-mode-helpers | 6.8KB | Admin-only functionality: [viewmode:disabled], [editmode:resource], [editmode:onclick] |
63
+ | option-visibility | 7.1KB | Dynamic show/hide based on ancestor state with option:attribute="value" |
64
64
  | persist | 2.4KB | Persist input/select/textarea values to the DOM with [persist] attribute |
65
65
  | save-core | 8.9KB | Basic save function only - hyperclay.savePage() |
66
66
  | save-system | 13.4KB | CMD+S, [trigger-save] button, savestatus attribute |
@@ -86,8 +86,8 @@ import 'hyperclayjs/presets/standard.js';
86
86
  | Module | Size | Description |
87
87
  |--------|------|-------------|
88
88
  | dialogs | 7.7KB | ask(), consent(), tell(), snippet() dialog functions |
89
- | the-modal | 21.5KB | Full modal window creation system - window.theModal |
90
- | toast | 15.9KB | Success/error message notifications, toast(msg, msgType) |
89
+ | the-modal | 22.4KB | Full modal window creation system - window.theModal |
90
+ | toast | 15.8KB | Success/error message notifications, toast(msg, msgType) |
91
91
 
92
92
  ### Utilities (Core utilities (often auto-included))
93
93
 
@@ -96,7 +96,7 @@ import 'hyperclayjs/presets/standard.js';
96
96
  | cache-bust | 0.6KB | Cache-bust href/src attributes |
97
97
  | cookie | 1.4KB | Cookie management (often auto-included) |
98
98
  | debounce | 0.4KB | Function debouncing |
99
- | mutation | 13.5KB | DOM mutation observation (often auto-included) |
99
+ | mutation | 13.7KB | DOM mutation observation (often auto-included) |
100
100
  | nearest | 3.4KB | Find nearest elements (often auto-included) |
101
101
  | throttle | 0.8KB | Function throttling |
102
102
 
@@ -122,7 +122,7 @@ import 'hyperclayjs/presets/standard.js';
122
122
  | Module | Size | Description |
123
123
  |--------|------|-------------|
124
124
  | file-upload | 10.7KB | File upload with progress |
125
- | live-sync | 11.5KB | Real-time DOM sync across browsers |
125
+ | live-sync | 11.7KB | Real-time DOM sync across browsers |
126
126
  | send-message | 1.3KB | Message sending utility |
127
127
 
128
128
  ### Vendor Libraries (Third-party libraries)
@@ -133,17 +133,17 @@ import 'hyperclayjs/presets/standard.js';
133
133
 
134
134
  ## Presets
135
135
 
136
- ### Minimal (~58.6KB)
136
+ ### Minimal (~57KB)
137
137
  Essential features for basic editing
138
138
 
139
139
  **Modules:** `save-core`, `snapshot`, `save-system`, `edit-mode-helpers`, `toast`, `save-toast`, `export-to-window`, `view-mode-excludes-edit-modules`
140
140
 
141
- ### Standard (~81.5KB)
141
+ ### Standard (~79.2KB)
142
142
  Standard feature set for most use cases
143
143
 
144
144
  **Modules:** `save-core`, `snapshot`, `save-system`, `unsaved-warning`, `edit-mode-helpers`, `persist`, `option-visibility`, `event-attrs`, `dom-helpers`, `toast`, `save-toast`, `export-to-window`, `view-mode-excludes-edit-modules`
145
145
 
146
- ### Everything (~218.6KB)
146
+ ### Everything (~217.6KB)
147
147
  All available features
148
148
 
149
149
  Includes all available modules across all categories.
@@ -316,9 +316,9 @@ tell("Welcome to Hyperclay!");
316
316
 
317
317
  ```html
318
318
  <!-- Only visible/editable in edit mode -->
319
- <div contenteditable edit-mode-contenteditable>Admin can edit this</div>
320
- <input type="text" edit-mode-input>
321
- <script edit-mode-resource>console.log('Admin only');</script>
319
+ <div contenteditable editmode:contenteditable>Admin can edit this</div>
320
+ <input type="text" viewmode:disabled>
321
+ <script editmode:resource>console.log('Admin only');</script>
322
322
  ```
323
323
 
324
324
  ## Module Creation
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hyperclayjs",
3
- "version": "1.22.1",
3
+ "version": "1.23.1",
4
4
  "description": "Modular JavaScript library for building interactive malleable HTML files with Hyperclay",
5
5
  "type": "module",
6
6
  "main": "src/hyperclay.js",
@@ -321,7 +321,9 @@ class LiveSync {
321
321
 
322
322
  this._log('applyUpdate - morph complete, resuming mutations');
323
323
  Mutation.resume();
324
- this.isPaused = false;
324
+ // Defer past microtask boundary — MutationObserver callbacks and any async
325
+ // morph side-effects fire before this, so isPaused catches stray snapshots
326
+ setTimeout(() => { this.isPaused = false; }, 0);
325
327
  }
326
328
 
327
329
  /**
@@ -2,7 +2,7 @@ import { isEditMode, isOwner } from "./isAdminOfCurrentResource.js";
2
2
  import onDomReady from "../dom-utilities/onDomReady.js";
3
3
  import {beforeSave} from "./savePage.js";
4
4
 
5
- const SELECTOR = '[edit-mode-contenteditable], [editmode\\:contenteditable]';
5
+ const SELECTOR = '[editmode\\:contenteditable]';
6
6
 
7
7
  export function disableContentEditableBeforeSave () {
8
8
  beforeSave(docElem => {
@@ -2,20 +2,13 @@ import { isEditMode, isOwner } from "./isAdminOfCurrentResource.js";
2
2
  import onDomReady from "../dom-utilities/onDomReady.js";
3
3
  import { beforeSave } from "./savePage.js";
4
4
 
5
- const SELECTOR_DISABLED = '[edit-mode-input], [viewmode\\:disabled]';
5
+ const SELECTOR_DISABLED = '[viewmode\\:disabled]';
6
6
  const SELECTOR_READONLY = '[viewmode\\:readonly]';
7
- const SELECTOR_ALL = '[edit-mode-input], [viewmode\\:disabled], [viewmode\\:readonly]';
8
7
 
9
8
  export function disableAdminInputsBeforeSave() {
10
9
  beforeSave(docElem => {
11
10
  docElem.querySelectorAll(SELECTOR_DISABLED).forEach(input => {
12
- if (input.hasAttribute('viewmode:disabled')) {
13
- input.setAttribute('disabled', '');
14
- } else if (supportsReadonly(input)) {
15
- input.setAttribute('readonly', '');
16
- } else {
17
- input.setAttribute('disabled', '');
18
- }
11
+ input.setAttribute('disabled', '');
19
12
  });
20
13
  docElem.querySelectorAll(SELECTOR_READONLY).forEach(input => {
21
14
  input.setAttribute('readonly', '');
@@ -31,16 +24,9 @@ export function enableAdminInputsOnPageLoad() {
31
24
  });
32
25
  }
33
26
 
34
- // Runtime toggle functions
35
27
  export function enableAdminInputs() {
36
28
  document.querySelectorAll(SELECTOR_DISABLED).forEach(input => {
37
- if (input.hasAttribute('viewmode:disabled')) {
38
- input.removeAttribute('disabled');
39
- } else if (supportsReadonly(input)) {
40
- input.removeAttribute('readonly');
41
- } else {
42
- input.removeAttribute('disabled');
43
- }
29
+ input.removeAttribute('disabled');
44
30
  });
45
31
  document.querySelectorAll(SELECTOR_READONLY).forEach(input => {
46
32
  input.removeAttribute('readonly');
@@ -49,43 +35,18 @@ export function enableAdminInputs() {
49
35
 
50
36
  export function disableAdminInputs() {
51
37
  document.querySelectorAll(SELECTOR_DISABLED).forEach(input => {
52
- if (input.hasAttribute('viewmode:disabled')) {
53
- input.setAttribute('disabled', '');
54
- } else if (supportsReadonly(input)) {
55
- input.setAttribute('readonly', '');
56
- } else {
57
- input.setAttribute('disabled', '');
58
- }
38
+ input.setAttribute('disabled', '');
59
39
  });
60
40
  document.querySelectorAll(SELECTOR_READONLY).forEach(input => {
61
41
  input.setAttribute('readonly', '');
62
42
  });
63
43
  }
64
44
 
65
- // Input types that support the readonly attribute
66
- const readonlyTypes = ['text', 'search', 'url', 'tel', 'email', 'password', 'date', 'month', 'week', 'time', 'datetime-local', 'number'];
67
-
68
- function supportsReadonly(element) {
69
- const tagName = element.tagName?.toUpperCase();
70
-
71
- if (tagName === 'TEXTAREA') return true;
72
- if (tagName === 'SELECT' || tagName === 'BUTTON' || tagName === 'FIELDSET') return false;
73
-
74
- if (tagName === 'INPUT') {
75
- const type = element.type || 'text';
76
- return readonlyTypes.includes(type);
77
- }
78
-
79
- return false;
80
- }
81
-
82
- // Auto-initialize
83
45
  export function init() {
84
46
  disableAdminInputsBeforeSave();
85
47
  enableAdminInputsOnPageLoad();
86
48
  }
87
49
 
88
- // Export to window
89
50
  window.hyperclay = window.hyperclay || {};
90
51
  window.hyperclay.enableAdminInputs = enableAdminInputs;
91
52
  window.hyperclay.disableAdminInputs = disableAdminInputs;
@@ -2,7 +2,7 @@ import { isEditMode, isOwner } from "./isAdminOfCurrentResource.js";
2
2
  import onDomReady from "../dom-utilities/onDomReady.js";
3
3
  import {beforeSave} from "./savePage.js";
4
4
 
5
- const SELECTOR = '[edit-mode-onclick], [editmode\\:onclick]';
5
+ const SELECTOR = '[editmode\\:onclick]';
6
6
 
7
7
  export function disableOnClickBeforeSave () {
8
8
  beforeSave(docElem => {
@@ -2,8 +2,8 @@ import { isEditMode, isOwner } from "./isAdminOfCurrentResource.js";
2
2
  import onDomReady from "../dom-utilities/onDomReady.js";
3
3
  import {beforeSave} from "./savePage.js";
4
4
 
5
- const SELECTOR = '[edit-mode-resource]:is(style, link, script), [editmode\\:resource]:is(style, link, script)';
6
- const SELECTOR_INERT = '[edit-mode-resource]:is(style, link, script)[type^="inert/"], [editmode\\:resource]:is(style, link, script)[type^="inert/"]';
5
+ const SELECTOR = '[editmode\\:resource]:is(style, link, script)';
6
+ const SELECTOR_INERT = '[editmode\\:resource]:is(style, link, script)[type^="inert/"]';
7
7
 
8
8
  export function disableAdminResourcesBeforeSave () {
9
9
  beforeSave(docElem => {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Option Visibility (CSS Layers Implementation)
2
+ * Option Visibility
3
3
  *
4
4
  * Shows/hides elements based on `option:` and `option-not:` attributes.
5
5
  *
@@ -23,19 +23,19 @@
23
23
  * </div>
24
24
  *
25
25
  * HOW IT WORKS:
26
- * 1. Uses `display: none !important` to forcefully hide elements
27
- * 2. Uses `display: revert-layer !important` to un-hide when ancestor matches
28
- * `revert-layer` tells the browser: "Ignore rules in this layer, fall back to author styles"
29
- * 3. This preserves the user's original `display` (flex, grid, block) without us knowing what it is
26
+ * Uses a single conditional-hide rule per pattern. Elements get `display: none !important`
27
+ * ONLY when they are NOT inside a matching ancestor scope. When the ancestor condition
28
+ * IS met, the hide rule doesn't match, so the author's original display value
29
+ * (flex, grid, block, etc.) applies naturally no recovery needed.
30
30
  *
31
31
  * BROWSER SUPPORT:
32
- * Requires `@layer`, `revert-layer`, and `:not()` selector lists (~92% of browsers, 2022+).
32
+ * Requires `:is()` and `:not()` with selector lists (~96% of browsers, 2021+).
33
33
  * Falls back gracefully - elements remain visible if unsupported.
34
34
  *
35
35
  * TRADEOFFS:
36
36
  * - Pro: Pure CSS after generation, zero JS overhead for toggling
37
- * - Pro: Simple code, similar to original approach
38
- * - Con: Loses to user `!important` rules (layered !important < unlayered !important)
37
+ * - Pro: No @layer or revert-layer works with any author CSS (layered or unlayered)
38
+ * - Pro: One rule per pattern instead of two
39
39
  * - Con: Pipe character `|` cannot be used as a literal value (reserved as OR delimiter)
40
40
  */
41
41
 
@@ -78,16 +78,7 @@ const optionVisibility = {
78
78
  _unsubscribe: null,
79
79
 
80
80
  log(...args) {
81
- if (this.debug) console.log('[OptionVisibility:Layer]', ...args);
82
- },
83
-
84
- /**
85
- * Check if browser supports the layer approach
86
- */
87
- isSupported() {
88
- return typeof CSS !== 'undefined'
89
- && typeof CSS.supports === 'function'
90
- && CSS.supports('display', 'revert-layer');
81
+ if (this.debug) console.log('[OptionVisibility]', ...args);
91
82
  },
92
83
 
93
84
  /**
@@ -126,61 +117,51 @@ const optionVisibility = {
126
117
  },
127
118
 
128
119
  /**
129
- * Generate CSS rules wrapped in @layer
120
+ * Generate CSS rules for conditional hiding.
121
+ *
122
+ * Each pattern produces a single rule that hides the element ONLY when
123
+ * it's NOT inside a matching ancestor scope. When the condition IS met,
124
+ * the rule doesn't match, so the author's original display value applies.
130
125
  */
131
126
  generateCSS(patterns) {
132
127
  if (!patterns.length) return '';
133
128
 
134
- const rules = patterns.map(({ name, rawValue, values, negated }) => {
129
+ return patterns.map(({ name, rawValue, values, negated }) => {
135
130
  const safeName = CSS.escape(name);
136
131
  const safeRawValue = CSS.escape(rawValue);
137
132
  const prefix = negated ? 'option-not' : 'option';
138
133
  const attrSelector = `[${prefix}\\:${safeName}="${safeRawValue}"]`;
139
134
 
140
- // Hide rule (same for both types)
141
- const hideRule = `${attrSelector}{display:none!important}`;
142
-
143
- // Show rule depends on type
144
- let showRule;
135
+ let scopeSelectors;
145
136
  if (negated) {
146
- // option-not: show when ancestor has attr but NOT any of the values
147
- // Uses :not(sel1, sel2) selector list syntax
137
+ // option-not: active when ancestor has attr but NOT any of the values
148
138
  const notList = values.map(v => `[${safeName}="${CSS.escape(v)}"]`).join(',');
149
- showRule = `[${safeName}]:not(${notList}) ${attrSelector}{display:revert-layer!important}`;
139
+ const scope = `[${safeName}]:not(${notList})`;
140
+ scopeSelectors = `${scope},${scope} *`;
150
141
  } else {
151
- // option: show when ancestor has ANY of the values
152
- const showSelectors = values.map(v =>
153
- `[${safeName}="${CSS.escape(v)}"] ${attrSelector}`
154
- ).join(',');
155
- showRule = `${showSelectors}{display:revert-layer!important}`;
142
+ // option: active when ancestor has ANY of the values
143
+ const self = values.map(v => `[${safeName}="${CSS.escape(v)}"]`);
144
+ const desc = values.map(v => `[${safeName}="${CSS.escape(v)}"] *`);
145
+ scopeSelectors = [...self, ...desc].join(',');
156
146
  }
157
147
 
158
- return hideRule + showRule;
148
+ return `${attrSelector}:not(:is(${scopeSelectors})){display:none!important}`;
159
149
  }).join('');
160
-
161
- return `@layer ${STYLE_NAME}{${rules}}`;
162
150
  },
163
151
 
164
152
  /**
165
153
  * Update the style element with current rules
166
154
  */
167
155
  update() {
168
- if (!this.isSupported()) {
169
- this.log('Browser lacks revert-layer support, skipping');
170
- return;
171
- }
172
-
173
156
  try {
174
157
  const attributes = this.findOptionAttributes();
175
158
  const css = this.generateCSS(attributes);
176
- // mutations-ignore: This style tag is regenerated on load. Without this,
177
- // the mutation observer would detect it as a change, delaying the settled signal.
178
159
  insertStyles(STYLE_NAME, css, (style) => {
179
160
  style.setAttribute('mutations-ignore', '');
180
161
  });
181
162
  this.log(`Generated ${attributes.length} rules`);
182
163
  } catch (error) {
183
- console.error('[OptionVisibility:Layer] Error generating rules:', error);
164
+ console.error('[OptionVisibility] Error generating rules:', error);
184
165
  }
185
166
  },
186
167
 
@@ -194,11 +175,6 @@ const optionVisibility = {
194
175
 
195
176
  this._started = true;
196
177
 
197
- if (!this.isSupported()) {
198
- console.warn('[OptionVisibility:Layer] Browser lacks revert-layer support. Elements will remain visible.');
199
- return;
200
- }
201
-
202
178
  this.update();
203
179
 
204
180
  // selectorFilter only triggers on option:/option-not: attribute changes (new patterns).
package/src/hyperclay.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * DO NOT EDIT THIS FILE DIRECTLY — it is generated from build/hyperclay.template.js
3
3
  *
4
- * HyperclayJS v1.22.1 - Minimal Browser-Native Loader
4
+ * HyperclayJS v1.23.1 - Minimal Browser-Native Loader
5
5
  *
6
6
  * Modules auto-init when imported (no separate init call needed).
7
7
  * Include `export-to-window` feature to export to window.hyperclay.
@@ -608,6 +608,24 @@ const themodal = (() => {
608
608
  const themodalMain = {
609
609
  isShowing: false,
610
610
  open() {
611
+ // Clean up stale modal if DOM was removed externally (e.g. live-sync morph)
612
+ if (this.isShowing && !document.querySelector('.micromodal-parent')) {
613
+ this._cleanupListeners?.();
614
+ html = "";
615
+ yes = "";
616
+ no = "";
617
+ zIndex = "100";
618
+ closeHtml = "";
619
+ enableClickOutsideCloses = true;
620
+ disableScroll = true;
621
+ disableFocus = false;
622
+ onYes = [];
623
+ onNo = [];
624
+ onOpen = [];
625
+ this.isShowing = false;
626
+ document.body.style.overflow = '';
627
+ }
628
+
611
629
  document.body.insertAdjacentHTML("afterbegin", "<div save-remove snapshot-remove class='micromodal-parent'>" + modalCss + modalHtml + "</div>");
612
630
 
613
631
  const modalOverlayElem = document.querySelector(".micromodal__overlay");
@@ -682,6 +700,13 @@ const themodal = (() => {
682
700
  document.addEventListener("click", handleClick);
683
701
  document.addEventListener("submit", handleSubmit);
684
702
 
703
+ // Store cleanup so stale listeners can be removed if DOM is yanked externally
704
+ this._cleanupListeners = () => {
705
+ document.removeEventListener("mousedown", handleMousedown);
706
+ document.removeEventListener("click", handleClick);
707
+ document.removeEventListener("submit", handleSubmit);
708
+ };
709
+
685
710
  function setButtonsVisibility () {
686
711
  modalButtonsElem.classList.toggle("micromodal__hide", !yes && !no);
687
712
  modalYesElem.classList.toggle("micromodal__hide", !yes);
@@ -718,6 +743,7 @@ const themodal = (() => {
718
743
  document.removeEventListener("mousedown", handleMousedown);
719
744
  document.removeEventListener("click", handleClick);
720
745
  document.removeEventListener("submit", handleSubmit);
746
+ this._cleanupListeners = null;
721
747
  }
722
748
  });
723
749
 
package/src/ui/toast.js CHANGED
@@ -235,9 +235,6 @@ export const hyperclayStyles = `
235
235
  }
236
236
  `;
237
237
 
238
- // Track which theme styles have been injected
239
- const injectedThemes = new Set();
240
-
241
238
  // Global toast configuration (can be overridden by toast-hyperclay module)
242
239
  let toastConfig = {
243
240
  styles: modernStyles,
@@ -253,7 +250,7 @@ export function setToastTheme(config) {
253
250
 
254
251
  // Helper function to inject styles for a theme (additive, not replacing)
255
252
  export function injectToastStyles(styles, theme) {
256
- if (injectedThemes.has(theme)) return;
253
+ if (document.querySelector(`style.toast-styles-${theme}`)) return;
257
254
 
258
255
  const styleSheet = document.createElement('style');
259
256
  styleSheet.className = `toast-styles-${theme}`;
@@ -261,8 +258,6 @@ export function injectToastStyles(styles, theme) {
261
258
  styleSheet.setAttribute('snapshot-remove', '');
262
259
  styleSheet.textContent = styles;
263
260
  document.head.appendChild(styleSheet);
264
-
265
- injectedThemes.add(theme);
266
261
  }
267
262
 
268
263
  // Core toast function (used by both toast and toastHyperclay)
@@ -357,8 +352,6 @@ const persistentToastStyles = `
357
352
  }
358
353
  `;
359
354
 
360
- let persistentStylesInjected = false;
361
-
362
355
  // Track active persistent toasts by message
363
356
  const activePersistentToasts = new Map();
364
357
 
@@ -367,14 +360,13 @@ function toastPersistent(message, messageType = "warning") {
367
360
  injectToastStyles(toastConfig.styles, toastConfig.theme);
368
361
 
369
362
  // Inject persistent-specific styles once
370
- if (!persistentStylesInjected) {
363
+ if (!document.querySelector('style.toast-styles-persistent')) {
371
364
  const styleSheet = document.createElement('style');
372
365
  styleSheet.className = 'toast-styles-persistent';
373
366
  styleSheet.setAttribute('save-remove', '');
374
367
  styleSheet.setAttribute('snapshot-remove', '');
375
368
  styleSheet.textContent = persistentToastStyles;
376
369
  document.head.appendChild(styleSheet);
377
- persistentStylesInjected = true;
378
370
  }
379
371
 
380
372
  const templates = toastConfig.templates;
@@ -84,6 +84,11 @@ const Mutation = {
84
84
  * Resume mutation observation after a pause.
85
85
  */
86
86
  resume() {
87
+ // Drain pending mutation records — observer stays connected during pause,
88
+ // so morph mutations are recorded and would fire async after _paused=false
89
+ if (this._observer) {
90
+ this._observer.takeRecords();
91
+ }
87
92
  this._paused = false;
88
93
  this._log('Resumed');
89
94
  },