hyperclayjs 1.14.0 → 1.15.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/README.md CHANGED
@@ -60,10 +60,10 @@ import 'hyperclayjs/presets/standard.js';
60
60
  | autosave | 0.9KB | Auto-save on DOM changes |
61
61
  | edit-mode | 1.8KB | Toggle edit mode on hyperclay on/off |
62
62
  | edit-mode-helpers | 7.5KB | Admin-only functionality: [edit-mode-input], [edit-mode-resource], [edit-mode-onclick] |
63
- | option-visibility | 5.3KB | Dynamic show/hide based on ancestor state with option:attribute="value" |
63
+ | option-visibility | 7.8KB | 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 | 6.8KB | Basic save function only - hyperclay.savePage() |
66
- | save-system | 7.1KB | CMD+S, [trigger-save] button, savestatus attribute |
66
+ | save-system | 9.6KB | CMD+S, [trigger-save] button, savestatus attribute |
67
67
  | save-toast | 0.9KB | Toast notifications for save events |
68
68
  | snapshot | 7.5KB | Source of truth for page state - captures DOM snapshots for save and sync |
69
69
  | tailwind-inject | 0.4KB | Injects tailwind CSS link with cache-bust on save |
@@ -106,7 +106,7 @@ import 'hyperclayjs/presets/standard.js';
106
106
  | all-js | 14.4KB | Full DOM manipulation library |
107
107
  | dom-ready | 0.4KB | DOM ready callback |
108
108
  | form-data | 2KB | Extract form data as an object |
109
- | style-injection | 2.4KB | Dynamic stylesheet injection |
109
+ | style-injection | 4KB | Dynamic stylesheet injection |
110
110
 
111
111
  ### String Utilities (String manipulation helpers)
112
112
 
@@ -132,17 +132,17 @@ import 'hyperclayjs/presets/standard.js';
132
132
 
133
133
  ## Presets
134
134
 
135
- ### Minimal (~38.1KB)
135
+ ### Minimal (~40.6KB)
136
136
  Essential features for basic editing
137
137
 
138
138
  **Modules:** `save-core`, `snapshot`, `save-system`, `edit-mode-helpers`, `toast`, `save-toast`, `export-to-window`, `view-mode-excludes-edit-modules`
139
139
 
140
- ### Standard (~57.4KB)
140
+ ### Standard (~62.4KB)
141
141
  Standard feature set for most use cases
142
142
 
143
143
  **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`
144
144
 
145
- ### Everything (~178KB)
145
+ ### Everything (~184.6KB)
146
146
  All available features
147
147
 
148
148
  Includes all available modules across all categories.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hyperclayjs",
3
- "version": "1.14.0",
3
+ "version": "1.15.0",
4
4
  "description": "Modular JavaScript library for building interactive HTML applications with Hyperclay",
5
5
  "type": "module",
6
6
  "main": "src/hyperclay.js",
@@ -1,18 +1,26 @@
1
1
  /**
2
2
  * Option Visibility (CSS Layers Implementation)
3
3
  *
4
- * Shows/hides elements based on `option:` attributes and ancestor matches.
4
+ * Shows/hides elements based on `option:` and `option-not:` attributes.
5
5
  *
6
- * Usage:
6
+ * SYNTAX:
7
+ * option:name="value" - Show when ancestor has name="value"
8
+ * option:name="a|b|c" - Show when ancestor has name="a" OR "b" OR "c"
9
+ * option:name="" - Show when ancestor has name="" (empty value)
10
+ * option:name="|saved" - Show when ancestor has name="" OR name="saved"
11
+ * option-not:name="value" - Show when ancestor has name attr but ≠ "value"
12
+ * option-not:name="a|b" - Show when ancestor has name attr but ≠ "a" AND ≠ "b"
13
+ *
14
+ * EXAMPLES:
7
15
  * <div editmode="true">
8
- * <button option:editmode="true">Visible</button>
16
+ * <button option:editmode="true">Visible in edit mode</button>
9
17
  * <button option:editmode="false">Hidden</button>
10
18
  * </div>
11
19
  *
12
- * An element with `option:name="value"` is hidden by default.
13
- * It becomes visible when ANY ancestor has `name="value"`.
14
- *
15
- * ---
20
+ * <div savestatus="error">
21
+ * <span option:savestatus="saved|error">Visible (matches error)</span>
22
+ * <span option-not:savestatus="saving">Visible (error ≠ saving)</span>
23
+ * </div>
16
24
  *
17
25
  * HOW IT WORKS:
18
26
  * 1. Uses `display: none !important` to forcefully hide elements
@@ -21,13 +29,14 @@
21
29
  * 3. This preserves the user's original `display` (flex, grid, block) without us knowing what it is
22
30
  *
23
31
  * BROWSER SUPPORT:
24
- * Requires `@layer` and `revert-layer` support (~92% of browsers, 2022+).
32
+ * Requires `@layer`, `revert-layer`, and `:not()` selector lists (~92% of browsers, 2022+).
25
33
  * Falls back gracefully - elements remain visible if unsupported.
26
34
  *
27
35
  * TRADEOFFS:
28
36
  * - Pro: Pure CSS after generation, zero JS overhead for toggling
29
37
  * - Pro: Simple code, similar to original approach
30
38
  * - Con: Loses to user `!important` rules (layered !important < unlayered !important)
39
+ * - Con: Pipe character `|` cannot be used as a literal value (reserved as OR delimiter)
31
40
  */
32
41
 
33
42
  import Mutation from "../utilities/mutation.js";
@@ -35,6 +44,34 @@ import insertStyles from "../dom-utilities/insertStyleTag.js";
35
44
 
36
45
  const STYLE_NAME = 'option-visibility';
37
46
 
47
+ /**
48
+ * Parse an option:/option-not: attribute into a pattern object.
49
+ * Pure function for easy testing.
50
+ *
51
+ * @param {string} attrName - Attribute name (e.g., 'option:editmode', 'option-not:status')
52
+ * @param {string} attrValue - Attribute value (e.g., 'true', 'a|b|c')
53
+ * @returns {Object|null} Pattern object or null if not a valid option attribute
54
+ */
55
+ export function parseOptionAttribute(attrName, attrValue) {
56
+ let negated = false;
57
+ let name;
58
+
59
+ if (attrName.startsWith('option-not:')) {
60
+ negated = true;
61
+ name = attrName.slice(11);
62
+ } else if (attrName.startsWith('option:')) {
63
+ name = attrName.slice(7);
64
+ } else {
65
+ return null;
66
+ }
67
+
68
+ const rawValue = attrValue;
69
+ // Split by pipe, keep empty strings (they match empty attribute values)
70
+ const values = rawValue.split('|');
71
+
72
+ return { name, rawValue, values, negated };
73
+ }
74
+
38
75
  const optionVisibility = {
39
76
  debug: false,
40
77
  _started: false,
@@ -54,14 +91,15 @@ const optionVisibility = {
54
91
  },
55
92
 
56
93
  /**
57
- * Find all unique option:name="value" patterns using XPath (faster than regex on HTML)
94
+ * Find all unique option:/option-not: patterns using XPath
95
+ * Returns array of { name, rawValue, values, negated }
58
96
  */
59
97
  findOptionAttributes() {
60
98
  const patterns = new Map();
61
99
 
62
100
  try {
63
101
  const snapshot = document.evaluate(
64
- '//*[@*[starts-with(name(), "option:")]]',
102
+ '//*[@*[starts-with(name(), "option:") or starts-with(name(), "option-not:")]]',
65
103
  document.documentElement,
66
104
  null,
67
105
  XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE,
@@ -71,13 +109,12 @@ const optionVisibility = {
71
109
  for (let i = 0; i < snapshot.snapshotLength; i++) {
72
110
  const el = snapshot.snapshotItem(i);
73
111
  for (const attr of el.attributes) {
74
- if (attr.name.startsWith('option:')) {
75
- const name = attr.name.slice(7);
76
- const value = attr.value;
77
- const key = `${name}=${value}`;
78
- if (!patterns.has(key)) {
79
- patterns.set(key, { name, value });
80
- }
112
+ const pattern = parseOptionAttribute(attr.name, attr.value);
113
+ if (!pattern) continue;
114
+
115
+ const key = `${pattern.negated ? '!' : ''}${pattern.name}=${pattern.rawValue}`;
116
+ if (!patterns.has(key)) {
117
+ patterns.set(key, pattern);
81
118
  }
82
119
  }
83
120
  }
@@ -91,16 +128,34 @@ const optionVisibility = {
91
128
  /**
92
129
  * Generate CSS rules wrapped in @layer
93
130
  */
94
- generateCSS(attributes) {
95
- if (!attributes.length) return '';
131
+ generateCSS(patterns) {
132
+ if (!patterns.length) return '';
96
133
 
97
- const rules = attributes.map(({ name, value }) => {
134
+ const rules = patterns.map(({ name, rawValue, values, negated }) => {
98
135
  const safeName = CSS.escape(name);
99
- const safeValue = CSS.escape(value);
136
+ const safeRawValue = CSS.escape(rawValue);
137
+ const prefix = negated ? 'option-not' : 'option';
138
+ const attrSelector = `[${prefix}\\:${safeName}="${safeRawValue}"]`;
139
+
140
+ // Hide rule (same for both types)
141
+ const hideRule = `${attrSelector}{display:none!important}`;
142
+
143
+ // Show rule depends on type
144
+ let showRule;
145
+ if (negated) {
146
+ // option-not: show when ancestor has attr but NOT any of the values
147
+ // Uses :not(sel1, sel2) selector list syntax
148
+ const notList = values.map(v => `[${safeName}="${CSS.escape(v)}"]`).join(',');
149
+ showRule = `[${safeName}]:not(${notList}) ${attrSelector}{display:revert-layer!important}`;
150
+ } 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}`;
156
+ }
100
157
 
101
- // Hidden by default, visible when ancestor matches
102
- // Both rules need !important for consistency within the layer
103
- return `[option\\:${safeName}="${safeValue}"]{display:none!important}[${safeName}="${safeValue}"] [option\\:${safeName}="${safeValue}"]{display:revert-layer!important}`;
158
+ return hideRule + showRule;
104
159
  }).join('');
105
160
 
106
161
  return `@layer ${STYLE_NAME}{${rules}}`;
@@ -118,7 +173,11 @@ const optionVisibility = {
118
173
  try {
119
174
  const attributes = this.findOptionAttributes();
120
175
  const css = this.generateCSS(attributes);
121
- insertStyles(STYLE_NAME, css);
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
+ insertStyles(STYLE_NAME, css, (style) => {
179
+ style.setAttribute('mutations-ignore', '');
180
+ });
122
181
  this.log(`Generated ${attributes.length} rules`);
123
182
  } catch (error) {
124
183
  console.error('[OptionVisibility:Layer] Error generating rules:', error);
@@ -142,12 +201,14 @@ const optionVisibility = {
142
201
 
143
202
  this.update();
144
203
 
145
- // selectorFilter only triggers on option:* attribute changes (new patterns).
204
+ // selectorFilter only triggers on option:/option-not: attribute changes (new patterns).
146
205
  // Ancestor attribute changes (e.g., editmode="true" -> "false") are handled
147
206
  // automatically by the browser - CSS rules re-evaluate when attributes change.
148
207
  this._unsubscribe = Mutation.onAnyChange({
149
208
  debounce: 200,
150
- selectorFilter: el => [...el.attributes].some(attr => attr.name.startsWith('option:')),
209
+ selectorFilter: el => [...el.attributes].some(attr =>
210
+ attr.name.startsWith('option:') || attr.name.startsWith('option-not:')
211
+ ),
151
212
  omitChangeDetails: true
152
213
  }, () => this.update());
153
214
 
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import throttle from "../utilities/throttle.js";
14
+ import Mutation from "../utilities/mutation.js";
14
15
  import { isEditMode, isOwner } from "./isAdminOfCurrentResource.js";
15
16
  import {
16
17
  savePage as savePageCore,
@@ -19,6 +20,11 @@ import {
19
20
  beforeSave
20
21
  } from "./savePageCore.js";
21
22
 
23
+ // Reset savestatus to 'saved' in snapshots (each module cleans up its own attrs)
24
+ beforeSave(clone => {
25
+ clone.setAttribute('savestatus', 'saved');
26
+ });
27
+
22
28
  // ============================================
23
29
  // SAVE STATE MANAGEMENT
24
30
  // ============================================
@@ -94,17 +100,6 @@ export function setUnsavedChanges(val) { unsavedChanges = val; }
94
100
  export function getLastSavedContents() { return lastSavedContents; }
95
101
  export function setLastSavedContents(val) { lastSavedContents = val; }
96
102
 
97
- // Initialize lastSavedContents on page load to match what's on disk
98
- // This prevents unnecessary save attempts when content hasn't changed
99
- document.addEventListener('DOMContentLoaded', () => {
100
- if (isEditMode) {
101
- // Capture initial state immediately for comparison
102
- lastSavedContents = getPageContents();
103
- // Set initial save status to 'saved'
104
- document.documentElement.setAttribute('savestatus', 'saved');
105
- }
106
- });
107
-
108
103
  /**
109
104
  * Save the current page with change detection and state management
110
105
  *
@@ -185,14 +180,87 @@ const throttledSave = throttle(savePage, 1200);
185
180
  // Baseline for autosave comparison
186
181
  let baselineContents = '';
187
182
 
188
- // Capture baseline after setup mutations settle
189
- document.addEventListener('DOMContentLoaded', () => {
190
- if (isEditMode) {
191
- setTimeout(() => {
192
- baselineContents = getPageContents();
193
- }, 1500);
194
- }
195
- });
183
+ // ============================================
184
+ // BASELINE CAPTURE (Settled Signal)
185
+ // ============================================
186
+ //
187
+ // WHY SETTLED SIGNAL:
188
+ // Modules run on load and mutate the DOM (add styles, modify attributes).
189
+ // A fixed delay (e.g., 1500ms) is arbitrary and either too short (misses slow
190
+ // mutations) or too long (delays baseline). Instead, we wait for mutations to
191
+ // stop, meaning all modules have finished their setup work.
192
+ //
193
+ // WHY IMMEDIATE + CONDITIONAL UPDATE:
194
+ // We set baseline immediately as a safety net. If the user edits or saves
195
+ // before settle completes, we don't overwrite their work. The settled snapshot
196
+ // only replaces baseline if nothing changed (lastSavedContents === immediateContents).
197
+
198
+ const SETTLE_MS = 500; // Wait for no mutations for this long
199
+ const MAX_SETTLE_MS = 3000; // Max time to wait before forcing capture
200
+
201
+ function initBaselineCapture() {
202
+ if (!isEditMode) return;
203
+
204
+ let userEdited = false;
205
+ let settled = false;
206
+ let unsubscribeMutation = null;
207
+
208
+ // Take immediate snapshot and set as baseline right away
209
+ // This ensures saves during settle window work correctly
210
+ const immediateContents = getPageContents();
211
+ lastSavedContents = immediateContents;
212
+ baselineContents = immediateContents;
213
+
214
+ // Track user edits to avoid overwriting real changes
215
+ const userEditEvents = ['input', 'change', 'paste'];
216
+ const markUserEdited = (e) => {
217
+ const target = e.target;
218
+ const isEditable = target.isContentEditable ||
219
+ target.tagName === 'INPUT' ||
220
+ target.tagName === 'TEXTAREA' ||
221
+ target.tagName === 'SELECT';
222
+ if (isEditable) userEdited = true;
223
+ };
224
+ userEditEvents.forEach(evt => document.addEventListener(evt, markUserEdited, true));
225
+
226
+ // Called when mutations settle OR max timeout reached
227
+ const captureBaseline = () => {
228
+ if (settled) return;
229
+ settled = true;
230
+
231
+ // Cleanup listeners
232
+ if (unsubscribeMutation) unsubscribeMutation();
233
+ userEditEvents.forEach(evt => document.removeEventListener(evt, markUserEdited, true));
234
+
235
+ // Only update if no user edits AND no saves occurred during settle
236
+ // (if a save happened, lastSavedContents would differ from immediateContents)
237
+ if (!userEdited && lastSavedContents === immediateContents) {
238
+ const contents = getPageContents();
239
+ lastSavedContents = contents;
240
+ baselineContents = contents;
241
+ }
242
+
243
+ document.documentElement.setAttribute('savestatus', 'saved');
244
+ };
245
+
246
+ // Start settle observer - fires when no mutations for SETTLE_MS
247
+ unsubscribeMutation = Mutation.onAnyChange(
248
+ { debounce: SETTLE_MS, omitChangeDetails: true },
249
+ captureBaseline
250
+ );
251
+
252
+ // Max timeout fallback
253
+ setTimeout(() => {
254
+ if (!settled) captureBaseline();
255
+ }, MAX_SETTLE_MS);
256
+ }
257
+
258
+ // Run when DOM is ready
259
+ if (document.readyState === 'loading') {
260
+ document.addEventListener('DOMContentLoaded', initBaselineCapture);
261
+ } else {
262
+ initBaselineCapture();
263
+ }
196
264
 
197
265
  /**
198
266
  * Save the page with throttling, for use with auto-save
@@ -2,10 +2,20 @@
2
2
  * Insert styles into the document (inline CSS or external stylesheet).
3
3
  *
4
4
  * With a persistent DOM (i.e. hyperclay), we need a way to update styles.
5
- * This function always inserts the new styles first, then removes any
6
- * duplicates. This ensures:
7
- * - No flickering: new styles are applied before old ones are removed
8
- * - Always upgrades: we default to the new styles/approach
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 (hyperclay), 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.
9
19
  *
10
20
  * Usage:
11
21
  * insertStyles('/path/to/file.css') // External stylesheet
@@ -18,15 +28,33 @@ function insertStyles(nameOrHref, cssOrCallback, callback) {
18
28
  // Inline style: insertStyles('my-styles', '.foo { ... }', optionalCallback)
19
29
  const name = nameOrHref;
20
30
  const css = cssOrCallback;
21
- const oldStyles = document.querySelectorAll(`style[data-name="${name}"]`);
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
+ }
22
41
 
23
- const style = document.createElement('style');
24
- style.dataset.name = name;
25
- style.textContent = css;
26
- if (callback) callback(style);
27
- document.head.appendChild(style);
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
+ }
28
57
 
29
- oldStyles.forEach(el => el.remove());
30
58
  return style;
31
59
  }
32
60
 
@@ -34,25 +62,35 @@ function insertStyles(nameOrHref, cssOrCallback, callback) {
34
62
  const href = nameOrHref;
35
63
  const cb = typeof cssOrCallback === 'function' ? cssOrCallback : undefined;
36
64
 
37
- let identifier;
38
- try {
39
- const url = new URL(href, window.location.href);
40
- identifier = url.pathname.split('/').pop();
41
- } catch (e) {
42
- identifier = href;
43
- }
65
+ // Normalize href to full URL for comparison
66
+ const normalizedHref = new URL(href, window.location.href).href;
67
+
68
+ // Find all links with matching normalized path
69
+ const existingLinks = [...document.querySelectorAll('link[rel="stylesheet"]')]
70
+ .filter(el => {
71
+ try {
72
+ return new URL(el.getAttribute('href'), window.location.href).href === normalizedHref;
73
+ } catch {
74
+ return false;
75
+ }
76
+ });
44
77
 
45
- const oldLinks = document.querySelectorAll(
46
- `link[href="${href}"], link[href*="${identifier}"]`
47
- );
78
+ // If match exists, just update attributes via callback and return it
79
+ if (existingLinks.length > 0) {
80
+ const link = existingLinks[0];
81
+ if (cb) cb(link);
82
+ // Remove any duplicates
83
+ existingLinks.slice(1).forEach(el => el.remove());
84
+ return link;
85
+ }
48
86
 
87
+ // Create new link element
49
88
  const link = document.createElement('link');
50
89
  link.rel = 'stylesheet';
51
90
  link.href = href;
52
91
  if (cb) cb(link);
53
92
  document.head.appendChild(link);
54
93
 
55
- oldLinks.forEach(el => el.remove());
56
94
  return link;
57
95
  }
58
96
 
package/src/hyperclay.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * HyperclayJS v1.14.0 - Minimal Browser-Native Loader
2
+ * HyperclayJS v1.15.0 - Minimal Browser-Native Loader
3
3
  *
4
4
  * Modules auto-init when imported (no separate init call needed).
5
5
  * Include `export-to-window` feature to export to window.hyperclay.