@zakkster/lite-ui-fx 1.0.5 → 1.1.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 +43 -1
- package/README.md +4 -0
- package/UIFXController.d.ts +7 -0
- package/UIFXController.js +253 -35
- package/llms.txt +8 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,7 +5,49 @@ All notable changes to `@zakkster/lite-ui-fx` are documented here.
|
|
|
5
5
|
The format follows Keep a Changelog; this project adheres to Semantic
|
|
6
6
|
Versioning.
|
|
7
7
|
|
|
8
|
-
## [1.0
|
|
8
|
+
## [1.1.0] -- unreleased
|
|
9
|
+
|
|
10
|
+
Controller correctness: the two S1 defects (U-01, U-02) and three
|
|
11
|
+
controller-level S3s (U-09, U-10, U-11). No visual change at default mounts.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- U-01 (keyboard): a toggle activates on one Space press with exactly one
|
|
16
|
+
`onToggle`, and the native checkbox is the sole source of truth. The manual
|
|
17
|
+
keydown checked-flip -- which fired a second `onToggle` -- is removed; Enter
|
|
18
|
+
is bridged to the same native activation via `el.click()`.
|
|
19
|
+
- U-02 (loop survival): one malformed recipe can no longer freeze the page.
|
|
20
|
+
Invalid recipes are rejected at mount (fail closed -- every side effect is
|
|
21
|
+
unwound, so no orphan DOM and no leaked refcount); a `tick()` that throws
|
|
22
|
+
quarantines only that component (one `console.error`, its canvas cleared)
|
|
23
|
+
while the shared ticker and every other component keep running.
|
|
24
|
+
- U-09 (style leak): the slider-thumb `<style>` is one shared, ref-counted
|
|
25
|
+
node -- injected on first slider mount, removed when the last slider
|
|
26
|
+
unmounts; `document.head` child count nets to zero.
|
|
27
|
+
- U-10 (fail-open options): unknown option keys and unknown recipe-hook keys
|
|
28
|
+
are now errors with a did-you-mean hint; `value` must be a number in [0,1].
|
|
29
|
+
- U-11 (forced reflow): the bounding rect is cached on pointerenter and
|
|
30
|
+
refreshed on scroll/resize (passive listeners); `pointermove` does zero
|
|
31
|
+
layout reads.
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- Mount options `value` (slider initial, 0..1), `checked` (toggle initial),
|
|
36
|
+
and `disabled` -- each lands in the native element and `state` before the
|
|
37
|
+
first frame. New `state.disabled` for recipes to render a disabled look.
|
|
38
|
+
- DPR re-read: the canvas re-scales on a display-density change
|
|
39
|
+
(`matchMedia`, feature-detected; a silent no-op where unavailable).
|
|
40
|
+
- Torture tiers t2 (the accessibility contract) and t5 (100-component scale
|
|
41
|
+
plus the U-02 quarantine regression); two t9 controls (double-toggle,
|
|
42
|
+
validation-bypass).
|
|
43
|
+
|
|
44
|
+
### Changed
|
|
45
|
+
|
|
46
|
+
- Mount validates every input before any side effect (fail closed):
|
|
47
|
+
container, options, factory, and recipe shape are checked before the DOM,
|
|
48
|
+
the shared style/ticker refcounts, or the render loop are touched.
|
|
49
|
+
|
|
50
|
+
## [1.0.5] -- 2026-09-06
|
|
9
51
|
|
|
10
52
|
Truth pass, law pass, and the torture skeleton. No runtime behaviour
|
|
11
53
|
changes: this release makes the package honest, lawful, and provable.
|
package/README.md
CHANGED
|
@@ -150,6 +150,9 @@ import { VolumeKnob, WaterLevel, TimerCountdown } from './recipes/UIFXRecipes3.j
|
|
|
150
150
|
| `options.height` | `number` | Element height |
|
|
151
151
|
| `options.padding` | `number` | Canvas overflow (default: 40px) |
|
|
152
152
|
| `options.label` | `string` | Accessible label (aria-label) |
|
|
153
|
+
| `options.value` | `number` | Slider initial value, 0..1 (default 0.5); out-of-range throws |
|
|
154
|
+
| `options.checked` | `boolean` | Toggle initial state (default false) |
|
|
155
|
+
| `options.disabled` | `boolean` | Disables the native element; sets `state.disabled` |
|
|
153
156
|
|
|
154
157
|
Returns `{ el, canvas, wrapper, state, destroy() }`.
|
|
155
158
|
|
|
@@ -169,6 +172,7 @@ Returns `{ el, canvas, wrapper, state, destroy() }`.
|
|
|
169
172
|
active: boolean; // Pointer pressed
|
|
170
173
|
focused: boolean; // Keyboard focus
|
|
171
174
|
toggled: boolean; // Checkbox state
|
|
175
|
+
disabled: boolean; // Disabled via options.disabled
|
|
172
176
|
val: number; // Slider value (0-1)
|
|
173
177
|
w: number; // Element width
|
|
174
178
|
h: number; // Element height
|
package/UIFXController.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ export interface UIFXState {
|
|
|
13
13
|
active: boolean;
|
|
14
14
|
focused: boolean;
|
|
15
15
|
toggled: boolean;
|
|
16
|
+
disabled: boolean;
|
|
16
17
|
val: number;
|
|
17
18
|
w: number;
|
|
18
19
|
h: number;
|
|
@@ -52,6 +53,12 @@ export interface MountOptions {
|
|
|
52
53
|
height?: number;
|
|
53
54
|
padding?: number;
|
|
54
55
|
label?: string;
|
|
56
|
+
/** Slider initial value, 0..1 (default 0.5). Out-of-range or non-number throws. */
|
|
57
|
+
value?: number;
|
|
58
|
+
/** Toggle initial checked state (default false). */
|
|
59
|
+
checked?: boolean;
|
|
60
|
+
/** Disables the native element and sets state.disabled for recipes. */
|
|
61
|
+
disabled?: boolean;
|
|
55
62
|
}
|
|
56
63
|
|
|
57
64
|
export interface UIFXInstance {
|
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.0
|
|
25
|
+
export const VERSION = '1.1.0';
|
|
26
26
|
|
|
27
27
|
// ---------------------------------------------------------
|
|
28
28
|
// SHARED TICKER (ref-counted, one RAF for all UI components)
|
|
@@ -50,6 +50,90 @@ function releaseTicker() {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
|
|
53
|
+
// ---------------------------------------------------------
|
|
54
|
+
// SHARED SLIDER STYLE (ref-counted, one <style> for all sliders)
|
|
55
|
+
// ---------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
let _sliderStyle = null;
|
|
58
|
+
let _sliderRefs = 0;
|
|
59
|
+
|
|
60
|
+
function acquireSliderStyle() {
|
|
61
|
+
if (!_sliderStyle) {
|
|
62
|
+
_sliderStyle = document.createElement('style');
|
|
63
|
+
_sliderStyle.textContent = `
|
|
64
|
+
.uifx-slider::-webkit-slider-thumb { -webkit-appearance:none; width:24px; height:24px; cursor:grab; }
|
|
65
|
+
.uifx-slider::-moz-range-thumb { width:24px; height:24px; cursor:grab; border:none; background:transparent; }
|
|
66
|
+
`;
|
|
67
|
+
document.head.appendChild(_sliderStyle);
|
|
68
|
+
}
|
|
69
|
+
_sliderRefs++;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function releaseSliderStyle() {
|
|
73
|
+
_sliderRefs--;
|
|
74
|
+
if (_sliderRefs <= 0 && _sliderStyle) {
|
|
75
|
+
_sliderStyle.remove();
|
|
76
|
+
_sliderStyle = null;
|
|
77
|
+
_sliderRefs = 0;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------
|
|
83
|
+
// MOUNT-TIME VALIDATION (cold path only -- never a hot body)
|
|
84
|
+
// ---------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
const KNOWN_HOOKS = ['init', 'tick', 'onHover', 'onLeave', 'onClick', 'onToggle', 'onDrag', 'destroy'];
|
|
87
|
+
const KNOWN_OPTIONS = ['width', 'height', 'padding', 'label', 'value', 'checked', 'disabled'];
|
|
88
|
+
|
|
89
|
+
// Levenshtein edit distance. Cold: only reached on the error path.
|
|
90
|
+
function _editDistance(a, b) {
|
|
91
|
+
const al = a.length;
|
|
92
|
+
const bl = b.length;
|
|
93
|
+
if (al === 0) return bl;
|
|
94
|
+
if (bl === 0) return al;
|
|
95
|
+
let prev = new Array(bl + 1);
|
|
96
|
+
for (let j = 0; j <= bl; j++) prev[j] = j;
|
|
97
|
+
for (let i = 1; i <= al; i++) {
|
|
98
|
+
const cur = new Array(bl + 1);
|
|
99
|
+
cur[0] = i;
|
|
100
|
+
for (let j = 1; j <= bl; j++) {
|
|
101
|
+
const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
|
|
102
|
+
let m = prev[j] + 1;
|
|
103
|
+
const del = cur[j - 1] + 1;
|
|
104
|
+
if (del < m) m = del;
|
|
105
|
+
const sub = prev[j - 1] + cost;
|
|
106
|
+
if (sub < m) m = sub;
|
|
107
|
+
cur[j] = m;
|
|
108
|
+
}
|
|
109
|
+
prev = cur;
|
|
110
|
+
}
|
|
111
|
+
return prev[bl];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Nearest known key within edit distance <=2, else one sharing a >=2-char
|
|
115
|
+
// prefix, else null. Cold path.
|
|
116
|
+
function _suggest(name, known) {
|
|
117
|
+
let best = null;
|
|
118
|
+
let bestD = Infinity;
|
|
119
|
+
for (let i = 0; i < known.length; i++) {
|
|
120
|
+
const d = _editDistance(name, known[i]);
|
|
121
|
+
if (d < bestD) { bestD = d; best = known[i]; }
|
|
122
|
+
}
|
|
123
|
+
if (bestD <= 2) return best;
|
|
124
|
+
const head = name.length >= 2 ? name.slice(0, 2) : name;
|
|
125
|
+
for (let i = 0; i < known.length; i++) {
|
|
126
|
+
if (known[i].indexOf(head) === 0) return known[i];
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function _didYouMean(prefix, name, known) {
|
|
132
|
+
const s = _suggest(name, known);
|
|
133
|
+
return prefix + ' "' + name + '"' + (s ? '. Did you mean "' + s + '"?' : '');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
53
137
|
// ---------------------------------------------------------
|
|
54
138
|
// ELEMENT TYPES
|
|
55
139
|
// ---------------------------------------------------------
|
|
@@ -79,16 +163,88 @@ export const UIType = Object.freeze({
|
|
|
79
163
|
* @param {string} [options.label] Accessible label for the element
|
|
80
164
|
* @returns {{ el: HTMLElement, destroy: Function }}
|
|
81
165
|
*/
|
|
82
|
-
export function mountUIFX(container, type, recipeFactory, {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
166
|
+
export function mountUIFX(container, type, recipeFactory, options = {}) {
|
|
167
|
+
// =====================================================================
|
|
168
|
+
// PHASE 1 -- VALIDATION ONLY. No side effect runs until every check
|
|
169
|
+
// below has passed: no createElement, no appendChild, no
|
|
170
|
+
// acquireSliderStyle, no ticker acquire, no recipe.init. A rejected
|
|
171
|
+
// mount must leave the DOM and every shared refcount exactly as it
|
|
172
|
+
// found them (fail closed -- BLOCKER 1).
|
|
173
|
+
// =====================================================================
|
|
174
|
+
|
|
175
|
+
// 1. container
|
|
176
|
+
if (!container || typeof container.appendChild !== 'function') {
|
|
177
|
+
throw new Error('mountUIFX: container must be a DOM element');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// 2. options: unknown keys -> did-you-mean; value/checked/disabled
|
|
181
|
+
// validated and coerced HERE, before any element exists.
|
|
182
|
+
for (const k in options) {
|
|
183
|
+
if (!Object.prototype.hasOwnProperty.call(options, k)) continue;
|
|
184
|
+
if (KNOWN_OPTIONS.indexOf(k) === -1) {
|
|
185
|
+
throw new Error(_didYouMean('mountUIFX: unknown option', k, KNOWN_OPTIONS));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const value = options.value;
|
|
189
|
+
if (value !== undefined &&
|
|
190
|
+
(typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 1)) {
|
|
191
|
+
// null is not zero: an out-of-range or non-numeric value is an error,
|
|
192
|
+
// never a silent coercion.
|
|
193
|
+
throw new Error('mountUIFX: option "value" must be a number in [0,1]');
|
|
194
|
+
}
|
|
195
|
+
const checked = options.checked === undefined ? false : !!options.checked;
|
|
196
|
+
const disabled = options.disabled === undefined ? false : !!options.disabled;
|
|
197
|
+
const width = options.width;
|
|
198
|
+
const height = options.height;
|
|
199
|
+
const padding = options.padding === undefined ? 40 : options.padding;
|
|
200
|
+
const label = options.label === undefined ? '' : options.label;
|
|
201
|
+
|
|
202
|
+
// 3. recipeFactory
|
|
203
|
+
if (typeof recipeFactory !== 'function') {
|
|
204
|
+
throw new Error('mountUIFX: recipeFactory must be a function');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// 4. recipe object + hooks. Created now so a bad recipe throws BEFORE any
|
|
208
|
+
// DOM/refcount side effect; .init is deferred to phase 2 (needs ctx).
|
|
209
|
+
const recipe = recipeFactory();
|
|
210
|
+
if (!recipe || typeof recipe !== 'object') {
|
|
211
|
+
throw new Error('mountUIFX: recipe must be an object');
|
|
212
|
+
}
|
|
213
|
+
if (typeof recipe.tick !== 'function') {
|
|
214
|
+
throw new Error('mountUIFX: recipe.tick must be a function');
|
|
215
|
+
}
|
|
216
|
+
for (const k in recipe) {
|
|
217
|
+
if (!Object.prototype.hasOwnProperty.call(recipe, k)) continue;
|
|
218
|
+
if (typeof recipe[k] === 'function' && KNOWN_HOOKS.indexOf(k) === -1) {
|
|
219
|
+
throw new Error(_didYouMean('mountUIFX: unknown recipe hook', k, KNOWN_HOOKS));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// =====================================================================
|
|
224
|
+
// PHASE 2 -- SIDE EFFECTS. Every check above has passed; only now do
|
|
225
|
+
// we allocate DOM, bump refcounts, and wire events.
|
|
226
|
+
//
|
|
227
|
+
// This region is ALSO fail-closed: if any step throws (realistically a
|
|
228
|
+
// user recipe.init, but anything here), we UNWIND every side effect that
|
|
229
|
+
// actually landed -- in reverse acquisition order, each guarded by its own
|
|
230
|
+
// flag so nothing underflows a refcount or double-frees -- then re-throw
|
|
231
|
+
// the ORIGINAL error. A try/catch is free on the success path; this is all
|
|
232
|
+
// cold mount code with zero hot-path impact.
|
|
233
|
+
// =====================================================================
|
|
234
|
+
|
|
235
|
+
let styleAcquired = false; // acquireSliderStyle() bumped _sliderRefs
|
|
236
|
+
let wrapperAppended = false; // wrapper is in container.children
|
|
237
|
+
let acCreated = false; // AbortController exists (listeners may be on it)
|
|
238
|
+
let tickerAcquired = false; // acquireTicker() bumped _sharedRefs
|
|
239
|
+
let wrapper = null;
|
|
240
|
+
let ac = null;
|
|
241
|
+
let removeTick = null;
|
|
242
|
+
|
|
243
|
+
try {
|
|
88
244
|
// -- Resolve dimensions --
|
|
89
245
|
const w = width || (type === UIType.BUTTON ? 160 : type === UIType.SLIDER ? 200 : 64);
|
|
90
246
|
const h = height || (type === UIType.BUTTON ? 48 : type === UIType.SLIDER ? 28 : 36);
|
|
91
|
-
|
|
247
|
+
let dpr = window.devicePixelRatio || 1;
|
|
92
248
|
|
|
93
249
|
// -- Create native element (invisible, accessible, receives events) --
|
|
94
250
|
let el;
|
|
@@ -96,17 +252,20 @@ export function mountUIFX(container, type, recipeFactory, {
|
|
|
96
252
|
el = document.createElement('input');
|
|
97
253
|
el.type = 'checkbox';
|
|
98
254
|
el.setAttribute('role', 'switch');
|
|
255
|
+
el.checked = checked; // coerced boolean; lands before frame 1
|
|
99
256
|
if (label) el.setAttribute('aria-label', label);
|
|
100
257
|
} else if (type === UIType.SLIDER) {
|
|
101
258
|
el = document.createElement('input');
|
|
102
259
|
el.type = 'range';
|
|
103
|
-
el.min = '0'; el.max = '100';
|
|
260
|
+
el.min = '0'; el.max = '100';
|
|
261
|
+
el.value = value !== undefined ? String(value * 100) : '50'; // 0..1 -> 0..100
|
|
104
262
|
if (label) el.setAttribute('aria-label', label);
|
|
105
263
|
} else {
|
|
106
264
|
el = document.createElement('button');
|
|
107
265
|
el.textContent = label || 'Action';
|
|
108
266
|
el.type = 'button';
|
|
109
267
|
}
|
|
268
|
+
if (disabled) el.disabled = true; // lands before frame 1
|
|
110
269
|
|
|
111
270
|
Object.assign(el.style, {
|
|
112
271
|
position: 'relative', zIndex: '2',
|
|
@@ -117,14 +276,12 @@ export function mountUIFX(container, type, recipeFactory, {
|
|
|
117
276
|
WebkitAppearance: 'none', appearance: 'none',
|
|
118
277
|
});
|
|
119
278
|
|
|
120
|
-
// Slider thumb needs explicit sizing for hit area
|
|
279
|
+
// Slider thumb needs explicit sizing for hit area. One shared, ref-counted
|
|
280
|
+
// <style> for all sliders (U-09): released in destroy() when the last slider
|
|
281
|
+
// goes -- head child count nets to zero across mount/destroy.
|
|
121
282
|
if (type === UIType.SLIDER) {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
.uifx-slider::-webkit-slider-thumb { -webkit-appearance:none; width:24px; height:24px; cursor:grab; }
|
|
125
|
-
.uifx-slider::-moz-range-thumb { width:24px; height:24px; cursor:grab; border:none; background:transparent; }
|
|
126
|
-
`;
|
|
127
|
-
document.head.appendChild(thumbCSS);
|
|
283
|
+
acquireSliderStyle();
|
|
284
|
+
styleAcquired = true;
|
|
128
285
|
el.classList.add('uifx-slider');
|
|
129
286
|
}
|
|
130
287
|
|
|
@@ -145,7 +302,7 @@ export function mountUIFX(container, type, recipeFactory, {
|
|
|
145
302
|
ctx.scale(dpr, dpr);
|
|
146
303
|
|
|
147
304
|
// -- Wrapper --
|
|
148
|
-
|
|
305
|
+
wrapper = document.createElement('div');
|
|
149
306
|
Object.assign(wrapper.style, {
|
|
150
307
|
position: 'relative', display: 'inline-block',
|
|
151
308
|
width: `${w}px`, height: `${h}px`,
|
|
@@ -153,40 +310,58 @@ export function mountUIFX(container, type, recipeFactory, {
|
|
|
153
310
|
wrapper.appendChild(el);
|
|
154
311
|
wrapper.appendChild(canvas);
|
|
155
312
|
container.appendChild(wrapper);
|
|
313
|
+
wrapperAppended = true;
|
|
156
314
|
|
|
157
|
-
// -- State --
|
|
315
|
+
// -- State (value/checked/disabled land here BEFORE frame 1) --
|
|
158
316
|
const state = {
|
|
159
317
|
hover: false,
|
|
160
318
|
active: false, // pointer is down
|
|
161
319
|
focused: false, // keyboard focus
|
|
162
|
-
toggled:
|
|
163
|
-
|
|
320
|
+
toggled: checked, // coerced boolean; element + state AGREE
|
|
321
|
+
disabled, // recipes can render a disabled look
|
|
322
|
+
val: value !== undefined ? value : (type === UIType.SLIDER ? 0.5 : 0), // 0-1
|
|
164
323
|
w, h, padding, dpr,
|
|
165
324
|
};
|
|
166
325
|
|
|
167
326
|
const pointer = { x: -999, y: -999, vx: 0, vy: 0 };
|
|
327
|
+
// Cached bounding rect. Refreshed on pointerenter + scroll/resize (cold);
|
|
328
|
+
// pointermove reads it with ZERO layout reads (U-11). null until the first
|
|
329
|
+
// pointer event, then lazily filled once (see updatePointer).
|
|
330
|
+
let rect = null;
|
|
168
331
|
|
|
169
|
-
// -- Initialize recipe
|
|
170
|
-
|
|
332
|
+
// -- Initialize recipe (already validated in phase 1: object, tick fn,
|
|
333
|
+
// only known hooks). ctx exists now, so init can run. --
|
|
171
334
|
if (recipe.init) recipe.init(ctx, w, h, padding);
|
|
172
335
|
|
|
173
336
|
// -- Events (all via AbortController) --
|
|
174
|
-
|
|
337
|
+
ac = new AbortController();
|
|
338
|
+
acCreated = true;
|
|
175
339
|
const signal = ac.signal;
|
|
176
340
|
|
|
177
341
|
function updatePointer(e) {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
342
|
+
// Lazily fill the rect on the first pointer event (e.g. a pointerdown
|
|
343
|
+
// with no prior pointerenter). Fires getBoundingClientRect at most once
|
|
344
|
+
// until the next scroll/resize/enter nulls or refreshes it -- steady-
|
|
345
|
+
// state pointermove does ZERO layout reads (U-11 / NIT 1).
|
|
346
|
+
if (!rect) rect = el.getBoundingClientRect();
|
|
347
|
+
const nx = e.clientX - rect.left;
|
|
348
|
+
const ny = e.clientY - rect.top;
|
|
181
349
|
pointer.vx = nx - pointer.x;
|
|
182
350
|
pointer.vy = ny - pointer.y;
|
|
183
351
|
pointer.x = nx;
|
|
184
352
|
pointer.y = ny;
|
|
185
353
|
}
|
|
354
|
+
function refreshRect() { rect = el.getBoundingClientRect(); }
|
|
355
|
+
|
|
356
|
+
// Rect invalidation on layout shift -- cold path, through ac.signal so
|
|
357
|
+
// destroy()'s abort removes them (no orphaned window listeners).
|
|
358
|
+
window.addEventListener('scroll', refreshRect, { passive: true, signal });
|
|
359
|
+
window.addEventListener('resize', refreshRect, { passive: true, signal });
|
|
186
360
|
|
|
187
361
|
el.addEventListener('pointermove', updatePointer, { signal });
|
|
188
362
|
el.addEventListener('pointerenter', (e) => {
|
|
189
363
|
state.hover = true;
|
|
364
|
+
refreshRect(); // one layout read per enter
|
|
190
365
|
updatePointer(e);
|
|
191
366
|
if (recipe.onHover) recipe.onHover(state, pointer);
|
|
192
367
|
}, { signal });
|
|
@@ -211,13 +386,11 @@ export function mountUIFX(container, type, recipeFactory, {
|
|
|
211
386
|
state.toggled = el.checked;
|
|
212
387
|
if (recipe.onToggle) recipe.onToggle(state.toggled, state);
|
|
213
388
|
}, { signal });
|
|
214
|
-
//
|
|
389
|
+
// Space activates the checkbox natively (browser fires click -> change ->
|
|
390
|
+
// the listener above). Enter is not native for a checkbox; route it
|
|
391
|
+
// through the SAME activation path so there is one onToggle per press.
|
|
215
392
|
el.addEventListener('keydown', (e) => {
|
|
216
|
-
if (e.code === '
|
|
217
|
-
el.checked = !el.checked;
|
|
218
|
-
state.toggled = el.checked;
|
|
219
|
-
if (recipe.onToggle) recipe.onToggle(state.toggled, state);
|
|
220
|
-
}
|
|
393
|
+
if (e.code === 'Enter') el.click();
|
|
221
394
|
}, { signal });
|
|
222
395
|
}
|
|
223
396
|
|
|
@@ -229,12 +402,29 @@ export function mountUIFX(container, type, recipeFactory, {
|
|
|
229
402
|
}, { signal });
|
|
230
403
|
}
|
|
231
404
|
|
|
405
|
+
// -- DPR re-read on display change (cold, feature-detected). Absent
|
|
406
|
+
// matchMedia is a silent no-op: the canvas stays at mount DPR (fail
|
|
407
|
+
// closed, never throw). Listener bound to signal for teardown. --
|
|
408
|
+
if (typeof window.matchMedia === 'function') {
|
|
409
|
+
const mq = window.matchMedia('(resolution: ' + dpr + 'dppx)');
|
|
410
|
+
mq.addEventListener('change', () => {
|
|
411
|
+
const nd = window.devicePixelRatio || 1;
|
|
412
|
+
dpr = nd;
|
|
413
|
+
canvas.width = cw * nd;
|
|
414
|
+
canvas.height = ch * nd;
|
|
415
|
+
ctx.setTransform(nd, 0, 0, nd, 0, 0);
|
|
416
|
+
state.dpr = nd;
|
|
417
|
+
}, { signal });
|
|
418
|
+
}
|
|
419
|
+
|
|
232
420
|
// -- Render loop (shared ticker) --
|
|
233
421
|
const ticker = acquireTicker();
|
|
422
|
+
tickerAcquired = true;
|
|
234
423
|
let destroyed = false;
|
|
424
|
+
let quarantined = false; // a recipe.tick throw quarantines only this one
|
|
235
425
|
|
|
236
|
-
|
|
237
|
-
if (destroyed) return;
|
|
426
|
+
removeTick = ticker.add((dtMs) => {
|
|
427
|
+
if (destroyed || quarantined) return;
|
|
238
428
|
const dt = dtMs / 1000;
|
|
239
429
|
const now = performance.now();
|
|
240
430
|
|
|
@@ -242,7 +432,17 @@ export function mountUIFX(container, type, recipeFactory, {
|
|
|
242
432
|
ctx.clearRect(0, 0, cw, ch);
|
|
243
433
|
ctx.save();
|
|
244
434
|
ctx.translate(padding, padding); // Origin = native element's top-left
|
|
245
|
-
|
|
435
|
+
try {
|
|
436
|
+
recipe.tick(ctx, dt, now, state, pointer);
|
|
437
|
+
} catch (err) {
|
|
438
|
+
// U-02B: contain the throw. The shared Ticker never sees it, so its
|
|
439
|
+
// RAF reschedules and every other component keeps running.
|
|
440
|
+
quarantined = true;
|
|
441
|
+
console.error('mountUIFX: recipe.tick threw for type "' + type + '"; component quarantined', err);
|
|
442
|
+
ctx.restore();
|
|
443
|
+
ctx.clearRect(0, 0, cw, ch);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
246
446
|
ctx.restore();
|
|
247
447
|
});
|
|
248
448
|
|
|
@@ -268,9 +468,27 @@ export function mountUIFX(container, type, recipeFactory, {
|
|
|
268
468
|
removeTick();
|
|
269
469
|
if (recipe.destroy) recipe.destroy();
|
|
270
470
|
releaseTicker();
|
|
471
|
+
if (type === UIType.SLIDER) releaseSliderStyle();
|
|
271
472
|
wrapper.remove();
|
|
272
473
|
},
|
|
273
474
|
};
|
|
475
|
+
} catch (err) {
|
|
476
|
+
// A step in phase 2 threw (realistically recipe.init -- user code).
|
|
477
|
+
// Unwind ONLY what was actually acquired, in reverse acquisition order,
|
|
478
|
+
// each guarded by its flag so an early throw (e.g. at init, before ac /
|
|
479
|
+
// ticker exist) never releases a ticker or aborts an ac that was never
|
|
480
|
+
// created. recipe.destroy() is deliberately NOT called: init did not
|
|
481
|
+
// succeed, so there is no initialised recipe to tear down.
|
|
482
|
+
if (tickerAcquired) {
|
|
483
|
+
if (removeTick) removeTick();
|
|
484
|
+
releaseTicker();
|
|
485
|
+
}
|
|
486
|
+
if (acCreated) ac.abort();
|
|
487
|
+
if (wrapperAppended) wrapper.remove();
|
|
488
|
+
if (styleAcquired) releaseSliderStyle();
|
|
489
|
+
// Re-throw the ORIGINAL error, preserved verbatim (never wrapped).
|
|
490
|
+
throw err;
|
|
491
|
+
}
|
|
274
492
|
}
|
|
275
493
|
|
|
276
494
|
export default mountUIFX;
|
package/llms.txt
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @zakkster/lite-ui-fx
|
|
2
2
|
> Canvas-hijacked UI components with pluggable recipe system. 50 built-in recipes.
|
|
3
3
|
|
|
4
|
-
VERSION 1.0
|
|
4
|
+
VERSION 1.1.0
|
|
5
5
|
|
|
6
6
|
## Install
|
|
7
7
|
npm i @zakkster/lite-ui-fx
|
|
@@ -23,13 +23,19 @@ import { VolumeKnob, WaterLevel, TimerCountdown } from './recipes/UIFXRecipes3.j
|
|
|
23
23
|
const instance = mountUIFX(container, UIType.TOGGLE, SwarmToggle, { label: 'Sound' });
|
|
24
24
|
instance.destroy(); // cleanup
|
|
25
25
|
|
|
26
|
+
## Options (4th arg; unknown option or recipe-hook keys throw a did-you-mean -- fail closed)
|
|
27
|
+
width, height, padding=40, label // geometry + accessible label
|
|
28
|
+
value // slider start, number 0..1 (default 0.5); out-of-range or non-number throws
|
|
29
|
+
checked // toggle start, boolean (default false)
|
|
30
|
+
disabled // native disabled attr + state.disabled for recipes to render
|
|
31
|
+
|
|
26
32
|
## Element Types
|
|
27
33
|
UIType.TOGGLE -> <input type="checkbox" role="switch"> -> state.toggled, onToggle(checked)
|
|
28
34
|
UIType.BUTTON -> <button> -> state.active, onClick(x, y, state)
|
|
29
35
|
UIType.SLIDER -> <input type="range"> -> state.val (0-1), onDrag(val, velocity)
|
|
30
36
|
|
|
31
37
|
## State Object (provided to tick every frame)
|
|
32
|
-
{ hover, active, focused, toggled, val, w, h, padding, dpr }
|
|
38
|
+
{ hover, active, focused, toggled, disabled, val, w, h, padding, dpr }
|
|
33
39
|
|
|
34
40
|
## 50 Built-in Recipes
|
|
35
41
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zakkster/lite-ui-fx",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Canvas-hijacked UI components with a pluggable recipe system. 50 built-in recipes across toggles, buttons, sliders, knobs, loaders, checkboxes, counters, and ratings.",
|
|
5
5
|
"author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
|
|
6
6
|
"license": "MIT",
|