hyperclayjs 1.6.0 → 1.7.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,7 +60,7 @@ import 'hyperclayjs/presets/standard.js';
60
60
  | autosave | 1.1KB | Auto-save on DOM changes, unsaved changes warning |
61
61
  | edit-mode | 1.8KB | Toggle edit mode on hyperclay on/off |
62
62
  | edit-mode-helpers | 5.4KB | Admin-only functionality: [edit-mode-input], [edit-mode-resource], [edit-mode-onclick] |
63
- | option-visibility | 4.7KB | Dynamic show/hide based on ancestor state with option:attribute="value" |
63
+ | option-visibility | 5.9KB | Dynamic show/hide based on ancestor state with option:attribute="value" |
64
64
  | persist | 2.5KB | Persist input/select/textarea values to the DOM with [persist] attribute |
65
65
  | save-core | 6.3KB | Basic save function only - hyperclay.savePage() |
66
66
  | save-system | 7.1KB | Manual save: keyboard shortcut (CMD+S), save button, change tracking |
@@ -132,12 +132,12 @@ Essential features for basic editing
132
132
 
133
133
  **Modules:** `save-core`, `save-system`, `edit-mode-helpers`, `toast`, `save-toast`, `export-to-window`
134
134
 
135
- ### Standard (~44.3KB)
135
+ ### Standard (~45.5KB)
136
136
  Standard feature set for most use cases
137
137
 
138
138
  **Modules:** `save-core`, `save-system`, `edit-mode-helpers`, `persist`, `option-visibility`, `event-attrs`, `dom-helpers`, `toast`, `save-toast`, `export-to-window`
139
139
 
140
- ### Everything (~149KB)
140
+ ### Everything (~150.2KB)
141
141
  All available features
142
142
 
143
143
  Includes all available modules across all categories.
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Option Visibility (CSS Layers Implementation)
3
+ *
4
+ * Shows/hides elements based on `option:` attributes and ancestor matches.
5
+ *
6
+ * Usage:
7
+ * <div editmode="true">
8
+ * <button option:editmode="true">Visible</button>
9
+ * <button option:editmode="false">Hidden</button>
10
+ * </div>
11
+ *
12
+ * An element with `option:name="value"` is hidden by default.
13
+ * It becomes visible when ANY ancestor has `name="value"`.
14
+ *
15
+ * ---
16
+ *
17
+ * HOW IT WORKS:
18
+ * 1. Uses `display: none !important` to forcefully hide elements
19
+ * 2. Uses `display: revert-layer !important` to un-hide when ancestor matches
20
+ * `revert-layer` tells the browser: "Ignore rules in this layer, fall back to author styles"
21
+ * 3. This preserves the user's original `display` (flex, grid, block) without us knowing what it is
22
+ *
23
+ * BROWSER SUPPORT:
24
+ * Requires `@layer` and `revert-layer` support (~92% of browsers, 2022+).
25
+ * Falls back gracefully - elements remain visible if unsupported.
26
+ *
27
+ * TRADEOFFS:
28
+ * - Pro: Pure CSS after generation, zero JS overhead for toggling
29
+ * - Pro: Simple code, similar to original approach
30
+ * - Con: Loses to user `!important` rules (layered !important < unlayered !important)
31
+ */
32
+
33
+ import Mutation from "../utilities/mutation.js";
34
+
35
+ const optionVisibility = {
36
+ debug: false,
37
+ _started: false,
38
+ _styleElement: null,
39
+ _unsubscribe: null,
40
+
41
+ LAYER_NAME: 'option-visibility',
42
+ STYLE_CLASS: 'option-visibility-layer-styles',
43
+
44
+ log(...args) {
45
+ if (this.debug) console.log('[OptionVisibility:Layer]', ...args);
46
+ },
47
+
48
+ /**
49
+ * Check if browser supports the layer approach
50
+ */
51
+ isSupported() {
52
+ return typeof CSS !== 'undefined'
53
+ && typeof CSS.supports === 'function'
54
+ && CSS.supports('display', 'revert-layer');
55
+ },
56
+
57
+ /**
58
+ * Find all unique option:name="value" patterns using XPath (faster than regex on HTML)
59
+ */
60
+ findOptionAttributes() {
61
+ const patterns = new Map();
62
+
63
+ try {
64
+ const snapshot = document.evaluate(
65
+ '//*[@*[starts-with(name(), "option:")]]',
66
+ document.documentElement,
67
+ null,
68
+ XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE,
69
+ null
70
+ );
71
+
72
+ for (let i = 0; i < snapshot.snapshotLength; i++) {
73
+ const el = snapshot.snapshotItem(i);
74
+ for (const attr of el.attributes) {
75
+ if (attr.name.startsWith('option:')) {
76
+ const name = attr.name.slice(7);
77
+ const value = attr.value;
78
+ const key = `${name}=${value}`;
79
+ if (!patterns.has(key)) {
80
+ patterns.set(key, { name, value });
81
+ }
82
+ }
83
+ }
84
+ }
85
+ } catch (error) {
86
+ this.log('XPath error, falling back to empty', error);
87
+ }
88
+
89
+ return [...patterns.values()];
90
+ },
91
+
92
+ /**
93
+ * Generate CSS rules wrapped in @layer
94
+ */
95
+ generateCSS(attributes) {
96
+ if (!attributes.length) return '';
97
+
98
+ const rules = attributes.map(({ name, value }) => {
99
+ const safeName = CSS.escape(name);
100
+ const safeValue = CSS.escape(value);
101
+
102
+ // Hidden by default, visible when ancestor matches
103
+ // Both rules need !important for consistency within the layer
104
+ return `[option\\:${safeName}="${safeValue}"]{display:none!important}[${safeName}="${safeValue}"] [option\\:${safeName}="${safeValue}"]{display:revert-layer!important}`;
105
+ }).join('');
106
+
107
+ return `@layer ${this.LAYER_NAME}{${rules}}`;
108
+ },
109
+
110
+ /**
111
+ * Update the style element with current rules
112
+ */
113
+ update() {
114
+ if (!this.isSupported()) {
115
+ this.log('Browser lacks revert-layer support, skipping');
116
+ return;
117
+ }
118
+
119
+ try {
120
+ const attributes = this.findOptionAttributes();
121
+ const css = this.generateCSS(attributes);
122
+
123
+ // Remove style element if no attributes
124
+ if (!css) {
125
+ if (this._styleElement) {
126
+ this._styleElement.remove();
127
+ this._styleElement = null;
128
+ this.log('Removed empty style element');
129
+ }
130
+ return;
131
+ }
132
+
133
+ // Skip if unchanged
134
+ if (this._styleElement?.textContent === css) {
135
+ this.log('Styles unchanged');
136
+ return;
137
+ }
138
+
139
+ // Create or update
140
+ if (!this._styleElement) {
141
+ this._styleElement = document.createElement('style');
142
+ this._styleElement.className = this.STYLE_CLASS;
143
+ document.head.appendChild(this._styleElement);
144
+ }
145
+
146
+ this._styleElement.textContent = css;
147
+ this.log(`Generated ${attributes.length} rules`);
148
+
149
+ } catch (error) {
150
+ console.error('[OptionVisibility:Layer] Error generating rules:', error);
151
+ }
152
+ },
153
+
154
+ start() {
155
+ if (this._started) return;
156
+
157
+ if (document.readyState === 'loading') {
158
+ document.addEventListener('DOMContentLoaded', () => this.start(), { once: true });
159
+ return;
160
+ }
161
+
162
+ this._started = true;
163
+
164
+ if (!this.isSupported()) {
165
+ console.warn('[OptionVisibility:Layer] Browser lacks revert-layer support. Elements will remain visible.');
166
+ return;
167
+ }
168
+
169
+ this.update();
170
+
171
+ // selectorFilter only triggers on option:* attribute changes (new patterns).
172
+ // Ancestor attribute changes (e.g., editmode="true" -> "false") are handled
173
+ // automatically by the browser - CSS rules re-evaluate when attributes change.
174
+ this._unsubscribe = Mutation.onAnyChange({
175
+ debounce: 200,
176
+ selectorFilter: el => [...el.attributes].some(attr => attr.name.startsWith('option:')),
177
+ omitChangeDetails: true
178
+ }, () => this.update());
179
+
180
+ this.log('Started');
181
+ },
182
+
183
+ stop() {
184
+ if (!this._started) return;
185
+
186
+ this._started = false;
187
+
188
+ if (this._unsubscribe) {
189
+ this._unsubscribe();
190
+ this._unsubscribe = null;
191
+ }
192
+
193
+ if (this._styleElement) {
194
+ this._styleElement.remove();
195
+ this._styleElement = null;
196
+ }
197
+
198
+ this.log('Stopped');
199
+ }
200
+ };
201
+
202
+ // Auto-export
203
+ if (!window.__hyperclayNoAutoExport) {
204
+ window.optionVisibility = optionVisibility;
205
+ window.hyperclay = window.hyperclay || {};
206
+ window.hyperclay.optionVisibility = optionVisibility;
207
+ window.h = window.hyperclay;
208
+ }
209
+
210
+ export default optionVisibility;
211
+
212
+ export function init() {
213
+ optionVisibility.start();
214
+ }
215
+
216
+ init();
@@ -121,7 +121,7 @@ export function savePage(callback = () => {}) {
121
121
 
122
122
  const msg = err.name === 'AbortError'
123
123
  ? 'Server not responding'
124
- : (err.message || 'Failed to save');
124
+ : 'Save failed';
125
125
 
126
126
  if (typeof callback === 'function') {
127
127
  callback({msg, msgType: "error"});
package/hyperclay.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * HyperclayJS v1.6.0 - Minimal Browser-Native Loader
2
+ * HyperclayJS v1.7.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.
@@ -31,7 +31,7 @@ const MODULE_PATHS = {
31
31
  "save-toast": "./core/saveToast.js",
32
32
  "edit-mode-helpers": "./core/adminSystem.js",
33
33
  "persist": "./core/enablePersistentFormInputValues.js",
34
- "option-visibility": "./core/optionVisibilityRuleGenerator.js",
34
+ "option-visibility": "./core/optionVisibility.js",
35
35
  "edit-mode": "./core/editmodeSystem.js",
36
36
  "event-attrs": "./custom-attributes/events.js",
37
37
  "ajax-elements": "./custom-attributes/ajaxElements.js",
@@ -68,7 +68,7 @@
68
68
  "string-utilities/query.js",
69
69
  "utilities/cookie.js"
70
70
  ],
71
- "core/optionVisibilityRuleGenerator.js": [
71
+ "core/optionVisibility.js": [
72
72
  "utilities/mutation.js"
73
73
  ],
74
74
  "core/savePage.js": [
@@ -242,9 +242,9 @@
242
242
  "option-visibility": {
243
243
  "name": "option-visibility",
244
244
  "category": "core",
245
- "size": 4.7,
245
+ "size": 5.9,
246
246
  "files": [
247
- "core/optionVisibilityRuleGenerator.js"
247
+ "core/optionVisibility.js"
248
248
  ],
249
249
  "description": "Dynamic show/hide based on ancestor state with option:attribute=\"value\"",
250
250
  "exports": {}
@@ -678,7 +678,7 @@
678
678
  "save-toast": "./core/saveToast.js",
679
679
  "edit-mode-helpers": "./core/adminSystem.js",
680
680
  "persist": "./core/enablePersistentFormInputValues.js",
681
- "option-visibility": "./core/optionVisibilityRuleGenerator.js",
681
+ "option-visibility": "./core/optionVisibility.js",
682
682
  "edit-mode": "./core/editmodeSystem.js",
683
683
  "event-attrs": "./custom-attributes/events.js",
684
684
  "ajax-elements": "./custom-attributes/ajaxElements.js",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hyperclayjs",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "Modular JavaScript library for building interactive HTML applications with Hyperclay",
5
5
  "type": "module",
6
6
  "main": "hyperclay.js",
@@ -1,171 +0,0 @@
1
- /**
2
- *
3
- * Automatically show/hide elements with "option" attributes based on ancestors' attributes.
4
- *
5
- * # Usage:
6
- * optionVisibilityRuleGenerator.debug = true;
7
- * optionVisibilityRuleGenerator.start();
8
- *
9
- * # HTML Example:
10
- * <div editmode="true"> <!-- Parent element with matching attribute -->
11
- * <div option:editmode="true"></div> <!-- This will be visible -->
12
- * <div option:editmode="false"></div> <!-- This will be hidden -->
13
- * </div>
14
- *
15
- * Elements with `option:` attributes will be:
16
- * - Visible if any ancestor has matching attribute
17
- * - Hidden if no ancestor has matching attribute
18
- *
19
- */
20
- import Mutation from "../utilities/mutation.js";
21
-
22
- const optionVisibilityRuleGenerator = {
23
- debug: false,
24
- styleElement: null,
25
-
26
- HIDDEN_STYLES: `
27
- visibility: hidden;
28
- pointer-events: none;
29
- width: 0;
30
- height: 0;
31
- overflow: hidden;
32
- `,
33
- VISIBLE_STYLES: `
34
- visibility: visible;
35
- pointer-events: auto;
36
- width: auto;
37
- height: auto;
38
- overflow: visible;
39
- `,
40
-
41
- STYLE_CLASS: 'option-visibility-styles',
42
-
43
- log(message, ...args) {
44
- if (this.debug) {
45
- console.log(`[OptionVisibilityRuleGenerator] ${message}`, ...args);
46
- }
47
- },
48
-
49
- findOptionAttributes() {
50
- const html = document.documentElement.outerHTML;
51
- const optionAttributes = new Set(); // Using Set for unique combinations
52
- const optionRegex = /option:([^\s"']+)=["']([^"']+)["']/g; // regex: "option:" + (anything but space and quote) + equal + quote + (anything but quote) + quote
53
-
54
- let match;
55
- while ((match = optionRegex.exec(html)) !== null) {
56
- // Create a unique key for each name-value pair
57
- const key = JSON.stringify({name: match[1], value: match[2]});
58
- optionAttributes.add(key);
59
- }
60
-
61
- // Convert back to objects
62
- return Array.from(optionAttributes).map(key => JSON.parse(key));
63
- },
64
-
65
- minifyCSS(css) {
66
- return css
67
- .replace(/\s+/g, ' ')
68
- .replace(/{\s+/g, '{')
69
- .replace(/\s+}/g, '}')
70
- .replace(/;\s+/g, ';')
71
- .replace(/:\s+/g, ':')
72
- .trim();
73
- },
74
-
75
- generateCSSRules(optionAttributes) {
76
- const rules = [];
77
-
78
- optionAttributes.forEach(({name, value}) => {
79
- const escapedValue = value.replace(/["\\]/g, '\\$&');
80
-
81
- rules.push(`
82
- [option\\:${name}="${escapedValue}"] {
83
- ${this.HIDDEN_STYLES}
84
- }
85
- `);
86
-
87
- rules.push(`
88
- [${name}="${escapedValue}"] [option\\:${name}="${escapedValue}"] {
89
- ${this.VISIBLE_STYLES}
90
- }
91
- `);
92
- });
93
-
94
- return this.minifyCSS(rules.join('\n'));
95
- },
96
-
97
- generateRules() {
98
- try {
99
- this.log('Starting rule generation');
100
-
101
- const optionAttributes = this.findOptionAttributes();
102
- this.log('Found option attributes:', optionAttributes);
103
-
104
- // Early return if no option attributes found
105
- if (optionAttributes.length === 0) {
106
- this.log('No option attributes found, skipping style creation');
107
- return;
108
- }
109
-
110
- const cssRules = this.generateCSSRules(optionAttributes);
111
- this.log('Generated CSS rules:', cssRules);
112
-
113
- // Check if we already have these exact rules
114
- const existingStyleElement = document.head.querySelector(`.${this.STYLE_CLASS}`);
115
- if (existingStyleElement && existingStyleElement.textContent.trim() === cssRules) {
116
- this.log('Rules unchanged, skipping update');
117
- return;
118
- }
119
-
120
- // Create new style element
121
- const newStyleElement = document.createElement('style');
122
- newStyleElement.className = this.STYLE_CLASS;
123
- newStyleElement.textContent = cssRules;
124
- document.head.appendChild(newStyleElement);
125
-
126
- // Remove all previous style elements
127
- document.head
128
- .querySelectorAll(`.${this.STYLE_CLASS}`)
129
- .forEach(el => {
130
- if (el !== newStyleElement) {
131
- el.remove();
132
- }
133
- });
134
-
135
- this.styleElement = newStyleElement;
136
-
137
- this.log('Rule generation complete');
138
- } catch (error) {
139
- console.error('Error generating visibility rules:', error);
140
- }
141
- },
142
-
143
- start() {
144
- Mutation.onAnyChange({
145
- selectorFilter: el => [...el.attributes].some(attr => attr.name.startsWith('option:')),
146
- debounce: 200
147
- }, () => {
148
- this.generateRules();
149
- });
150
- this.generateRules();
151
- this.log('Started observing DOM mutations');
152
- },
153
- };
154
-
155
- // Auto-export to window unless suppressed by loader
156
- if (!window.__hyperclayNoAutoExport) {
157
- window.optionVisibilityRuleGenerator = optionVisibilityRuleGenerator;
158
- window.hyperclay = window.hyperclay || {};
159
- window.hyperclay.optionVisibilityRuleGenerator = optionVisibilityRuleGenerator;
160
- window.h = window.hyperclay;
161
- }
162
-
163
- export default optionVisibilityRuleGenerator;
164
-
165
- // Auto-initialize
166
- export function init() {
167
- optionVisibilityRuleGenerator.start();
168
- }
169
-
170
- // Auto-init when module is imported
171
- init();