lume-js 2.0.1 → 2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lume-js",
3
- "version": "2.0.1",
3
+ "version": "2.2.0",
4
4
  "description": "Minimal reactive state management using only standard JavaScript and HTML - no custom syntax, no build step required",
5
5
  "main": "dist/index.mjs",
6
6
  "module": "dist/index.mjs",
@@ -28,14 +28,19 @@
28
28
  "dev": "vite",
29
29
  "dev:site": "node scripts/build-site-assets.js && vite -c vite.site.config.js",
30
30
  "build:site": "node scripts/build-site-assets.js && vite build -c vite.site.config.js",
31
+ "preview:site": "node scripts/build-site-assets.js && vite build -c vite.site.config.js --base / && vite preview -c vite.site.config.js --base /",
31
32
  "build": "node scripts/build.js",
32
- "size": "node scripts/check-size.js",
33
+ "size": "npm run build && node scripts/check-size.js",
34
+ "size:ci": "node scripts/check-size.js",
35
+ "bench": "npm run build && node scripts/bench-core.js",
36
+ "complexity": "node scripts/complexity.js",
37
+ "lint": "eslint src/",
33
38
  "test": "vitest run",
34
39
  "test:watch": "vitest",
35
40
  "coverage": "vitest run --coverage",
36
41
  "typecheck": "tsc --noEmit",
37
- "validate": "npm run size && npm run typecheck && npm test",
38
- "prepublishOnly": "npm run build && npm run validate"
42
+ "validate": "npm run build && node scripts/check-size.js && node scripts/complexity.js && npm run lint && npm run typecheck && npm run coverage",
43
+ "prepublishOnly": "npm run validate"
39
44
  },
40
45
  "files": [
41
46
  "dist",
@@ -77,6 +82,8 @@
77
82
  "@tailwindcss/typography": "^0.5.19",
78
83
  "@vitest/coverage-v8": "^2.1.4",
79
84
  "autoprefixer": "^10.4.22",
85
+ "eslint": "^10.3.0",
86
+ "eslint-plugin-sonarjs": "^4.0.3",
80
87
  "highlight.js": "^11.11.1",
81
88
  "jsdom": "^25.0.1",
82
89
  "marked": "^17.0.1",
@@ -90,4 +97,4 @@
90
97
  "engines": {
91
98
  "node": ">=20.19.0"
92
99
  }
93
- }
100
+ }
@@ -56,26 +56,44 @@ export interface Computed<T> {
56
56
  */
57
57
  export function computed<T>(fn: () => T): Computed<T>;
58
58
 
59
+ export interface WatchOptions {
60
+ /**
61
+ * Whether to call the callback immediately with the current value (default: true).
62
+ * Set to false to skip the initial call and only react to future changes.
63
+ */
64
+ immediate?: boolean;
65
+ }
66
+
59
67
  /**
60
68
  * Watch a single key on a reactive state object; convenience wrapper around $subscribe.
61
- *
69
+ *
70
+ * By default the callback fires immediately with the current value, then on every change.
71
+ * Pass `{ immediate: false }` to skip the initial call and only react to future changes.
72
+ *
62
73
  * @param store - Reactive state object created with state().
63
74
  * @param key - Property key to observe.
64
- * @param callback - Invoked immediately and on subsequent changes.
75
+ * @param callback - Called with new value on change (and immediately unless immediate=false).
76
+ * @param options - Watch options
65
77
  * @returns Unsubscribe function for cleanup
66
78
  * @throws {Error} If store is not a reactive state object
67
- *
79
+ *
68
80
  * @example
69
81
  * ```typescript
70
82
  * import { state } from 'lume-js';
71
83
  * import { watch } from 'lume-js/addons';
72
- *
84
+ *
73
85
  * const store = state({ count: 0 });
74
- *
86
+ *
87
+ * // Fires immediately with 0, then on every change
75
88
  * const unwatch = watch(store, 'count', (value) => {
76
89
  * console.log('Count is now:', value);
77
90
  * });
78
- *
91
+ *
92
+ * // Only fires on future changes (not the initial value)
93
+ * watch(store, 'count', (value) => {
94
+ * console.log('Count changed to:', value);
95
+ * }, { immediate: false });
96
+ *
79
97
  * // Cleanup
80
98
  * unwatch();
81
99
  * ```
@@ -83,7 +101,8 @@ export function computed<T>(fn: () => T): Computed<T>;
83
101
  export function watch<T extends object, K extends keyof T>(
84
102
  store: ReactiveState<T>,
85
103
  key: K,
86
- callback: Subscriber<T[K]>
104
+ callback: Subscriber<T[K]>,
105
+ options?: WatchOptions
87
106
  ): Unsubscribe;
88
107
 
89
108
  /**
@@ -35,7 +35,7 @@
35
35
  * ═══════════════════════════════════════════════════════════════════════
36
36
  * PATTERN 2: Clean separation (create + update) - recommended
37
37
  * ═══════════════════════════════════════════════════════════════════════
38
- *
38
+ *
39
39
  * repeat('#list', store, 'todos', {
40
40
  * key: todo => todo.id,
41
41
  * create: (todo, el) => {
@@ -47,6 +47,11 @@
47
47
  * btn.textContent = 'Delete';
48
48
  * btn.onclick = () => deleteTodo(todo.id);
49
49
  * el.appendChild(btn);
50
+ *
51
+ * // Return a cleanup function — called automatically when element is removed
52
+ * return () => {
53
+ * // Unsubscribe from external listeners, remove timers, etc.
54
+ * };
50
55
  * },
51
56
  * update: (todo, el, index, { isFirstRender }) => {
52
57
  * // Called on every update - bind data
@@ -165,8 +170,9 @@ export function defaultScrollPreservation(container, context = {}) {
165
170
  * @param {Object} options - Configuration
166
171
  * @param {Function} options.key - Function to extract unique key: (item) => key
167
172
  * @param {Function} [options.render] - Function to render item (called for all items): (item, element, index) => void
168
- * @param {Function} [options.create] - Function for new elements only: (item, element, index) => void
173
+ * @param {Function} [options.create] - Function for new elements only: (item, element, index) => void | Function. If a function is returned, it is registered as the element's cleanup and called automatically when the element is removed (by list update or full cleanup).
169
174
  * @param {Function} [options.update] - Function for data binding: (item, element, index, { isFirstRender }) => void. Skipped if same item reference AND same index.
175
+ * @param {Function} [options.remove] - Additional cleanup when element is removed: (item, element) => void. Called after any cleanup function returned by create(). Optional — prefer returning a cleanup from create() for automatic lifecycle management.
170
176
  * @param {string|Function} [options.element='div'] - Element tag name or factory function
171
177
  * @param {Function|null} [options.preserveFocus=defaultFocusPreservation] - Focus preservation strategy (null to disable)
172
178
  * @param {Function|null} [options.preserveScroll=defaultScrollPreservation] - Scroll preservation strategy (null to disable)
@@ -179,6 +185,7 @@ export function repeat(container, store, arrayKey, options) {
179
185
  render,
180
186
  create,
181
187
  update,
188
+ remove,
182
189
  element = 'div',
183
190
  preserveFocus = defaultFocusPreservation,
184
191
  preserveScroll = defaultScrollPreservation
@@ -209,6 +216,8 @@ export function repeat(container, store, arrayKey, options) {
209
216
  const prevItemsByKey = new Map();
210
217
  // key -> previous index (for reorder detection)
211
218
  const prevIndexByKey = new Map();
219
+ // key -> cleanup function returned by create()
220
+ const cleanupByKey = new Map();
212
221
  const seenKeys = new Set();
213
222
 
214
223
  function createElement() {
@@ -250,6 +259,7 @@ export function repeat(container, store, arrayKey, options) {
250
259
  if (restoreScroll) restoreScroll();
251
260
  }
252
261
 
262
+ // eslint-disable-next-line sonarjs/cognitive-complexity -- keyed DOM reconciliation: create/reuse/remove nodes, key dedup, scroll/focus preservation
253
263
  function updateList() {
254
264
  const items = store[arrayKey];
255
265
 
@@ -293,7 +303,10 @@ export function repeat(container, store, arrayKey, options) {
293
303
  try {
294
304
  // Call create for new elements (DOM structure)
295
305
  if (isFirstRender && create) {
296
- create(item, el, i);
306
+ const cleanup = create(item, el, i);
307
+ if (typeof cleanup === 'function') {
308
+ cleanupByKey.set(k, cleanup);
309
+ }
297
310
  }
298
311
 
299
312
  // Call update for data binding (new and existing elements)
@@ -320,6 +333,7 @@ export function repeat(container, store, arrayKey, options) {
320
333
  nextEls.push(el);
321
334
  }
322
335
 
336
+ // eslint-disable-next-line sonarjs/cognitive-complexity -- DOM cleanup pass: remove stale nodes, call per-item cleanup callbacks, update maps
323
337
  applyPreservation(containerEl, () => {
324
338
  reconcileDOM(containerEl, nextEls);
325
339
 
@@ -327,9 +341,24 @@ export function repeat(container, store, arrayKey, options) {
327
341
  if (elementsByKey.size !== seenKeys.size) {
328
342
  for (const k of elementsByKey.keys()) {
329
343
  if (!seenKeys.has(k)) {
344
+ const el = elementsByKey.get(k);
345
+ const prevItem = prevItemsByKey.get(k);
346
+ // Call create-returned cleanup first, then remove callback
347
+ const cleanup = cleanupByKey.get(k);
348
+ if (typeof cleanup === 'function') {
349
+ try {
350
+ cleanup();
351
+ } catch (err) {
352
+ logError(`[Lume.js] repeat(): cleanup error for key "${k}":`, err);
353
+ }
354
+ }
355
+ if (typeof remove === 'function' && el) {
356
+ remove(prevItem, el);
357
+ }
330
358
  elementsByKey.delete(k);
331
359
  prevItemsByKey.delete(k);
332
360
  prevIndexByKey.delete(k);
361
+ cleanupByKey.delete(k);
333
362
  }
334
363
  }
335
364
  }
@@ -354,10 +383,25 @@ export function repeat(container, store, arrayKey, options) {
354
383
  updateList();
355
384
  logWarn('[Lume.js] repeat(): store is not reactive (no $subscribe or subscribe method)');
356
385
  return () => {
386
+ for (const [k, el] of elementsByKey) {
387
+ const prevItem = prevItemsByKey.get(k);
388
+ const cleanup = cleanupByKey.get(k);
389
+ if (typeof cleanup === 'function') {
390
+ try {
391
+ cleanup();
392
+ } catch (err) {
393
+ logError(`[Lume.js] repeat(): cleanup error for key "${k}":`, err);
394
+ }
395
+ }
396
+ if (typeof remove === 'function') {
397
+ remove(prevItem, el);
398
+ }
399
+ }
357
400
  containerEl.replaceChildren();
358
401
  elementsByKey.clear();
359
402
  prevItemsByKey.clear();
360
403
  prevIndexByKey.clear();
404
+ cleanupByKey.clear();
361
405
  seenKeys.clear();
362
406
  };
363
407
  }
@@ -366,11 +410,27 @@ export function repeat(container, store, arrayKey, options) {
366
410
  if (typeof unsubscribe === 'function') {
367
411
  unsubscribe();
368
412
  }
413
+ // Invoke cleanup and remove callback for all remaining elements before clearing
414
+ for (const [k, el] of elementsByKey) {
415
+ const prevItem = prevItemsByKey.get(k);
416
+ const cleanup = cleanupByKey.get(k);
417
+ if (typeof cleanup === 'function') {
418
+ try {
419
+ cleanup();
420
+ } catch (err) {
421
+ logError(`[Lume.js] repeat(): cleanup error for key "${k}":`, err);
422
+ }
423
+ }
424
+ if (typeof remove === 'function') {
425
+ remove(prevItem, el);
426
+ }
427
+ }
369
428
  // Clear DOM elements (replaceChildren is faster than loop)
370
429
  containerEl.replaceChildren();
371
430
  elementsByKey.clear();
372
431
  prevItemsByKey.clear();
373
432
  prevIndexByKey.clear();
433
+ cleanupByKey.clear();
374
434
  seenKeys.clear();
375
435
  };
376
436
  }
@@ -3,11 +3,20 @@
3
3
  * @param {Object} store - reactive store created with state()
4
4
  * @param {string} key - key in store to watch
5
5
  * @param {Function} callback - called with new value
6
+ * @param {Object} [options]
7
+ * @param {boolean} [options.immediate=true] - call callback immediately with current value
6
8
  * @returns {Function} unsubscribe function
7
9
  */
8
- export function watch(store, key, callback) {
10
+ export function watch(store, key, callback, { immediate = true } = {}) {
9
11
  if (!store.$subscribe) {
10
12
  throw new Error("store must be created with state()");
11
13
  }
14
+ if (!immediate) {
15
+ let skipped = false;
16
+ return store.$subscribe(key, (val) => {
17
+ if (!skipped) { skipped = true; return; }
18
+ callback(val);
19
+ });
20
+ }
12
21
  return store.$subscribe(key, callback);
13
22
  }
@@ -58,6 +58,7 @@ let currentEffect = null;
58
58
  * analytics.log(store.count); // Won't track store.count automatically
59
59
  * }, [[store, 'count']]); // Explicit: only re-run on store.count
60
60
  */
61
+ // eslint-disable-next-line sonarjs/cognitive-complexity -- handles both auto-tracking and explicit-deps modes with cleanup; splitting would require exporting internal state
61
62
  export function effect(fn, deps) {
62
63
  if (typeof fn !== 'function') {
63
64
  throw new Error('effect() requires a function');
package/src/core/state.js CHANGED
@@ -100,6 +100,7 @@ export function state(obj) {
100
100
  if (flushScheduled) return;
101
101
 
102
102
  flushScheduled = true;
103
+ // eslint-disable-next-line sonarjs/cognitive-complexity -- single-pass flush loop: hooks → subscribers → effects → cycle detection; must stay atomic
103
104
  queueMicrotask(() => {
104
105
  let iterations = 0;
105
106
  const MAX_ITERATIONS = 100;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Create a handler for an ARIA attribute.
3
+ * Coerces value to "true"/"false" string — use stringAttr("aria-X") for token/string ARIA attrs.
4
+ *
5
+ * @param {string} name - ARIA name, with or without "aria-" prefix
6
+ * @returns {{ attr: string, apply: function }}
7
+ */
8
+ export function ariaAttr(name) {
9
+ const fullName = name.startsWith('aria-') ? name : `aria-${name}`;
10
+ return {
11
+ attr: `data-${fullName}`,
12
+ apply(el, val) { el.setAttribute(fullName, val ? 'true' : 'false'); }
13
+ };
14
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Create a handler for any HTML boolean attribute.
3
+ * Uses toggleAttribute() — works correctly with any attribute name
4
+ * (readonly, contenteditable, etc.) without worrying about camelCase property names.
5
+ *
6
+ * @param {string} name - Attribute name (e.g., 'readonly', 'open', 'contenteditable')
7
+ * @returns {{ attr: string, apply: function }}
8
+ */
9
+ export function boolAttr(name) {
10
+ return {
11
+ attr: `data-${name}`,
12
+ apply(el, val) { el.toggleAttribute(name, Boolean(val)); }
13
+ };
14
+ }
@@ -0,0 +1,5 @@
1
+ /** data-classname="key" → el.className = val || '' */
2
+ export const className = {
3
+ attr: 'data-classname',
4
+ apply(el, val) { el.className = val || ''; }
5
+ };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Create handlers for CSS class toggling.
3
+ * Each name creates a handler: data-class-{name}="key" → el.classList.toggle(name, Boolean(val))
4
+ * Returns an array — pass directly to handlers (auto-flattened by bindDom).
5
+ *
6
+ * @param {...string} names - CSS class names to create handlers for
7
+ * @returns {Array<{ attr: string, apply: function }>}
8
+ */
9
+ export function classToggle(...names) {
10
+ return names.map(name => ({
11
+ attr: `data-class-${name}`,
12
+ apply(el, val) { el.classList.toggle(name, Boolean(val)); }
13
+ }));
14
+ }
@@ -0,0 +1,57 @@
1
+ import { show } from './show.js';
2
+ import { boolAttr } from './boolAttr.js';
3
+ import { ariaAttr } from './ariaAttr.js';
4
+ import { stringAttr } from './stringAttr.js';
5
+
6
+ /** @see https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes#boolean_attributes */
7
+ const BOOL_ATTRS = [
8
+ 'readonly', 'open', 'novalidate', 'formnovalidate', 'multiple',
9
+ 'autofocus', 'autoplay', 'controls', 'loop', 'muted', 'defer',
10
+ 'async', 'reversed', 'selected', 'inert', 'allowfullscreen',
11
+ ];
12
+
13
+ /** @see https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes */
14
+ const STRING_ATTRS = [
15
+ 'href', 'src', 'alt', 'title', 'placeholder', 'action', 'method',
16
+ 'target', 'rel', 'type', 'name', 'role', 'lang', 'tabindex',
17
+ 'pattern', 'min', 'max', 'step', 'minlength', 'maxlength',
18
+ 'width', 'height', 'for', 'form', 'accept', 'autocomplete',
19
+ 'loading', 'decoding', 'inputmode', 'enterkeyhint', 'draggable',
20
+ 'contenteditable', 'spellcheck', 'translate', 'dir', 'id',
21
+ 'poster', 'preload', 'download', 'media', 'sizes', 'srcset',
22
+ 'colspan', 'rowspan', 'scope', 'headers', 'wrap', 'sandbox',
23
+ ];
24
+
25
+ /** ARIA boolean state attributes — coerced to "true"/"false" string. */
26
+ const ARIA_BOOL_ATTRS = [
27
+ 'pressed', 'selected', 'disabled', 'checked', 'invalid', 'required',
28
+ 'busy', 'modal', 'multiselectable', 'multiline', 'readonly', 'atomic',
29
+ ];
30
+
31
+ /** ARIA string/token/numeric attributes — value passed through as-is. */
32
+ const ARIA_STRING_ATTRS = [
33
+ 'current', 'live', 'relevant', 'haspopup',
34
+ 'sort', 'autocomplete', 'orientation',
35
+ 'label', 'describedby', 'labelledby', 'controls', 'owns',
36
+ 'activedescendant', 'errormessage', 'details', 'flowto',
37
+ 'valuenow', 'valuemin', 'valuemax', 'valuetext',
38
+ 'colcount', 'colindex', 'colspan', 'rowcount', 'rowindex', 'rowspan',
39
+ 'level', 'setsize', 'posinset', 'placeholder', 'roledescription',
40
+ 'keyshortcuts', 'braillelabel', 'brailleroledescription',
41
+ ];
42
+
43
+ /**
44
+ * One-import preset that enables all standard HTML attributes as reactive handlers.
45
+ * Returns a flat array — pass directly to handlers option.
46
+ *
47
+ * @returns {Array<{ attr: string, apply: function }>}
48
+ */
49
+ export function htmlAttrs() {
50
+ return [
51
+ show,
52
+ ...BOOL_ATTRS.map(name => boolAttr(name)),
53
+ ...STRING_ATTRS.map(name => stringAttr(name)),
54
+ ...ARIA_BOOL_ATTRS.map(name => ariaAttr(name)),
55
+ ...ARIA_STRING_ATTRS.map(name => stringAttr(`aria-${name}`)),
56
+ ];
57
+ }
@@ -14,6 +14,19 @@ import type { Handler } from '../index.js';
14
14
  */
15
15
  export const show: Handler;
16
16
 
17
+ /**
18
+ * data-classname="key" → el.className = val (replaces full class string)
19
+ * Use classToggle() for toggling individual classes.
20
+ *
21
+ * @example
22
+ * ```typescript
23
+ * bindDom(root, store, { handlers: [className] });
24
+ * // <div data-classname="cardClass">...</div>
25
+ * // store.cardClass = 'card card--error card--large';
26
+ * ```
27
+ */
28
+ export const className: Handler;
29
+
17
30
  /**
18
31
  * Create a handler for any HTML boolean property.
19
32
  * Use for properties beyond the built-in hidden/disabled/checked/required.
@@ -19,199 +19,11 @@
19
19
  * { attr: string, apply(el: HTMLElement, val: any): void }
20
20
  */
21
21
 
22
- // --- Ready-to-use Handlers ---
23
-
24
- /**
25
- * data-show="key" el.hidden = !Boolean(val)
26
- * Shows element when state value is truthy (inverse of built-in data-hidden).
27
- */
28
- export const show = {
29
- attr: 'data-show',
30
- apply(el, val) { el.hidden = !Boolean(val); }
31
- };
32
-
33
- // --- Factory Functions ---
34
-
35
- /**
36
- * Create a handler for any HTML boolean attribute.
37
- * Uses toggleAttribute() — works correctly with any attribute name
38
- * (readonly, contenteditable, etc.) without worrying about camelCase property names.
39
- *
40
- * Note: built-in boolean handlers (hidden, disabled, checked, required) use
41
- * property assignment directly. This factory uses toggleAttribute for broader
42
- * attribute name compatibility.
43
- *
44
- * @param {string} name - Attribute name (e.g., 'readonly', 'open', 'contenteditable')
45
- * @returns {{ attr: string, apply: function }}
46
- *
47
- * @example
48
- * bindDom(root, store, { handlers: [boolAttr('readonly')] });
49
- * // <input data-readonly="isReadonly" />
50
- */
51
- export function boolAttr(name) {
52
- return {
53
- attr: `data-${name}`,
54
- apply(el, val) { el.toggleAttribute(name, Boolean(val)); }
55
- };
56
- }
57
-
58
- /**
59
- * Create a handler for an ARIA attribute.
60
- * Use for ARIA attrs beyond the built-in aria-expanded/aria-hidden.
61
- *
62
- * @param {string} name - ARIA name, with or without "aria-" prefix
63
- * @returns {{ attr: string, apply: function }}
64
- *
65
- * @example
66
- * bindDom(root, store, { handlers: [ariaAttr('pressed'), ariaAttr('selected')] });
67
- * // <button data-aria-pressed="isPressed">Toggle</button>
68
- */
69
- export function ariaAttr(name) {
70
- const fullName = name.startsWith('aria-') ? name : `aria-${name}`;
71
- return {
72
- attr: `data-${fullName}`,
73
- apply(el, val) { el.setAttribute(fullName, val ? 'true' : 'false'); }
74
- };
75
- }
76
-
77
- /**
78
- * Create handlers for CSS class toggling.
79
- * Each name creates a handler: data-class-{name}="key" → el.classList.toggle(name, Boolean(val))
80
- *
81
- * Returns an array — pass directly to handlers (auto-flattened by bindDom).
82
- *
83
- * @param {...string} names - CSS class names to create handlers for
84
- * @returns {Array<{ attr: string, apply: function }>}
85
- *
86
- * @example
87
- * bindDom(root, store, { handlers: [classToggle('active', 'loading', 'error')] });
88
- * // <div data-class-active="isActive" data-class-loading="isLoading">
89
- */
90
- export function classToggle(...names) {
91
- return names.map(name => ({
92
- attr: `data-class-${name}`,
93
- apply(el, val) { el.classList.toggle(name, Boolean(val)); }
94
- }));
95
- }
96
-
97
- /**
98
- * Create a handler for any string attribute (href, src, title, alt, action, etc.)
99
- * Sets the attribute value as a string. Removes attribute when value is null/undefined.
100
- *
101
- * @param {string} name - HTML attribute name (e.g., 'href', 'src', 'title')
102
- * @returns {{ attr: string, apply: function }}
103
- *
104
- * @example
105
- * bindDom(root, store, { handlers: [stringAttr('href'), stringAttr('src')] });
106
- * // <a data-href="profileUrl">Profile</a>
107
- * // <img data-src="imageUrl" />
108
- */
109
- export function stringAttr(name) {
110
- return {
111
- attr: `data-${name}`,
112
- apply(el, val) {
113
- if (val == null) el.removeAttribute(name);
114
- else el.setAttribute(name, String(val));
115
- }
116
- };
117
- }
118
-
119
- // --- Presets ---
120
-
121
- /** Form-related handlers (beyond built-in disabled/checked/required) */
122
- export const formHandlers = [
123
- boolAttr('readonly'),
124
- ];
125
-
126
- /** Additional ARIA handlers (beyond built-in aria-expanded/aria-hidden) */
127
- export const a11yHandlers = [
128
- ariaAttr('pressed'),
129
- ariaAttr('selected'),
130
- ariaAttr('disabled'),
131
- ];
132
-
133
- // --- htmlAttrs() — All Standard HTML Attributes ---
134
-
135
- /**
136
- * Standard HTML boolean attributes (beyond built-in hidden/disabled/checked/required).
137
- * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes#boolean_attributes
138
- */
139
- const BOOL_ATTRS = [
140
- 'readonly', 'open', 'novalidate', 'formnovalidate', 'multiple',
141
- 'autofocus', 'autoplay', 'controls', 'loop', 'muted', 'defer',
142
- 'async', 'reversed', 'selected', 'inert', 'allowfullscreen',
143
- ];
144
-
145
- /**
146
- * Standard HTML string attributes.
147
- * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes
148
- */
149
- const STRING_ATTRS = [
150
- 'href', 'src', 'alt', 'title', 'placeholder', 'action', 'method',
151
- 'target', 'rel', 'type', 'name', 'role', 'lang', 'tabindex',
152
- 'pattern', 'min', 'max', 'step', 'minlength', 'maxlength',
153
- 'width', 'height', 'for', 'form', 'accept', 'autocomplete',
154
- 'loading', 'decoding', 'inputmode', 'enterkeyhint', 'draggable',
155
- 'contenteditable', 'spellcheck', 'translate', 'dir', 'id',
156
- 'poster', 'preload', 'download', 'media', 'sizes', 'srcset',
157
- 'colspan', 'rowspan', 'scope', 'headers', 'wrap', 'sandbox',
158
- ];
159
-
160
- /**
161
- * ARIA boolean state attributes — toggled between "true" and "false".
162
- * Use ariaAttr() for these (coerces to "true"/"false" string).
163
- * @see https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes
164
- */
165
- const ARIA_BOOL_ATTRS = [
166
- 'pressed', 'selected', 'disabled', 'checked', 'invalid', 'required',
167
- 'busy', 'modal', 'multiselectable', 'multiline', 'readonly', 'atomic',
168
- ];
169
-
170
- /**
171
- * ARIA string/token/numeric attributes — value passed through as-is.
172
- * Use stringAttr() with "aria-" prefix for these.
173
- * @see https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes
174
- */
175
- const ARIA_STRING_ATTRS = [
176
- 'current', 'live', 'relevant', 'haspopup',
177
- 'sort', 'autocomplete', 'orientation',
178
- 'label', 'describedby', 'labelledby', 'controls', 'owns',
179
- 'activedescendant', 'errormessage', 'details', 'flowto',
180
- 'valuenow', 'valuemin', 'valuemax', 'valuetext',
181
- 'colcount', 'colindex', 'colspan', 'rowcount', 'rowindex', 'rowspan',
182
- 'level', 'setsize', 'posinset', 'placeholder', 'roledescription',
183
- 'keyshortcuts', 'braillelabel', 'brailleroledescription',
184
- ];
185
-
186
- /**
187
- * One-import preset that enables all standard HTML attributes as reactive handlers.
188
- *
189
- * Includes:
190
- * - Boolean attributes: readonly, open, autofocus, controls, muted, inert, etc.
191
- * - String attributes: href, src, alt, title, placeholder, role, tabindex, etc.
192
- * - ARIA attributes: aria-pressed, aria-label, aria-describedby, aria-valuenow, etc.
193
- * - Show handler: data-show (inverse of data-hidden)
194
- *
195
- * Returns a flat array — pass directly to handlers option.
196
- *
197
- * @returns {Array<{ attr: string, apply: function }>}
198
- *
199
- * @example
200
- * import { htmlAttrs } from 'lume-js/handlers';
201
- *
202
- * bindDom(document.body, store, { handlers: [htmlAttrs()] });
203
- * // Now use any data-* attribute:
204
- * // <a data-href="url">Link</a>
205
- * // <input data-readonly="isLocked" />
206
- * // <div data-aria-label="labelText">...</div>
207
- * // <div data-show="isVisible">...</div>
208
- */
209
- export function htmlAttrs() {
210
- return [
211
- show,
212
- ...BOOL_ATTRS.map(name => boolAttr(name)),
213
- ...STRING_ATTRS.map(name => stringAttr(name)),
214
- ...ARIA_BOOL_ATTRS.map(name => ariaAttr(name)),
215
- ...ARIA_STRING_ATTRS.map(name => stringAttr(`aria-${name}`)),
216
- ];
217
- }
22
+ export { show } from './show.js';
23
+ export { className } from './className.js';
24
+ export { boolAttr } from './boolAttr.js';
25
+ export { ariaAttr } from './ariaAttr.js';
26
+ export { classToggle } from './classToggle.js';
27
+ export { stringAttr } from './stringAttr.js';
28
+ export { htmlAttrs } from './htmlAttrs.js';
29
+ export { formHandlers, a11yHandlers } from './presets.js';
@@ -0,0 +1,14 @@
1
+ import { boolAttr } from './boolAttr.js';
2
+ import { ariaAttr } from './ariaAttr.js';
3
+
4
+ /** Form-related handlers (beyond built-in disabled/checked/required) */
5
+ export const formHandlers = [
6
+ boolAttr('readonly'),
7
+ ];
8
+
9
+ /** Additional ARIA handlers (beyond built-in aria-expanded/aria-hidden) */
10
+ export const a11yHandlers = [
11
+ ariaAttr('pressed'),
12
+ ariaAttr('selected'),
13
+ ariaAttr('disabled'),
14
+ ];
@@ -0,0 +1,5 @@
1
+ /** data-show="key" → el.hidden = !Boolean(val) */
2
+ export const show = {
3
+ attr: 'data-show',
4
+ apply(el, val) { el.hidden = !Boolean(val); }
5
+ };