hyperclayjs 1.14.1 → 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,7 +60,7 @@ 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.5KB | 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
66
  | save-system | 9.6KB | CMD+S, [trigger-save] button, savestatus attribute |
@@ -137,12 +137,12 @@ 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 (~60.1KB)
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 (~182.3KB)
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.1",
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}}`;
@@ -146,12 +201,14 @@ const optionVisibility = {
146
201
 
147
202
  this.update();
148
203
 
149
- // selectorFilter only triggers on option:* attribute changes (new patterns).
204
+ // selectorFilter only triggers on option:/option-not: attribute changes (new patterns).
150
205
  // Ancestor attribute changes (e.g., editmode="true" -> "false") are handled
151
206
  // automatically by the browser - CSS rules re-evaluate when attributes change.
152
207
  this._unsubscribe = Mutation.onAnyChange({
153
208
  debounce: 200,
154
- 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
+ ),
155
212
  omitChangeDetails: true
156
213
  }, () => this.update());
157
214
 
package/src/hyperclay.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * HyperclayJS v1.14.1 - 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.