@zakkster/lite-ui-fx 1.4.0 → 1.6.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/CHANGELOG.md +95 -0
- package/README.md +64 -12
- package/UIFX-RECIPE-GUIDE.md +59 -3
- package/UIFXController.d.ts +86 -1
- package/UIFXController.js +513 -17
- package/UIFXRecipes.d.ts +42 -4
- package/UIFXRecipes.js +366 -46
- package/llms.txt +54 -13
- package/package.json +1 -1
package/UIFXController.js
CHANGED
|
@@ -22,7 +22,7 @@ import { Ticker } from '@zakkster/lite-ticker';
|
|
|
22
22
|
|
|
23
23
|
// Three-place version sync: this constant, package.json "version", and the
|
|
24
24
|
// VERSION line in llms.txt must always match. /release keeps them locked.
|
|
25
|
-
export const VERSION = '1.
|
|
25
|
+
export const VERSION = '1.6.0';
|
|
26
26
|
|
|
27
27
|
// ---------------------------------------------------------
|
|
28
28
|
// SHARED TICKER (ref-counted, one RAF for all UI components)
|
|
@@ -84,7 +84,14 @@ function releaseSliderStyle() {
|
|
|
84
84
|
// ---------------------------------------------------------
|
|
85
85
|
|
|
86
86
|
const KNOWN_HOOKS = ['init', 'tick', 'onHover', 'onLeave', 'onClick', 'onToggle', 'onDrag', 'destroy'];
|
|
87
|
-
const KNOWN_OPTIONS = ['width', 'height', 'padding', 'label', 'value', 'checked', 'disabled', 'seed', 'colors', 'theme', 'text', 'font'];
|
|
87
|
+
const KNOWN_OPTIONS = ['width', 'height', 'padding', 'label', 'value', 'checked', 'disabled', 'seed', 'colors', 'theme', 'text', 'font', 'knobMode', 'announce'];
|
|
88
|
+
const KNOB_MODES = ['rotate', 'vertical'];
|
|
89
|
+
|
|
90
|
+
// Options valid in decorate mode (decorateUIFX). A canvas AROUND a live element
|
|
91
|
+
// inherits the host's geometry (offset box) and value (read from el), so the
|
|
92
|
+
// hijack-only options (width/height/value/checked/disabled/knobMode/announce/
|
|
93
|
+
// label) are rejected here -- fail closed. Cold: read only at mount.
|
|
94
|
+
const DECORATE_OPTIONS = ['padding', 'seed', 'colors', 'theme', 'text', 'font'];
|
|
88
95
|
|
|
89
96
|
// Levenshtein edit distance. Cold: only reached on the error path.
|
|
90
97
|
function _editDistance(a, b) {
|
|
@@ -143,8 +150,15 @@ export const UIType = Object.freeze({
|
|
|
143
150
|
BUTTON: 'button',
|
|
144
151
|
TOGGLE: 'toggle',
|
|
145
152
|
SLIDER: 'slider',
|
|
153
|
+
CHECKBOX: 'checkbox', // <input type=checkbox> WITHOUT role=switch (a check is not a switch)
|
|
154
|
+
PROGRESS: 'progress', // native <progress>, non-interactive, value driven programmatically
|
|
155
|
+
KNOB: 'knob', // <input type=range>, arrows native, canvas-side knobMode pointer map
|
|
146
156
|
});
|
|
147
157
|
|
|
158
|
+
// The valid mount types, derived once from UIType so the controller guard, the
|
|
159
|
+
// recipe registry, and the d.ts never drift apart. Cold: read only at mount.
|
|
160
|
+
const _KNOWN_TYPES = new Set(Object.values(UIType));
|
|
161
|
+
|
|
148
162
|
|
|
149
163
|
// =========================================================
|
|
150
164
|
// UIFXController -- The Canvas Hijacker
|
|
@@ -177,11 +191,11 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
|
|
|
177
191
|
throw new Error('mountUIFX: container must be a DOM element');
|
|
178
192
|
}
|
|
179
193
|
|
|
180
|
-
// 1b. type: exactly one of the
|
|
194
|
+
// 1b. type: exactly one of the known element types (UIType). An unknown or
|
|
181
195
|
// undefined type is an Error here, never a silent default to a button
|
|
182
196
|
// (fail closed -- the type selects the native element).
|
|
183
|
-
if (
|
|
184
|
-
throw new Error('mountUIFX: type must be UIType.BUTTON,
|
|
197
|
+
if (!_KNOWN_TYPES.has(type)) {
|
|
198
|
+
throw new Error('mountUIFX: type must be one of UIType.BUTTON, TOGGLE, SLIDER, CHECKBOX, PROGRESS, KNOB');
|
|
185
199
|
}
|
|
186
200
|
|
|
187
201
|
// 2. options: unknown keys -> did-you-mean; value/checked/disabled
|
|
@@ -205,6 +219,8 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
|
|
|
205
219
|
const height = options.height;
|
|
206
220
|
const padding = options.padding === undefined ? 40 : options.padding;
|
|
207
221
|
const label = options.label === undefined ? '' : options.label;
|
|
222
|
+
const knobMode = options.knobMode || 'rotate'; // KNOB pointer map; validated below
|
|
223
|
+
const announce = options.announce === true; // PROGRESS aria-live; validated below
|
|
208
224
|
|
|
209
225
|
// Reserved theming options (decisions/0002): all optional, validated fail
|
|
210
226
|
// closed here, then forwarded to the recipe factory which resolves them in
|
|
@@ -233,6 +249,26 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
|
|
|
233
249
|
throw new Error('mountUIFX: option "seed" must be a finite number');
|
|
234
250
|
}
|
|
235
251
|
|
|
252
|
+
// Type-scoped options (U4a). knobMode belongs only to a KNOB; announce only
|
|
253
|
+
// to a PROGRESS. Presence on the wrong type is a mistake, not a silent
|
|
254
|
+
// ignore (fail closed). Both validated here, before any element exists.
|
|
255
|
+
if (options.knobMode !== undefined) {
|
|
256
|
+
if (type !== UIType.KNOB) {
|
|
257
|
+
throw new Error('mountUIFX: option "knobMode" is only valid for UIType.KNOB');
|
|
258
|
+
}
|
|
259
|
+
if (KNOB_MODES.indexOf(options.knobMode) === -1) {
|
|
260
|
+
throw new Error('mountUIFX: option "knobMode" must be "rotate" or "vertical"');
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (options.announce !== undefined) {
|
|
264
|
+
if (type !== UIType.PROGRESS) {
|
|
265
|
+
throw new Error('mountUIFX: option "announce" is only valid for UIType.PROGRESS');
|
|
266
|
+
}
|
|
267
|
+
if (typeof options.announce !== 'boolean') {
|
|
268
|
+
throw new Error('mountUIFX: option "announce" must be a boolean');
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
236
272
|
// 3. recipeFactory
|
|
237
273
|
if (typeof recipeFactory !== 'function') {
|
|
238
274
|
throw new Error('mountUIFX: recipeFactory must be a function');
|
|
@@ -279,24 +315,37 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
|
|
|
279
315
|
|
|
280
316
|
try {
|
|
281
317
|
// -- Resolve dimensions --
|
|
282
|
-
|
|
283
|
-
|
|
318
|
+
// SLIDER/PROGRESS/KNOB share the 200x28 range geometry (so every vol.3
|
|
319
|
+
// re-home renders byte-identical to its slider era); CHECKBOX shares the
|
|
320
|
+
// 64x36 toggle geometry; BUTTON keeps 160x48.
|
|
321
|
+
const _rangeLike = type === UIType.SLIDER || type === UIType.PROGRESS || type === UIType.KNOB;
|
|
322
|
+
const w = width || (type === UIType.BUTTON ? 160 : _rangeLike ? 200 : 64);
|
|
323
|
+
const h = height || (type === UIType.BUTTON ? 48 : _rangeLike ? 28 : 36);
|
|
284
324
|
let dpr = window.devicePixelRatio || 1;
|
|
285
325
|
|
|
286
326
|
// -- Create native element (invisible, accessible, receives events) --
|
|
287
327
|
let el;
|
|
288
|
-
if (type === UIType.TOGGLE) {
|
|
328
|
+
if (type === UIType.TOGGLE || type === UIType.CHECKBOX) {
|
|
289
329
|
el = document.createElement('input');
|
|
290
330
|
el.type = 'checkbox';
|
|
291
|
-
|
|
331
|
+
// TOGGLE is a switch; CHECKBOX is a plain checkbox. A check is not a
|
|
332
|
+
// switch -- U4a drops the role for CHECKBOX (the vol.3 mis-mount fix).
|
|
333
|
+
if (type === UIType.TOGGLE) el.setAttribute('role', 'switch');
|
|
292
334
|
el.checked = checked; // coerced boolean; lands before frame 1
|
|
293
335
|
if (label) el.setAttribute('aria-label', label);
|
|
294
|
-
} else if (type === UIType.SLIDER) {
|
|
336
|
+
} else if (type === UIType.SLIDER || type === UIType.KNOB) {
|
|
295
337
|
el = document.createElement('input');
|
|
296
338
|
el.type = 'range';
|
|
297
339
|
el.min = '0'; el.max = '100';
|
|
298
340
|
el.value = value !== undefined ? String(value * 100) : '50'; // 0..1 -> 0..100
|
|
299
341
|
if (label) el.setAttribute('aria-label', label);
|
|
342
|
+
} else if (type === UIType.PROGRESS) {
|
|
343
|
+
// Non-interactive: value is written programmatically (setValue) only,
|
|
344
|
+
// and exposed to assistive tech by the native <progress> element.
|
|
345
|
+
el = document.createElement('progress');
|
|
346
|
+
el.max = 1;
|
|
347
|
+
el.value = value !== undefined ? value : 0; // 0..1
|
|
348
|
+
if (label) el.setAttribute('aria-label', label);
|
|
300
349
|
} else {
|
|
301
350
|
el = document.createElement('button');
|
|
302
351
|
el.textContent = label || 'Action';
|
|
@@ -316,7 +365,7 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
|
|
|
316
365
|
// Slider thumb needs explicit sizing for hit area. One shared, ref-counted
|
|
317
366
|
// <style> for all sliders (U-09): released in destroy() when the last slider
|
|
318
367
|
// goes -- head child count nets to zero across mount/destroy.
|
|
319
|
-
if (type === UIType.SLIDER) {
|
|
368
|
+
if (type === UIType.SLIDER || type === UIType.KNOB) {
|
|
320
369
|
acquireSliderStyle();
|
|
321
370
|
styleAcquired = true;
|
|
322
371
|
el.classList.add('uifx-slider');
|
|
@@ -349,14 +398,32 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
|
|
|
349
398
|
container.appendChild(wrapper);
|
|
350
399
|
wrapperAppended = true;
|
|
351
400
|
|
|
401
|
+
// -- Optional aria-live announcer for PROGRESS (U4a). Opt-in via
|
|
402
|
+
// { announce: true }: a visually-hidden polite region that setValue
|
|
403
|
+
// updates at 10% steps. It lives in the wrapper, so wrapper.remove() in
|
|
404
|
+
// destroy() takes it with everything else -- no separate teardown. --
|
|
405
|
+
let announceRegion = null;
|
|
406
|
+
if (type === UIType.PROGRESS && announce) {
|
|
407
|
+
announceRegion = document.createElement('span');
|
|
408
|
+
announceRegion.setAttribute('aria-live', 'polite');
|
|
409
|
+
Object.assign(announceRegion.style, {
|
|
410
|
+
position: 'absolute', width: '1px', height: '1px',
|
|
411
|
+
overflow: 'hidden', clipPath: 'inset(50%)',
|
|
412
|
+
whiteSpace: 'nowrap', border: '0', padding: '0', margin: '-1px',
|
|
413
|
+
});
|
|
414
|
+
wrapper.appendChild(announceRegion);
|
|
415
|
+
}
|
|
416
|
+
let _lastAnnouncePct = -1; // last announced 10% step (cold: only setValue writes)
|
|
417
|
+
|
|
352
418
|
// -- State (value/checked/disabled land here BEFORE frame 1) --
|
|
353
419
|
const state = {
|
|
354
420
|
hover: false,
|
|
355
421
|
active: false, // pointer is down
|
|
356
422
|
focused: false, // keyboard focus
|
|
357
423
|
toggled: checked, // coerced boolean; element + state AGREE
|
|
424
|
+
indeterminate: false, // CHECKBOX only; set via setValue(null)
|
|
358
425
|
disabled, // recipes can render a disabled look
|
|
359
|
-
val: value !== undefined ? value : (type === UIType.SLIDER ? 0.5 : 0), // 0-1
|
|
426
|
+
val: value !== undefined ? value : (type === UIType.SLIDER || type === UIType.KNOB ? 0.5 : 0), // 0-1
|
|
360
427
|
w, h, padding, dpr,
|
|
361
428
|
};
|
|
362
429
|
|
|
@@ -366,6 +433,28 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
|
|
|
366
433
|
// pointer event, then lazily filled once (see updatePointer).
|
|
367
434
|
let rect = null;
|
|
368
435
|
|
|
436
|
+
// Apply a numeric value to a valued control (SLIDER/KNOB/PROGRESS): reflect
|
|
437
|
+
// it to the native element, update state, announce (PROGRESS), and fire
|
|
438
|
+
// onDrag exactly once when asked. A programmatic el.value write fires NO
|
|
439
|
+
// native 'input', so this explicit hook call is the only one -- no double
|
|
440
|
+
// fire. Cold path (setValue + knob drag), never a per-frame body.
|
|
441
|
+
function _applyVal(v, fireHook) {
|
|
442
|
+
state.val = v;
|
|
443
|
+
if (type === UIType.PROGRESS) {
|
|
444
|
+
el.value = v; // 0..1, native max=1
|
|
445
|
+
if (announceRegion) {
|
|
446
|
+
const pct = Math.round(v * 10) * 10;
|
|
447
|
+
if (pct !== _lastAnnouncePct) {
|
|
448
|
+
_lastAnnouncePct = pct;
|
|
449
|
+
announceRegion.textContent = pct + '%';
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
} else {
|
|
453
|
+
el.value = String(v * 100); // range 0..100
|
|
454
|
+
}
|
|
455
|
+
if (fireHook && recipe.onDrag) recipe.onDrag(v, pointer.vx, state);
|
|
456
|
+
}
|
|
457
|
+
|
|
369
458
|
// -- Initialize recipe (already validated in phase 1: object, tick fn,
|
|
370
459
|
// only known hooks). ctx exists now, so init can run. --
|
|
371
460
|
if (recipe.init) recipe.init(ctx, w, h, padding);
|
|
@@ -417,9 +506,12 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
|
|
|
417
506
|
el.addEventListener('focus', () => { state.focused = true; }, { signal });
|
|
418
507
|
el.addEventListener('blur', () => { state.focused = false; }, { signal });
|
|
419
508
|
|
|
420
|
-
// Toggle events
|
|
421
|
-
if (type === UIType.TOGGLE) {
|
|
509
|
+
// Toggle + checkbox events (both are a native <input type=checkbox>)
|
|
510
|
+
if (type === UIType.TOGGLE || type === UIType.CHECKBOX) {
|
|
422
511
|
el.addEventListener('change', () => {
|
|
512
|
+
// A user interaction resolves any indeterminate state (native does
|
|
513
|
+
// this too); keep state.indeterminate in agreement.
|
|
514
|
+
state.indeterminate = false;
|
|
423
515
|
state.toggled = el.checked;
|
|
424
516
|
if (recipe.onToggle) recipe.onToggle(state.toggled, state);
|
|
425
517
|
}, { signal });
|
|
@@ -431,14 +523,51 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
|
|
|
431
523
|
}, { signal });
|
|
432
524
|
}
|
|
433
525
|
|
|
434
|
-
// Slider events
|
|
435
|
-
|
|
526
|
+
// Slider + knob value events. A range input fires 'input' on native drag
|
|
527
|
+
// (slider) AND on arrow keys (both) -- one path for keyboard on either type.
|
|
528
|
+
if (type === UIType.SLIDER || type === UIType.KNOB) {
|
|
436
529
|
el.addEventListener('input', () => {
|
|
437
530
|
state.val = el.value / 100;
|
|
438
531
|
if (recipe.onDrag) recipe.onDrag(state.val, pointer.vx, state);
|
|
439
532
|
}, { signal });
|
|
440
533
|
}
|
|
441
534
|
|
|
535
|
+
// KNOB pointer remap (U4a). A range input maps value to horizontal thumb
|
|
536
|
+
// position; a knob maps a rotational or vertical drag instead. Arrow keys
|
|
537
|
+
// stay native (the 'input' handler above); for pointer we drive the value
|
|
538
|
+
// ourselves and preventDefault the native jump-to-pointer, restoring focus
|
|
539
|
+
// by hand. All cold: pointer handlers, no per-frame work.
|
|
540
|
+
if (type === UIType.KNOB) {
|
|
541
|
+
let knobActive = false;
|
|
542
|
+
let knobStartVal = 0;
|
|
543
|
+
let knobStartY = 0;
|
|
544
|
+
el.addEventListener('pointerdown', (e) => {
|
|
545
|
+
knobActive = true;
|
|
546
|
+
knobStartVal = state.val;
|
|
547
|
+
knobStartY = e.clientY;
|
|
548
|
+
refreshRect();
|
|
549
|
+
el.focus();
|
|
550
|
+
e.preventDefault(); // suppress the range's native jump-to-pointer
|
|
551
|
+
if (el.setPointerCapture) el.setPointerCapture(e.pointerId);
|
|
552
|
+
}, { signal });
|
|
553
|
+
el.addEventListener('pointermove', (e) => {
|
|
554
|
+
if (!knobActive) return;
|
|
555
|
+
let v;
|
|
556
|
+
if (knobMode === 'vertical') {
|
|
557
|
+
v = knobStartVal + (knobStartY - e.clientY) / 150; // 150px = full sweep
|
|
558
|
+
} else {
|
|
559
|
+
const cx = rect ? rect.left + rect.width / 2 : e.clientX;
|
|
560
|
+
const cy = rect ? rect.top + rect.height / 2 : e.clientY;
|
|
561
|
+
let a = Math.atan2(e.clientY - cy, e.clientX - cx); // -PI..PI
|
|
562
|
+
a = (a + Math.PI * 2.5) % (Math.PI * 2); // 0 at bottom, clockwise
|
|
563
|
+
v = a / (Math.PI * 2);
|
|
564
|
+
}
|
|
565
|
+
if (v < 0) v = 0; else if (v > 1) v = 1;
|
|
566
|
+
_applyVal(v, true); // reflect + fire onDrag once
|
|
567
|
+
}, { signal });
|
|
568
|
+
el.addEventListener('pointerup', () => { knobActive = false; }, { signal });
|
|
569
|
+
}
|
|
570
|
+
|
|
442
571
|
// -- DPR re-read on display change (cold, feature-detected). Absent
|
|
443
572
|
// matchMedia is a silent no-op: the canvas stays at mount DPR (fail
|
|
444
573
|
// closed, never throw). Listener bound to signal for teardown. --
|
|
@@ -497,6 +626,50 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
|
|
|
497
626
|
/** Current state (read-only reference). */
|
|
498
627
|
state,
|
|
499
628
|
|
|
629
|
+
/**
|
|
630
|
+
* Programmatically set a valued control (SLIDER/KNOB/PROGRESS) to v in
|
|
631
|
+
* [0,1]: updates the native element, state.val, any PROGRESS announcer,
|
|
632
|
+
* and fires onDrag exactly once (a programmatic write emits no native
|
|
633
|
+
* event, so there is no second fire). For a CHECKBOX, setValue(null)
|
|
634
|
+
* sets the indeterminate state. Fail closed on wrong type / bad value.
|
|
635
|
+
*/
|
|
636
|
+
setValue(v) {
|
|
637
|
+
if (destroyed) return;
|
|
638
|
+
if (type === UIType.CHECKBOX) {
|
|
639
|
+
if (v === null) {
|
|
640
|
+
el.indeterminate = true;
|
|
641
|
+
state.indeterminate = true;
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
throw new Error('setValue: a checkbox takes setChecked(bool), or setValue(null) for indeterminate');
|
|
645
|
+
}
|
|
646
|
+
if (type !== UIType.SLIDER && type !== UIType.KNOB && type !== UIType.PROGRESS) {
|
|
647
|
+
throw new Error('setValue: only SLIDER, KNOB, and PROGRESS carry a numeric value');
|
|
648
|
+
}
|
|
649
|
+
if (typeof v !== 'number' || !Number.isFinite(v) || v < 0 || v > 1) {
|
|
650
|
+
throw new Error('setValue: v must be a number in [0,1]');
|
|
651
|
+
}
|
|
652
|
+
_applyVal(v, true);
|
|
653
|
+
},
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Programmatically set a TOGGLE/CHECKBOX checked state: updates the
|
|
657
|
+
* native element, state.toggled, clears indeterminate, and fires
|
|
658
|
+
* onToggle exactly once. Fail closed on the wrong type.
|
|
659
|
+
*/
|
|
660
|
+
setChecked(b) {
|
|
661
|
+
if (destroyed) return;
|
|
662
|
+
if (type !== UIType.TOGGLE && type !== UIType.CHECKBOX) {
|
|
663
|
+
throw new Error('setChecked: only TOGGLE and CHECKBOX carry a checked state');
|
|
664
|
+
}
|
|
665
|
+
const nb = !!b;
|
|
666
|
+
el.indeterminate = false;
|
|
667
|
+
el.checked = nb;
|
|
668
|
+
state.indeterminate = false;
|
|
669
|
+
state.toggled = nb;
|
|
670
|
+
if (recipe.onToggle) recipe.onToggle(nb, state);
|
|
671
|
+
},
|
|
672
|
+
|
|
500
673
|
/** Destroy everything. Idempotent. */
|
|
501
674
|
destroy() {
|
|
502
675
|
if (destroyed) return;
|
|
@@ -505,7 +678,7 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
|
|
|
505
678
|
removeTick();
|
|
506
679
|
if (recipe.destroy) recipe.destroy();
|
|
507
680
|
releaseTicker();
|
|
508
|
-
if (type === UIType.SLIDER) releaseSliderStyle();
|
|
681
|
+
if (type === UIType.SLIDER || type === UIType.KNOB) releaseSliderStyle();
|
|
509
682
|
wrapper.remove();
|
|
510
683
|
},
|
|
511
684
|
};
|
|
@@ -528,4 +701,327 @@ export function mountUIFX(container, type, recipeFactory, options = {}) {
|
|
|
528
701
|
}
|
|
529
702
|
}
|
|
530
703
|
|
|
704
|
+
// =========================================================
|
|
705
|
+
// decorateUIFX -- The second mount mode (canvas AROUND a live element)
|
|
706
|
+
// =========================================================
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Decorate an EXISTING visible element with a canvas recipe, WITHOUT hijacking
|
|
710
|
+
* it. Unlike mountUIFX this creates no native element, never sets opacity:0, and
|
|
711
|
+
* never reparents `el`: it adds ONE absolutely-positioned overlay canvas as a
|
|
712
|
+
* sibling in el.parentNode (placed from el's offset box) plus the listeners it
|
|
713
|
+
* owns, and on destroy removes exactly those -- the host is byte-identical to
|
|
714
|
+
* before. Recipe state is wired from el's own events; for a form-control host,
|
|
715
|
+
* state.text and state.valid mirror el.value / el.validity (read at event time,
|
|
716
|
+
* never per frame). This is the honest home for a decoration over a real input
|
|
717
|
+
* (PasswordStrength, TypewriterField) and for generic form feedback (FocusHalo,
|
|
718
|
+
* ErrorShake, SuccessBloom). See decisions/0004.
|
|
719
|
+
*
|
|
720
|
+
* @param {HTMLElement} el The live element to decorate (stays visible).
|
|
721
|
+
* @param {Function} recipeFactory (options) => Recipe object
|
|
722
|
+
* @param {Object} [options] padding, seed, colors, theme, text, font
|
|
723
|
+
* @returns {{ el, canvas, state, setValue, setChecked, destroy }}
|
|
724
|
+
*/
|
|
725
|
+
export function decorateUIFX(el, recipeFactory, options = {}) {
|
|
726
|
+
// =====================================================================
|
|
727
|
+
// PHASE 1 -- VALIDATION ONLY. No side effect until every check passes
|
|
728
|
+
// (fail closed, mirrors mountUIFX): no createElement, no insertBefore,
|
|
729
|
+
// no ticker acquire, no recipe.init.
|
|
730
|
+
// =====================================================================
|
|
731
|
+
|
|
732
|
+
// 1. el must be a live, attached DOM element -- we read its offset box and
|
|
733
|
+
// hang the overlay off its parent. A detached el has no parentNode to host
|
|
734
|
+
// the canvas: an Error, never a silent no-op.
|
|
735
|
+
if (!el || typeof el.addEventListener !== 'function' ||
|
|
736
|
+
typeof el.getBoundingClientRect !== 'function') {
|
|
737
|
+
throw new Error('decorateUIFX: el must be a DOM element');
|
|
738
|
+
}
|
|
739
|
+
if (!el.parentNode || typeof el.parentNode.insertBefore !== 'function') {
|
|
740
|
+
throw new Error('decorateUIFX: el must be attached to the DOM (no parentNode to host the overlay)');
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// 2. options: decorate accepts a subset. A hijack-only key is a mistake, not a
|
|
744
|
+
// silent ignore; a truly unknown key gets a did-you-mean over the decorate
|
|
745
|
+
// set. Both fail closed, before any element exists.
|
|
746
|
+
for (const k in options) {
|
|
747
|
+
if (!Object.prototype.hasOwnProperty.call(options, k)) continue;
|
|
748
|
+
if (DECORATE_OPTIONS.indexOf(k) === -1) {
|
|
749
|
+
if (KNOWN_OPTIONS.indexOf(k) !== -1) {
|
|
750
|
+
throw new Error('decorateUIFX: option "' + k + '" is not valid in decorate mode (hijack-only)');
|
|
751
|
+
}
|
|
752
|
+
throw new Error(_didYouMean('decorateUIFX: unknown option', k, DECORATE_OPTIONS));
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
const padding = options.padding === undefined ? 40 : options.padding;
|
|
756
|
+
|
|
757
|
+
// Theming options (decisions/0002): validated fail closed here, forwarded to
|
|
758
|
+
// the recipe factory which resolves them in init. Cold mount code.
|
|
759
|
+
const _theme = options.theme;
|
|
760
|
+
if (_theme !== undefined) {
|
|
761
|
+
if (_theme === null || typeof _theme !== 'object' ||
|
|
762
|
+
typeof _theme.light !== 'string' || typeof _theme.mid !== 'string' ||
|
|
763
|
+
typeof _theme.dark !== 'string' || Object.keys(_theme).length !== 3) {
|
|
764
|
+
throw new Error('decorateUIFX: option "theme" must be { light, mid, dark } of color strings');
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
const _colors = options.colors;
|
|
768
|
+
if (_colors !== undefined &&
|
|
769
|
+
(!Array.isArray(_colors) || _colors.some((c) => typeof c !== 'string'))) {
|
|
770
|
+
throw new Error('decorateUIFX: option "colors" must be an array of color strings');
|
|
771
|
+
}
|
|
772
|
+
if (options.text !== undefined && typeof options.text !== 'string') {
|
|
773
|
+
throw new Error('decorateUIFX: option "text" must be a string');
|
|
774
|
+
}
|
|
775
|
+
if (options.font !== undefined && typeof options.font !== 'string') {
|
|
776
|
+
throw new Error('decorateUIFX: option "font" must be a string');
|
|
777
|
+
}
|
|
778
|
+
if (options.seed !== undefined &&
|
|
779
|
+
(typeof options.seed !== 'number' || !Number.isFinite(options.seed))) {
|
|
780
|
+
throw new Error('decorateUIFX: option "seed" must be a finite number');
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// 3. recipeFactory + recipe object + hooks (same contract as mountUIFX).
|
|
784
|
+
if (typeof recipeFactory !== 'function') {
|
|
785
|
+
throw new Error('decorateUIFX: recipeFactory must be a function');
|
|
786
|
+
}
|
|
787
|
+
const recipe = recipeFactory(options);
|
|
788
|
+
if (!recipe || typeof recipe !== 'object') {
|
|
789
|
+
throw new Error('decorateUIFX: recipe must be an object');
|
|
790
|
+
}
|
|
791
|
+
if (typeof recipe.tick !== 'function') {
|
|
792
|
+
throw new Error('decorateUIFX: recipe.tick must be a function');
|
|
793
|
+
}
|
|
794
|
+
for (const k in recipe) {
|
|
795
|
+
if (!Object.prototype.hasOwnProperty.call(recipe, k)) continue;
|
|
796
|
+
if (typeof recipe[k] === 'function' && KNOWN_HOOKS.indexOf(k) === -1) {
|
|
797
|
+
throw new Error(_didYouMean('decorateUIFX: unknown recipe hook', k, KNOWN_HOOKS));
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// =====================================================================
|
|
802
|
+
// PHASE 2 -- SIDE EFFECTS (fail-closed unwind, mirrors mountUIFX). The
|
|
803
|
+
// only acquisitions are the overlay canvas, the AbortController, and the
|
|
804
|
+
// shared ticker -- unwound in reverse order on any throw.
|
|
805
|
+
// =====================================================================
|
|
806
|
+
let canvasAppended = false;
|
|
807
|
+
let acCreated = false;
|
|
808
|
+
let tickerAcquired = false;
|
|
809
|
+
let canvas = null;
|
|
810
|
+
let ac = null;
|
|
811
|
+
let removeTick = null;
|
|
812
|
+
|
|
813
|
+
try {
|
|
814
|
+
// -- Placement from el's OFFSET box. Because the overlay is a SIBLING of el,
|
|
815
|
+
// they share an offsetParent, so offset-box coords land the canvas over el
|
|
816
|
+
// WITHOUT writing any style onto the parent (decision 2). Read once here,
|
|
817
|
+
// refreshed on resize only. --
|
|
818
|
+
let ow = el.offsetWidth;
|
|
819
|
+
let oh = el.offsetHeight;
|
|
820
|
+
let dpr = window.devicePixelRatio || 1;
|
|
821
|
+
let cw = ow + padding * 2;
|
|
822
|
+
let ch = oh + padding * 2;
|
|
823
|
+
|
|
824
|
+
canvas = document.createElement('canvas');
|
|
825
|
+
canvas.width = cw * dpr;
|
|
826
|
+
canvas.height = ch * dpr;
|
|
827
|
+
Object.assign(canvas.style, {
|
|
828
|
+
position: 'absolute',
|
|
829
|
+
left: (el.offsetLeft - padding) + 'px',
|
|
830
|
+
top: (el.offsetTop - padding) + 'px',
|
|
831
|
+
width: cw + 'px', height: ch + 'px',
|
|
832
|
+
pointerEvents: 'none',
|
|
833
|
+
});
|
|
834
|
+
const ctx = canvas.getContext('2d');
|
|
835
|
+
ctx.scale(dpr, dpr);
|
|
836
|
+
|
|
837
|
+
// Insert the overlay right AFTER el: among auto-z siblings it paints on top,
|
|
838
|
+
// and pointerEvents:none keeps el receiving every event. el is NOT touched --
|
|
839
|
+
// no style write, no reparent (the whole point of decorate mode).
|
|
840
|
+
el.parentNode.insertBefore(canvas, el.nextSibling);
|
|
841
|
+
canvasAppended = true;
|
|
842
|
+
|
|
843
|
+
// -- State. Generic fields wire like hijack mode; text/valid mirror the host,
|
|
844
|
+
// read now at init (law: hook initial values from the element) and refreshed
|
|
845
|
+
// at event time only. --
|
|
846
|
+
const state = {
|
|
847
|
+
hover: false,
|
|
848
|
+
active: false,
|
|
849
|
+
focused: (typeof document !== 'undefined' && document.activeElement === el),
|
|
850
|
+
text: (typeof el.value === 'string' ? el.value : ''),
|
|
851
|
+
valid: (el.validity ? !!el.validity.valid : true),
|
|
852
|
+
w: ow, h: oh, padding, dpr,
|
|
853
|
+
};
|
|
854
|
+
const pointer = { x: -999, y: -999, vx: 0, vy: 0 };
|
|
855
|
+
// Cached rect for pointer math (U-11): filled lazily, refreshed on enter/
|
|
856
|
+
// scroll/resize; pointermove does ZERO layout reads at steady state.
|
|
857
|
+
let rect = null;
|
|
858
|
+
|
|
859
|
+
// -- Initialize recipe (validated in phase 1). ctx exists now. --
|
|
860
|
+
if (recipe.init) recipe.init(ctx, ow, oh, padding);
|
|
861
|
+
|
|
862
|
+
// -- Events (all via AbortController: destroy()'s abort removes exactly what
|
|
863
|
+
// decorate added and nothing the host owned). --
|
|
864
|
+
ac = new AbortController();
|
|
865
|
+
acCreated = true;
|
|
866
|
+
const signal = ac.signal;
|
|
867
|
+
|
|
868
|
+
function updatePointer(e) {
|
|
869
|
+
if (!rect) rect = el.getBoundingClientRect();
|
|
870
|
+
const nx = e.clientX - rect.left;
|
|
871
|
+
const ny = e.clientY - rect.top;
|
|
872
|
+
pointer.vx = nx - pointer.x;
|
|
873
|
+
pointer.vy = ny - pointer.y;
|
|
874
|
+
pointer.x = nx;
|
|
875
|
+
pointer.y = ny;
|
|
876
|
+
}
|
|
877
|
+
function refreshRect() { rect = el.getBoundingClientRect(); }
|
|
878
|
+
// Reposition the overlay from the offset box after a layout change (cold path).
|
|
879
|
+
// All layout READS are hoisted above the style WRITES: writing canvas.style
|
|
880
|
+
// dirties layout, so reading el.offset* afterwards would force a synchronous
|
|
881
|
+
// reflow. A decoration over live DOM is the one place this package can force
|
|
882
|
+
// layout (see the U4b brief HOT PATH note), so keep read-before-write even here.
|
|
883
|
+
function reposition() {
|
|
884
|
+
const nw = el.offsetWidth;
|
|
885
|
+
const nh = el.offsetHeight;
|
|
886
|
+
const ol = el.offsetLeft;
|
|
887
|
+
const ot = el.offsetTop;
|
|
888
|
+
canvas.style.left = (ol - padding) + 'px';
|
|
889
|
+
canvas.style.top = (ot - padding) + 'px';
|
|
890
|
+
if (nw !== ow || nh !== oh) {
|
|
891
|
+
ow = nw; oh = nh;
|
|
892
|
+
cw = ow + padding * 2;
|
|
893
|
+
ch = oh + padding * 2;
|
|
894
|
+
canvas.width = cw * dpr;
|
|
895
|
+
canvas.height = ch * dpr;
|
|
896
|
+
canvas.style.width = cw + 'px';
|
|
897
|
+
canvas.style.height = ch + 'px';
|
|
898
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
899
|
+
state.w = ow; state.h = oh;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
window.addEventListener('scroll', refreshRect, { passive: true, signal });
|
|
904
|
+
window.addEventListener('resize', () => { reposition(); refreshRect(); }, { passive: true, signal });
|
|
905
|
+
|
|
906
|
+
el.addEventListener('pointermove', updatePointer, { signal });
|
|
907
|
+
el.addEventListener('pointerenter', (e) => {
|
|
908
|
+
state.hover = true;
|
|
909
|
+
refreshRect();
|
|
910
|
+
updatePointer(e);
|
|
911
|
+
if (recipe.onHover) recipe.onHover(state, pointer);
|
|
912
|
+
}, { signal });
|
|
913
|
+
el.addEventListener('pointerleave', () => {
|
|
914
|
+
state.hover = false;
|
|
915
|
+
if (recipe.onLeave) recipe.onLeave(state, pointer);
|
|
916
|
+
}, { signal });
|
|
917
|
+
el.addEventListener('pointerdown', (e) => {
|
|
918
|
+
state.active = true;
|
|
919
|
+
updatePointer(e);
|
|
920
|
+
if (recipe.onClick) recipe.onClick(pointer.x, pointer.y, state);
|
|
921
|
+
}, { signal });
|
|
922
|
+
el.addEventListener('pointerup', () => { state.active = false; }, { signal });
|
|
923
|
+
|
|
924
|
+
el.addEventListener('focus', () => { state.focused = true; }, { signal });
|
|
925
|
+
el.addEventListener('blur', () => { state.focused = false; }, { signal });
|
|
926
|
+
|
|
927
|
+
// Host content -> state, at EVENT time only (el.value getter allocates a
|
|
928
|
+
// string; keep it off the frame path). A non-form host never fires these.
|
|
929
|
+
function syncHostValue() {
|
|
930
|
+
state.text = (typeof el.value === 'string' ? el.value : '');
|
|
931
|
+
state.valid = (el.validity ? !!el.validity.valid : true);
|
|
932
|
+
}
|
|
933
|
+
el.addEventListener('input', syncHostValue, { signal });
|
|
934
|
+
el.addEventListener('change', syncHostValue, { signal });
|
|
935
|
+
el.addEventListener('invalid', () => { state.valid = false; }, { signal });
|
|
936
|
+
|
|
937
|
+
// -- DPR re-read on display change (cold, feature-detected; absent matchMedia
|
|
938
|
+
// is a silent no-op -- fail closed, never throw). --
|
|
939
|
+
if (typeof window.matchMedia === 'function') {
|
|
940
|
+
const mq = window.matchMedia('(resolution: ' + dpr + 'dppx)');
|
|
941
|
+
mq.addEventListener('change', () => {
|
|
942
|
+
const nd = window.devicePixelRatio || 1;
|
|
943
|
+
dpr = nd;
|
|
944
|
+
canvas.width = cw * nd;
|
|
945
|
+
canvas.height = ch * nd;
|
|
946
|
+
ctx.setTransform(nd, 0, 0, nd, 0, 0);
|
|
947
|
+
state.dpr = nd;
|
|
948
|
+
}, { signal });
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// -- Render loop (shared ticker; same quarantine-on-throw as mountUIFX). --
|
|
952
|
+
const ticker = acquireTicker();
|
|
953
|
+
tickerAcquired = true;
|
|
954
|
+
let destroyed = false;
|
|
955
|
+
let quarantined = false;
|
|
956
|
+
|
|
957
|
+
removeTick = ticker.add((dtMs) => {
|
|
958
|
+
if (destroyed || quarantined) return;
|
|
959
|
+
const dt = dtMs / 1000;
|
|
960
|
+
const now = performance.now();
|
|
961
|
+
|
|
962
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
963
|
+
ctx.clearRect(0, 0, cw, ch);
|
|
964
|
+
ctx.save();
|
|
965
|
+
ctx.translate(padding, padding); // Origin = the host element's top-left
|
|
966
|
+
try {
|
|
967
|
+
recipe.tick(ctx, dt, now, state, pointer);
|
|
968
|
+
} catch (err) {
|
|
969
|
+
quarantined = true;
|
|
970
|
+
console.error('decorateUIFX: recipe.tick threw; decoration quarantined', err);
|
|
971
|
+
ctx.restore();
|
|
972
|
+
ctx.clearRect(0, 0, cw, ch);
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
ctx.restore();
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
// -- Public API --
|
|
979
|
+
return {
|
|
980
|
+
/** The decorated host element (unchanged; provided for external reads). */
|
|
981
|
+
el,
|
|
982
|
+
|
|
983
|
+
/** The overlay canvas (for external styling). */
|
|
984
|
+
canvas,
|
|
985
|
+
|
|
986
|
+
/** Current state (read-only reference). */
|
|
987
|
+
state,
|
|
988
|
+
|
|
989
|
+
/**
|
|
990
|
+
* Hijack-only. A decoration reflects the host; it does not own or push
|
|
991
|
+
* into the host's value, so setValue/setChecked fail closed here (use the
|
|
992
|
+
* host's own API to change it -- the decoration follows via its events).
|
|
993
|
+
*/
|
|
994
|
+
setValue() {
|
|
995
|
+
throw new Error('decorateUIFX: setValue is hijack-only; a decoration reflects the host, it does not drive it');
|
|
996
|
+
},
|
|
997
|
+
setChecked() {
|
|
998
|
+
throw new Error('decorateUIFX: setChecked is hijack-only; a decoration reflects the host, it does not drive it');
|
|
999
|
+
},
|
|
1000
|
+
|
|
1001
|
+
/** Destroy: remove the overlay + every listener decorate added. Idempotent.
|
|
1002
|
+
* The host element is byte-identical to before decorate (never touched). */
|
|
1003
|
+
destroy() {
|
|
1004
|
+
if (destroyed) return;
|
|
1005
|
+
destroyed = true;
|
|
1006
|
+
ac.abort();
|
|
1007
|
+
removeTick();
|
|
1008
|
+
if (recipe.destroy) recipe.destroy();
|
|
1009
|
+
releaseTicker();
|
|
1010
|
+
canvas.remove(); // the ONLY DOM node decorate added
|
|
1011
|
+
},
|
|
1012
|
+
};
|
|
1013
|
+
} catch (err) {
|
|
1014
|
+
// A phase-2 step threw (realistically recipe.init). Unwind ONLY what was
|
|
1015
|
+
// acquired, reverse order, each flag-guarded. recipe.destroy is NOT called
|
|
1016
|
+
// (init did not succeed). Re-throw the ORIGINAL error, unwrapped.
|
|
1017
|
+
if (tickerAcquired) {
|
|
1018
|
+
if (removeTick) removeTick();
|
|
1019
|
+
releaseTicker();
|
|
1020
|
+
}
|
|
1021
|
+
if (acCreated) ac.abort();
|
|
1022
|
+
if (canvasAppended) canvas.remove();
|
|
1023
|
+
throw err;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
531
1027
|
export default mountUIFX;
|