@zakkster/lite-ui-fx 1.8.0 → 1.9.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 +63 -0
- package/README.md +54 -19
- package/UIFXController.d.ts +127 -0
- package/UIFXController.js +525 -1
- package/UIFXRecipes.d.ts +33 -13
- package/UIFXRecipes.js +149 -98
- package/llms.txt +61 -5
- package/package.json +1 -1
package/UIFXRecipes.js
CHANGED
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
|
|
36
36
|
import { lerp, clamp, easeOut, easeIn, easeInOut } from '@zakkster/lite-lerp';
|
|
37
37
|
import { Random } from '@zakkster/lite-random';
|
|
38
|
-
import { mountUIFX, decorateUIFX, UIType } from './UIFXController.js';
|
|
38
|
+
import { mountUIFX, decorateUIFX, mountUIFXGroup, UIType, GroupType } from './UIFXController.js';
|
|
39
39
|
|
|
40
40
|
|
|
41
41
|
// ---------------------------------------------------------
|
|
@@ -1800,51 +1800,57 @@ export function GlitchCounter(o = {}) {
|
|
|
1800
1800
|
|
|
1801
1801
|
/** 20. Bubble Rating -- 5 bubbles inflate based on slider position. Click pops them. */
|
|
1802
1802
|
export function BubbleRating(o = {}) {
|
|
1803
|
+
// U7 GROUP (rating): N native radios (a rating is "pick 1 of N stars"); native
|
|
1804
|
+
// arrow keys move the selection (st.index), 0007. Bubbles fill to st.index+1;
|
|
1805
|
+
// onSelect pops the newly-chosen bubble. Fixed pool + preallocated sizes lane.
|
|
1803
1806
|
const { seed = 42 } = o;
|
|
1804
1807
|
const P = resolveTheme(o, { accent: '#38bdf8', dim: '#9999b8' });
|
|
1805
1808
|
const rng = new Random(seed);
|
|
1806
|
-
const R5 = ['0 / 5', '1 / 5', '2 / 5', '3 / 5', '4 / 5', '5 / 5']; // const labels
|
|
1807
1809
|
const POPS = 32;
|
|
1808
1810
|
const pop = [];
|
|
1809
1811
|
for (let i = 0; i < POPS; i++) pop[i] = { x: 0, y: 0, vx: 0, vy: 0, life: 0 };
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1812
|
+
// per-bubble size lane, sized from st.count on first tick (the mount owns count,
|
|
1813
|
+
// 0007) -- a one-time grow absorbed by warm-up, then zero-alloc forever.
|
|
1814
|
+
let sizes = new Float32Array((o.items && o.items.length) || 5);
|
|
1815
|
+
function spawnPops(cx, cy) {
|
|
1813
1816
|
for (let j = 0; j < 5; j++) {
|
|
1814
1817
|
for (let i = 0; i < POPS; i++) {
|
|
1815
1818
|
const p = pop[i];
|
|
1816
1819
|
if (p.life <= 0) {
|
|
1817
|
-
const a = rng.range(0,
|
|
1818
|
-
p.x = cx; p.y =
|
|
1820
|
+
const a = rng.range(0, PI2), v = rng.range(20, 50);
|
|
1821
|
+
p.x = cx; p.y = cy; p.vx = Math.cos(a) * v; p.vy = Math.sin(a) * v; p.life = 1;
|
|
1819
1822
|
break;
|
|
1820
1823
|
}
|
|
1821
1824
|
}
|
|
1822
1825
|
}
|
|
1823
1826
|
}
|
|
1824
1827
|
return {
|
|
1828
|
+
onSelect(i, st) {
|
|
1829
|
+
// Pop the newly selected bubble at its real centre (geometry lane).
|
|
1830
|
+
spawnPops(st.itemX[i] + st.itemW[i] / 2, st.h / 2);
|
|
1831
|
+
},
|
|
1825
1832
|
tick(ctx, dt, now, st) {
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
const gap = st.w / 5;
|
|
1833
|
+
const filled = st.index + 1; // index 0 -> 1 filled bubble
|
|
1834
|
+
if (sizes.length < st.count) sizes = new Float32Array(st.count); // one-time grow (cold)
|
|
1829
1835
|
|
|
1830
|
-
for (let i = 0; i <
|
|
1831
|
-
const active = i <
|
|
1836
|
+
for (let i = 0; i < st.count; i++) {
|
|
1837
|
+
const active = i < filled;
|
|
1832
1838
|
const targetSz = active ? 12 : 6;
|
|
1833
1839
|
sizes[i] = lerp(sizes[i] || 6, targetSz, dt * 8);
|
|
1834
|
-
const cx =
|
|
1840
|
+
const cx = st.itemX[i] + st.itemW[i] / 2, cy = st.h / 2;
|
|
1835
1841
|
|
|
1836
1842
|
// Bubble -- const color, active alpha via globalAlpha
|
|
1837
1843
|
if (active) { ctx.fillStyle = P.accent; ctx.globalAlpha = 0.3 + sizes[i] / 20; }
|
|
1838
1844
|
else { ctx.fillStyle = 'rgba(255,255,255,.05)'; ctx.globalAlpha = 1; }
|
|
1839
|
-
ctx.beginPath(); ctx.arc(cx, cy, sizes[i], 0,
|
|
1845
|
+
ctx.beginPath(); ctx.arc(cx, cy, sizes[i], 0, PI2); ctx.fill();
|
|
1840
1846
|
ctx.globalAlpha = 1;
|
|
1841
1847
|
ctx.strokeStyle = active ? P.accent : 'rgba(255,255,255,.08)';
|
|
1842
|
-
ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(cx, cy, sizes[i], 0,
|
|
1848
|
+
ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(cx, cy, sizes[i], 0, PI2); ctx.stroke();
|
|
1843
1849
|
|
|
1844
1850
|
// Highlight
|
|
1845
1851
|
if (active) {
|
|
1846
1852
|
ctx.fillStyle = 'rgba(255,255,255,.15)';
|
|
1847
|
-
ctx.beginPath(); ctx.arc(cx - 3, cy - 3, 3, 0,
|
|
1853
|
+
ctx.beginPath(); ctx.arc(cx - 3, cy - 3, 3, 0, PI2); ctx.fill();
|
|
1848
1854
|
}
|
|
1849
1855
|
}
|
|
1850
1856
|
|
|
@@ -1856,20 +1862,13 @@ export function BubbleRating(o = {}) {
|
|
|
1856
1862
|
p.x += p.vx * dt; p.y += p.vy * dt; p.life -= 3 * dt;
|
|
1857
1863
|
if (p.life <= 0) continue;
|
|
1858
1864
|
ctx.globalAlpha = p.life;
|
|
1859
|
-
ctx.beginPath(); ctx.arc(p.x, p.y, 1.5, 0,
|
|
1865
|
+
ctx.beginPath(); ctx.arc(p.x, p.y, 1.5, 0, PI2); ctx.fill();
|
|
1860
1866
|
}
|
|
1861
1867
|
ctx.globalAlpha = 1;
|
|
1862
1868
|
|
|
1863
|
-
label(ctx,
|
|
1869
|
+
label(ctx, st.labels[st.index], st.w / 2, st.h + 12, filled >= st.count ? P.accent : P.dim);
|
|
1864
1870
|
if (st.focused) focusRing(ctx, st.w, st.h, 4);
|
|
1865
1871
|
},
|
|
1866
|
-
onDrag(val) {
|
|
1867
|
-
const rating = Math.round(val * 5);
|
|
1868
|
-
// Pop the newly activated bubble (position derived from st.w via lastW)
|
|
1869
|
-
const gap = lastW / 5;
|
|
1870
|
-
const cx = gap * (rating - 1) + gap / 2;
|
|
1871
|
-
spawnPops(cx);
|
|
1872
|
-
},
|
|
1873
1872
|
};
|
|
1874
1873
|
}
|
|
1875
1874
|
|
|
@@ -2180,122 +2179,154 @@ export function LiquidFill(o = {}) {
|
|
|
2180
2179
|
|
|
2181
2180
|
/** 6. Pill Tabs -- 3 segmented tabs with sliding indicator. */
|
|
2182
2181
|
export function PillTabs(o = {}) {
|
|
2182
|
+
// U7 GROUP (tabs): N native role=tab buttons, one canvas. The recipe reads the
|
|
2183
|
+
// live selection (st.index) and the per-item geometry lanes (st.itemX/itemW)
|
|
2184
|
+
// -- selection + arrow-key roving are owned by the native tablist (0007), not
|
|
2185
|
+
// faked here. The indicator springs toward the selected tab's real box.
|
|
2183
2186
|
const P = resolveTheme(o, { accent: '#c4b5fd', accent2: '#a78bfa', dim: '#9999b8' });
|
|
2184
2187
|
const themed = !!(o.theme || o.colors);
|
|
2185
2188
|
const indFill = themed ? rgbaOf(P.accent2, .12) : 'rgba(167,139,250,.12)';
|
|
2186
2189
|
const indStroke = themed ? rgbaOf(P.accent2, .25) : 'rgba(167,139,250,.25)';
|
|
2187
2190
|
const FONT = pickFont(o, "600 11px 'Space Grotesk',sans-serif");
|
|
2188
|
-
let
|
|
2189
|
-
const labels=['Alpha','Beta','Gamma'];
|
|
2191
|
+
let indX = -1, indW = 0; // indX < 0 snaps to the selected tab on frame 1
|
|
2190
2192
|
return {
|
|
2191
|
-
onClick(x,y,st) {
|
|
2192
|
-
const idx=Math.floor(x/(st.w/3));
|
|
2193
|
-
selected=clamp(idx,0,2);
|
|
2194
|
-
},
|
|
2195
2193
|
tick(c,dt,now,st) {
|
|
2196
|
-
const
|
|
2197
|
-
const tx=
|
|
2198
|
-
|
|
2199
|
-
|
|
2194
|
+
const sel = st.index;
|
|
2195
|
+
const tx = st.itemX[sel], tw = st.itemW[sel];
|
|
2196
|
+
if (indX < 0) { indX = tx; indW = tw; }
|
|
2197
|
+
else { indX = lerp(indX, tx, dt * 12); indW = lerp(indW, tw, dt * 12); }
|
|
2200
2198
|
|
|
2201
2199
|
// Background
|
|
2202
2200
|
c.fillStyle='rgba(255,255,255,.03)';rr(c,0,0,st.w,st.h,st.h/2);c.fill();
|
|
2203
2201
|
|
|
2204
|
-
// Indicator
|
|
2202
|
+
// Indicator (springs to the real selected-tab box)
|
|
2205
2203
|
c.fillStyle=indFill;c.strokeStyle=indStroke;c.lineWidth=1;
|
|
2206
|
-
rr(c,
|
|
2204
|
+
rr(c,indX+2,2,indW-4,st.h-4,st.h/2-2);c.fill();c.stroke();
|
|
2207
2205
|
|
|
2208
|
-
// Labels
|
|
2206
|
+
// Labels (from the native tabs' own text, via st.labels)
|
|
2209
2207
|
c.font=FONT;c.textAlign='center';c.textBaseline='middle';
|
|
2210
|
-
for(let i=0;i<
|
|
2211
|
-
c.fillStyle=i===
|
|
2212
|
-
c.fillText(labels[i],
|
|
2208
|
+
for(let i=0;i<st.count;i++){
|
|
2209
|
+
c.fillStyle=i===sel?P.accent:(i===st.hoverIndex?'#c4c4dc':P.dim);
|
|
2210
|
+
c.fillText(st.labels[i],st.itemX[i]+st.itemW[i]/2,st.h/2);
|
|
2213
2211
|
}
|
|
2214
2212
|
if(st.focused)fr(c,st.w,st.h,st.h/2);
|
|
2215
2213
|
},
|
|
2216
2214
|
};
|
|
2217
2215
|
}
|
|
2218
2216
|
|
|
2219
|
-
/**
|
|
2220
|
-
|
|
2221
|
-
|
|
2217
|
+
/** 6b. Segmented Slide (U7 GROUP tabs) -- a solid pill slides under the selected
|
|
2218
|
+
* segment. New in U7; reads st.index + the geometry lanes, zero-alloc tick. */
|
|
2219
|
+
export function SegmentedSlide(o = {}) {
|
|
2220
|
+
const P = resolveTheme(o, { accent: '#38bdf8', accent2: '#0ea5e9', dim: '#9999b8' });
|
|
2222
2221
|
const themed = !!(o.theme || o.colors);
|
|
2223
|
-
const
|
|
2224
|
-
const
|
|
2225
|
-
|
|
2226
|
-
let count=0,displayCount=0,flashDir=0,flashTimer=0;
|
|
2222
|
+
const pill = themed ? rgbaOf(P.accent, .92) : 'rgba(56,189,248,.92)';
|
|
2223
|
+
const FONT = pickFont(o, "600 11px 'Space Grotesk',sans-serif");
|
|
2224
|
+
let sx = -1, sw = 0;
|
|
2227
2225
|
return {
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2226
|
+
tick(c,dt,now,st) {
|
|
2227
|
+
const sel = st.index;
|
|
2228
|
+
const tx = st.itemX[sel], tw = st.itemW[sel];
|
|
2229
|
+
if (sx < 0) { sx = tx; sw = tw; }
|
|
2230
|
+
else { sx = lerp(sx, tx, dt * 14); sw = lerp(sw, tw, dt * 14); }
|
|
2231
|
+
|
|
2232
|
+
c.fillStyle='rgba(255,255,255,.05)';rr(c,0,0,st.w,st.h,st.h/2);c.fill();
|
|
2233
|
+
c.fillStyle=pill;rr(c,sx+3,3,sw-6,st.h-6,(st.h-6)/2);c.fill();
|
|
2234
|
+
|
|
2235
|
+
c.font=FONT;c.textAlign='center';c.textBaseline='middle';
|
|
2236
|
+
for(let i=0;i<st.count;i++){
|
|
2237
|
+
c.fillStyle=i===sel?'#0b0b12':(i===st.hoverIndex?'#e2e2f0':P.dim);
|
|
2238
|
+
c.fillText(st.labels[i],st.itemX[i]+st.itemW[i]/2,st.h/2);
|
|
2239
|
+
}
|
|
2240
|
+
if(st.focused)fr(c,st.w,st.h,st.h/2);
|
|
2232
2241
|
},
|
|
2242
|
+
};
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
/** 7. Stepper (U7 GROUP: stepper) -- one native <input type=number> spinbutton;
|
|
2246
|
+
* ArrowUp/Down + typing drive st.index natively (0007). The recipe draws the
|
|
2247
|
+
* current step label + a pip row (one per step, filled to the selection) and
|
|
2248
|
+
* flashes on change via onSelect. Zero-alloc tick. */
|
|
2249
|
+
export function Stepper(o = {}) {
|
|
2250
|
+
const P = resolveTheme(o, { accent: '#6ee7b6', accent2: '#ff6b6b', dim: '#e2e2f0' });
|
|
2251
|
+
const FONT = pickFont(o, "700 20px 'JetBrains Mono',monospace");
|
|
2252
|
+
let flash = 0; // brief highlight after a step change (onSelect)
|
|
2253
|
+
return {
|
|
2254
|
+
onSelect() { flash = 0.35; },
|
|
2233
2255
|
tick(c,dt,now,st) {
|
|
2234
|
-
|
|
2235
|
-
flashTimer=Math.max(0,flashTimer-dt);
|
|
2256
|
+
flash = flash > 0 ? flash - dt : 0;
|
|
2236
2257
|
|
|
2237
2258
|
// Background
|
|
2238
2259
|
c.fillStyle='rgba(255,255,255,.04)';rr(c,0,0,st.w,st.h,10);c.fill();
|
|
2239
2260
|
|
|
2240
|
-
//
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
c.
|
|
2245
|
-
c.
|
|
2261
|
+
// Up/Down chevron affordances (native spinbutton owns the behaviour)
|
|
2262
|
+
c.strokeStyle=P.accent;c.lineWidth=2;c.globalAlpha=0.7;
|
|
2263
|
+
const rx=st.w-14;
|
|
2264
|
+
c.beginPath();c.moveTo(rx-4,12);c.lineTo(rx,8);c.lineTo(rx+4,12);c.stroke();
|
|
2265
|
+
c.strokeStyle=P.accent2;
|
|
2266
|
+
c.beginPath();c.moveTo(rx-4,st.h-12);c.lineTo(rx,st.h-8);c.lineTo(rx+4,st.h-12);c.stroke();
|
|
2267
|
+
c.globalAlpha=1;
|
|
2246
2268
|
|
|
2247
|
-
//
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2269
|
+
// Step pips (one per step, filled up to the current index)
|
|
2270
|
+
for(let i=0;i<st.count;i++){
|
|
2271
|
+
const cx=st.itemX[i]+st.itemW[i]/2;
|
|
2272
|
+
c.globalAlpha=i===st.index?1:0.3;
|
|
2273
|
+
c.fillStyle=i<=st.index?P.accent:'rgba(255,255,255,.25)';
|
|
2274
|
+
c.beginPath();c.arc(cx,st.h-7,2.5,0,PI2);c.fill();
|
|
2275
|
+
}
|
|
2276
|
+
c.globalAlpha=1;
|
|
2251
2277
|
|
|
2252
|
-
//
|
|
2253
|
-
c.fillStyle=P.dim;c.font=
|
|
2254
|
-
c.
|
|
2278
|
+
// Current step label, brightening on change
|
|
2279
|
+
c.fillStyle=flash>0?P.accent:P.dim;c.font=FONT;
|
|
2280
|
+
c.textAlign='center';c.textBaseline='middle';
|
|
2281
|
+
c.fillText(st.labels[st.index],st.w/2,st.h/2-3);
|
|
2255
2282
|
|
|
2256
2283
|
if(st.focused)fr(c,st.w,st.h,10);
|
|
2257
2284
|
},
|
|
2258
2285
|
};
|
|
2259
2286
|
}
|
|
2260
2287
|
|
|
2261
|
-
/** 8. Radio Orbit --
|
|
2288
|
+
/** 8. Radio Orbit (U7 GROUP: radio) -- N options in a circle over N native
|
|
2289
|
+
* radios; native arrow keys move the selection (st.index), 0007. The recipe
|
|
2290
|
+
* reads st.count/st.index/st.labels; per-node glow is a preallocated lane. */
|
|
2262
2291
|
export function RadioOrbit(o = {}) {
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
//
|
|
2292
|
+
// per-node glow lane, sized from st.count on first tick (the mount owns count,
|
|
2293
|
+
// 0007) -- a one-time grow absorbed by warm-up, then zero-alloc forever.
|
|
2294
|
+
let glow = new Float32Array((o.items && o.items.length) || 4);
|
|
2295
|
+
// palette: `colors` is it; `theme` seeds it; else the default. Indexed base[i%bn]
|
|
2296
|
+
// so a node colour stays valid for any node count / override (no fixed array).
|
|
2267
2297
|
const base = o.colors ? o.colors
|
|
2268
2298
|
: (o.theme ? [o.theme.light, o.theme.mid, o.theme.dark, o.theme.light] : ['#ff6b6b','#fbbf24','#6ee7b6','#38bdf8']);
|
|
2269
|
-
const
|
|
2270
|
-
const OPTS=['OPTION A','OPTION B','OPTION C','OPTION D']; // const (was 'OPTION '+name concat)
|
|
2299
|
+
const bn = base.length;
|
|
2271
2300
|
return {
|
|
2272
2301
|
tick(c,dt,now,st) {
|
|
2273
|
-
const sel=
|
|
2302
|
+
const sel=st.index, n=st.count;
|
|
2303
|
+
if (glow.length < n) glow = new Float32Array(n); // one-time grow (cold)
|
|
2274
2304
|
const cx=st.w/2,cy=st.h/2,R=Math.min(cx,cy)-12;
|
|
2275
2305
|
|
|
2276
2306
|
// Center
|
|
2277
2307
|
c.fillStyle='rgba(255,255,255,.03)';c.beginPath();c.arc(cx,cy,8,0,PI2);c.fill();
|
|
2278
2308
|
|
|
2279
|
-
for(let i=0;i<
|
|
2280
|
-
const a=-Math.PI/2+i*
|
|
2309
|
+
for(let i=0;i<n;i++){
|
|
2310
|
+
const a=-Math.PI/2+i*(PI2/n);
|
|
2281
2311
|
const ox=cx+Math.cos(a)*R, oy=cy+Math.sin(a)*R;
|
|
2282
2312
|
const active=i===sel;
|
|
2283
|
-
|
|
2313
|
+
const col=base[i%bn];
|
|
2314
|
+
glow[i]=lerp(glow[i],active?1:(i===st.hoverIndex?0.4:0),dt*10);
|
|
2284
2315
|
|
|
2285
2316
|
// Orbit line -- const white, alpha via globalAlpha
|
|
2286
|
-
c.strokeStyle='#ffffff';c.globalAlpha=.03+
|
|
2317
|
+
c.strokeStyle='#ffffff';c.globalAlpha=.03+glow[i]*.05;c.lineWidth=1;
|
|
2287
2318
|
c.beginPath();c.moveTo(cx,cy);c.lineTo(ox,oy);c.stroke();c.globalAlpha=1;
|
|
2288
2319
|
|
|
2289
2320
|
// Node
|
|
2290
|
-
const sz=6+
|
|
2291
|
-
c.fillStyle=active?
|
|
2321
|
+
const sz=6+glow[i]*4;
|
|
2322
|
+
c.fillStyle=active?col:'rgba(255,255,255,.06)';
|
|
2292
2323
|
c.beginPath();c.arc(ox,oy,sz,0,PI2);c.fill();
|
|
2293
|
-
if(active){c.strokeStyle=
|
|
2324
|
+
if(active){c.strokeStyle=col;c.lineWidth=1;c.beginPath();c.arc(ox,oy,sz+3,0,PI2);c.stroke()}
|
|
2294
2325
|
|
|
2295
2326
|
c.fillStyle=active?'#fff':'#9999b8';c.font="600 9px 'JetBrains Mono',monospace";c.textAlign='center';c.textBaseline='middle';
|
|
2296
|
-
c.fillText(
|
|
2327
|
+
c.fillText(st.labels[i],ox,oy);
|
|
2297
2328
|
}
|
|
2298
|
-
lbl(c,
|
|
2329
|
+
lbl(c,st.labels[sel],cx,st.h+10,base[sel%bn]);
|
|
2299
2330
|
if(st.focused)fr(c,st.w,st.h,4);
|
|
2300
2331
|
},
|
|
2301
2332
|
};
|
|
@@ -3021,6 +3052,14 @@ export const UIFXRecipes5 = {
|
|
|
3021
3052
|
FocusHalo, ErrorShake, SuccessBloom,
|
|
3022
3053
|
};
|
|
3023
3054
|
|
|
3055
|
+
// U7 additions -- grouped controls (N native elements + one canvas, mounted via
|
|
3056
|
+
// mountUIFXGroup). PillTabs/Stepper/RadioOrbit/BubbleRating re-homed from their
|
|
3057
|
+
// vol.3 single-element fakes to real group types (see RECIPE_META); SegmentedSlide
|
|
3058
|
+
// is new. Reachable via RECIPES / RECIPE_META and their named exports regardless.
|
|
3059
|
+
export const UIFXRecipes6 = {
|
|
3060
|
+
SegmentedSlide,
|
|
3061
|
+
};
|
|
3062
|
+
|
|
3024
3063
|
|
|
3025
3064
|
// ===========================================================
|
|
3026
3065
|
// DEFAULT EXPORT -- combined all-53 namespace
|
|
@@ -3066,6 +3105,7 @@ export default {
|
|
|
3066
3105
|
SignalMeter,
|
|
3067
3106
|
LiquidFill,
|
|
3068
3107
|
PillTabs,
|
|
3108
|
+
SegmentedSlide,
|
|
3069
3109
|
Stepper,
|
|
3070
3110
|
RadioOrbit,
|
|
3071
3111
|
PasswordStrength,
|
|
@@ -3135,6 +3175,7 @@ export const RECIPES = Object.assign(Object.create(null), {
|
|
|
3135
3175
|
signalMeter: SignalMeter,
|
|
3136
3176
|
liquidFill: LiquidFill,
|
|
3137
3177
|
pillTabs: PillTabs,
|
|
3178
|
+
segmentedSlide: SegmentedSlide,
|
|
3138
3179
|
stepper: Stepper,
|
|
3139
3180
|
radioOrbit: RadioOrbit,
|
|
3140
3181
|
passwordStrength: PasswordStrength,
|
|
@@ -3198,16 +3239,17 @@ export const RECIPE_META = [
|
|
|
3198
3239
|
{ id: 'indeterminateScan', name: 'Indeterminate Scan', type: 'checkbox', family: 'Checkboxes', themeable: true, motionSafe: false },
|
|
3199
3240
|
{ id: 'flameCounter', name: 'Flame Counter', type: 'slider', family: 'Counters', themeable: true, motionSafe: false },
|
|
3200
3241
|
{ id: 'glitchCounter', name: 'Glitch Counter', type: 'slider', family: 'Counters', themeable: true, motionSafe: false },
|
|
3201
|
-
{ id: 'bubbleRating', name: 'Bubble Rating', type: '
|
|
3242
|
+
{ id: 'bubbleRating', name: 'Bubble Rating', type: 'rating', family: 'Rating', themeable: true, motionSafe: false },
|
|
3202
3243
|
{ id: 'volumeKnob', name: 'Volume Knob', type: 'knob', family: 'Knobs', themeable: true, motionSafe: false },
|
|
3203
3244
|
{ id: 'compassKnob', name: 'Compass Knob', type: 'knob', family: 'Knobs', themeable: true, motionSafe: false },
|
|
3204
3245
|
{ id: 'ringProgress', name: 'Ring Progress', type: 'progress', family: 'Progress', themeable: true, motionSafe: false },
|
|
3205
3246
|
{ id: 'batteryGauge', name: 'Battery Gauge', type: 'progress', family: 'Progress', themeable: true, motionSafe: false },
|
|
3206
3247
|
{ id: 'signalMeter', name: 'Signal Meter', type: 'progress', family: 'Progress', themeable: true, motionSafe: false },
|
|
3207
3248
|
{ id: 'liquidFill', name: 'Liquid Fill', type: 'progress', family: 'Progress', themeable: true, motionSafe: false },
|
|
3208
|
-
{ id: 'pillTabs', name: 'Pill Tabs', type: '
|
|
3209
|
-
{ id: '
|
|
3210
|
-
{ id: '
|
|
3249
|
+
{ id: 'pillTabs', name: 'Pill Tabs', type: 'tabs', family: 'Controls', themeable: true, motionSafe: false },
|
|
3250
|
+
{ id: 'segmentedSlide', name: 'Segmented Slide', type: 'tabs', family: 'Controls', themeable: true, motionSafe: false },
|
|
3251
|
+
{ id: 'stepper', name: 'Stepper', type: 'stepper', family: 'Controls', themeable: true, motionSafe: false },
|
|
3252
|
+
{ id: 'radioOrbit', name: 'Radio Orbit', type: 'radio', family: 'Controls', themeable: true, motionSafe: false },
|
|
3211
3253
|
{ id: 'passwordStrength', name: 'Password Strength', type: 'decorate', family: 'Indicators', themeable: true, motionSafe: true },
|
|
3212
3254
|
{ id: 'waterLevel', name: 'Water Level', type: 'slider', family: 'Indicators', themeable: true, motionSafe: false },
|
|
3213
3255
|
{ id: 'heatMap', name: 'Heat Map', type: 'slider', family: 'Indicators', themeable: true, motionSafe: false },
|
|
@@ -3228,14 +3270,15 @@ export const RECIPE_META = [
|
|
|
3228
3270
|
/** Names of every built-in recipe (the keys of RECIPES at load time). */
|
|
3229
3271
|
export const RECIPE_NAMES = Object.freeze(Object.keys(RECIPES));
|
|
3230
3272
|
|
|
3231
|
-
// The valid recipe/mount types, taken from the controller's UIType so
|
|
3232
|
-
// registry's fail-closed check and the controller's mount
|
|
3233
|
-
// of truth (they cannot drift as U4
|
|
3234
|
-
//
|
|
3235
|
-
//
|
|
3236
|
-
//
|
|
3237
|
-
// mountRecipe routes
|
|
3238
|
-
const
|
|
3273
|
+
// The valid recipe/mount types, taken from the controller's UIType + GroupType so
|
|
3274
|
+
// the registry's fail-closed check and the controller's mount guards are one
|
|
3275
|
+
// source of truth (they cannot drift as U4/U7 add types). Built once at load
|
|
3276
|
+
// (cold). Beyond the UITypes there are two non-UIType routing tags: 'decorate'
|
|
3277
|
+
// (U4b, mounted AROUND a live element by decorateUIFX) and the four GroupTypes
|
|
3278
|
+
// (U7: radio/tabs/stepper/rating, mounted as N native elements + one canvas by
|
|
3279
|
+
// mountUIFXGroup). mountRecipe routes each family to the right mount (see below).
|
|
3280
|
+
const _GROUP_TYPES = new Set(Object.values(GroupType));
|
|
3281
|
+
const VALID_META_TYPES = new Set([...Object.values(UIType), 'decorate', ..._GROUP_TYPES]);
|
|
3239
3282
|
|
|
3240
3283
|
/**
|
|
3241
3284
|
* Register a custom recipe, or override a built-in. Instantly usable via
|
|
@@ -3265,7 +3308,7 @@ export function registerRecipe(id, factory, meta) {
|
|
|
3265
3308
|
// valid set is UIType (VALID_META_TYPES), so registry + controller never
|
|
3266
3309
|
// disagree about what a type is.
|
|
3267
3310
|
if (!VALID_META_TYPES.has(type)) {
|
|
3268
|
-
throw new TypeError('registerRecipe: type must be
|
|
3311
|
+
throw new TypeError('registerRecipe: type must be a UIType ("button", "toggle", "slider", "checkbox", "progress", "knob"), "decorate", or a GroupType ("radio", "tabs", "stepper", "rating")');
|
|
3269
3312
|
}
|
|
3270
3313
|
|
|
3271
3314
|
RECIPES[id] = factory;
|
|
@@ -3367,10 +3410,18 @@ export function mountRecipe(container, id, options) {
|
|
|
3367
3410
|
mountOptions = {};
|
|
3368
3411
|
for (const k in options) if (k !== 'type') mountOptions[k] = options[k];
|
|
3369
3412
|
}
|
|
3413
|
+
// A grouped control (U7) is N native elements + one canvas, so it routes to
|
|
3414
|
+
// mountUIFXGroup with `container` as the parent and the group type selecting
|
|
3415
|
+
// the native structure (radios / tabs / spinbutton). It needs `items` in
|
|
3416
|
+
// options (fail closed inside mountUIFXGroup). mountUIFX/decorateUIFX reject a
|
|
3417
|
+
// group type via their own guards, so the three mount paths cannot cross.
|
|
3418
|
+
if (_GROUP_TYPES.has(type)) {
|
|
3419
|
+
return mountUIFXGroup(container, type, factory, mountOptions);
|
|
3420
|
+
}
|
|
3370
3421
|
// A decoration is mounted AROUND a live element (no native element created),
|
|
3371
3422
|
// so it routes to decorateUIFX with `container` as the host element. Every
|
|
3372
3423
|
// other type is a hijack mount. mountUIFX keeps rejecting 'decorate' via its
|
|
3373
|
-
// own _KNOWN_TYPES guard, so the
|
|
3424
|
+
// own _KNOWN_TYPES guard, so the paths cannot cross.
|
|
3374
3425
|
if (type === 'decorate') {
|
|
3375
3426
|
return decorateUIFX(container, factory, mountOptions);
|
|
3376
3427
|
}
|
package/llms.txt
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @zakkster/lite-ui-fx
|
|
2
|
-
> Canvas-hijacked UI components with pluggable recipe system.
|
|
2
|
+
> Canvas-hijacked UI components with pluggable recipe system. 57 built-in recipes.
|
|
3
3
|
|
|
4
|
-
VERSION 1.
|
|
4
|
+
VERSION 1.9.0
|
|
5
5
|
|
|
6
6
|
## Install
|
|
7
7
|
npm i @zakkster/lite-ui-fx
|
|
@@ -12,17 +12,19 @@ Canvas overlay (z-index:1) renders visuals via a recipe factory function.
|
|
|
12
12
|
Recipe = { tick(), init?(), onHover?(), onClick?(), onToggle?(), onDrag?(), destroy?() }
|
|
13
13
|
|
|
14
14
|
## Import -- Controller
|
|
15
|
-
import { mountUIFX, decorateUIFX, UIType } from '@zakkster/lite-ui-fx';
|
|
15
|
+
import { mountUIFX, decorateUIFX, mountUIFXGroup, UIType, GroupType } from '@zakkster/lite-ui-fx';
|
|
16
16
|
|
|
17
|
-
## Import -- Recipes (one ./recipes subpath,
|
|
17
|
+
## Import -- Recipes (one ./recipes subpath, 57 total, tree-shakeable)
|
|
18
18
|
import { SwarmToggle, MagneticButton, SparkSlider } from '@zakkster/lite-ui-fx/recipes';
|
|
19
19
|
import { PendulumToggle, HeartbeatButton, AuroraSlider } from '@zakkster/lite-ui-fx/recipes';
|
|
20
20
|
import { VolumeKnob, WaterLevel, TimerCountdown } from '@zakkster/lite-ui-fx/recipes';
|
|
21
|
+
import { PillTabs, SegmentedSlide, RadioOrbit, BubbleRating } from '@zakkster/lite-ui-fx/recipes'; // U7 group recipes
|
|
21
22
|
|
|
22
23
|
## Import -- Recipe registry (data-driven pickers)
|
|
23
24
|
import { RECIPES, RECIPE_META, RECIPE_NAMES, registerRecipe, mountRecipe } from '@zakkster/lite-ui-fx/recipes';
|
|
24
25
|
// RECIPES id->factory (null-proto); RECIPE_META { id, name, type, family, themeable, motionSafe }; RECIPE_NAMES frozen.
|
|
25
26
|
// mountRecipe(container, id, options?): resolves id fail-closed (did-you-mean), asserts META.type, mounts.
|
|
27
|
+
// Routes by type: a UIType -> mountUIFX; 'decorate' -> decorateUIFX; a group type -> mountUIFXGroup (needs items).
|
|
26
28
|
|
|
27
29
|
## Mount -- two modes
|
|
28
30
|
// HIJACK (mountUIFX): creates a native element (opacity:0) inside `container` and
|
|
@@ -42,6 +44,26 @@ deco.destroy(); // removes ONLY the overlay + its listeners; host untouc
|
|
|
42
44
|
// setValue/setChecked are HIJACK-ONLY: they throw in decorate mode (a decoration
|
|
43
45
|
// reflects the host; it does not drive it).
|
|
44
46
|
|
|
47
|
+
## Mount -- GROUP mode (U7): N native elements, one canvas, one recipe
|
|
48
|
+
// mountUIFXGroup: a grouped control -- radios in a fieldset, tabs in a tablist, a
|
|
49
|
+
// spinbutton, or a rating radiogroup -- sharing ONE canvas and one recipe. The
|
|
50
|
+
// native elements own selection + keyboard + a11y; the recipe reads state.index/
|
|
51
|
+
// state.count + the per-item geometry lanes. Additive: mountUIFX/decorateUIFX
|
|
52
|
+
// are untouched, and onSelect is a NINTH, group-only recipe hook.
|
|
53
|
+
const group = mountUIFXGroup(container, GroupType.RADIO, RadioOrbit, { items: ['A','B','C','D'], index: 0 });
|
|
54
|
+
group.els // the native elements (radios/tabs, or the single spinbutton)
|
|
55
|
+
group.state // read-only; state.index is the live selection
|
|
56
|
+
group.index // convenience getter over state.index
|
|
57
|
+
group.setIndex(i)// select item i in [0,count-1]: updates native + state, fires onSelect ONCE (no focus steal)
|
|
58
|
+
group.tick(dtMs) // driven mode only
|
|
59
|
+
group.destroy() // remove wrapper + listeners; idempotent
|
|
60
|
+
// GroupType.RADIO -> <fieldset role=radiogroup> + N <input type=radio>; native roving arrow keys
|
|
61
|
+
// GroupType.TABS -> <div role=tablist> + N <button role=tab>; APG roving tabindex + Left/Right/Home/End
|
|
62
|
+
// GroupType.STEPPER -> one <input type=number> spinbutton (count = steps); native Up/Down + typing
|
|
63
|
+
// GroupType.RATING -> radiogroup of N radios (rating semantics); native roving
|
|
64
|
+
// onSelect(index, state): recipe hook fired EXACTLY once per selection change.
|
|
65
|
+
// setValue/setChecked do not exist on a group -- selection is setIndex.
|
|
66
|
+
|
|
45
67
|
## Options (4th arg; unknown option or recipe-hook keys throw a did-you-mean -- fail closed)
|
|
46
68
|
width, height, padding=40, label // geometry + accessible label
|
|
47
69
|
value // slider start, number 0..1 (default 0.5); out-of-range or non-number throws
|
|
@@ -60,6 +82,10 @@ driven // U5 host clock: boolean; true = no ticker/RAF, the host calls instan
|
|
|
60
82
|
// the host-clock keys ticker/driven (a decoration wants clock control too). The
|
|
61
83
|
// hijack-only keys (width/height/value/checked/disabled/knobMode/announce/label)
|
|
62
84
|
// throw in decorate mode -- geometry comes from the host, value is read from it.
|
|
85
|
+
// mountUIFXGroup (U7) options: items(string[] >=2, REQUIRED -- length is the item/
|
|
86
|
+
// step count), index(integer initial selection, default 0), label, width, height,
|
|
87
|
+
// padding, disabled, seed, colors, theme, text, font, ticker, driven. The hijack-
|
|
88
|
+
// only keys (value/checked/knobMode/announce) throw -- a group uses index, not value.
|
|
63
89
|
|
|
64
90
|
## Host clock (U5) -- three mutually-exclusive modes, both mount modes
|
|
65
91
|
// default (neither option): the shared ref-counted ticker -- one RAF for all
|
|
@@ -82,6 +108,14 @@ UIType.KNOB -> <input type="range"> -> state.val, arrows native + knobMode p
|
|
|
82
108
|
(decorate) -> NO native element created; a canvas AROUND a live host (decorateUIFX). Not a UIType --
|
|
83
109
|
RECIPE_META.type 'decorate' routes mountRecipe to decorateUIFX. State: focused + text + valid.
|
|
84
110
|
|
|
111
|
+
## Group Types (U7) -- N native elements + one canvas (mountUIFXGroup)
|
|
112
|
+
GroupType.RADIO -> <fieldset role=radiogroup> + N <input type=radio>; native roving; state.index/count
|
|
113
|
+
GroupType.TABS -> <div role=tablist> + N <button role=tab>; APG roving tabindex + arrows/Home/End
|
|
114
|
+
GroupType.STEPPER -> one <input type=number> spinbutton (count = steps); native Up/Down + typing
|
|
115
|
+
GroupType.RATING -> radiogroup of N radios (rating semantics); native roving
|
|
116
|
+
// Not UITypes: RECIPE_META.type 'radio'|'tabs'|'stepper'|'rating' routes mountRecipe to mountUIFXGroup.
|
|
117
|
+
// onSelect(index, state) is the ninth, group-only recipe hook (mountUIFX/decorateUIFX reject it).
|
|
118
|
+
|
|
85
119
|
## State Object (provided to tick every frame)
|
|
86
120
|
{ hover, active, focused, toggled, indeterminate, disabled, val, w, h, padding, dpr,
|
|
87
121
|
reducedMotion, budget }
|
|
@@ -90,8 +124,12 @@ UIType.KNOB -> <input type="range"> -> state.val, arrows native + knobMode p
|
|
|
90
124
|
// budget (U5): 0..1 frame budget, 1 at ~60fps, lower as frames lengthen; budget-aware
|
|
91
125
|
// recipes shed work (particles/glow) when it drops.
|
|
92
126
|
// decorate mode also adds: text (host value string), valid (host validity boolean).
|
|
127
|
+
// GROUP mode (U7) is a SUPERSET of this state PLUS: index (selected 0..count-1),
|
|
128
|
+
// count, hoverIndex (item under pointer, -1 none), labels (string[] items),
|
|
129
|
+
// itemX/itemY/itemW/itemH (Float32Array per-item geometry lanes, read by index).
|
|
130
|
+
// The scalar value fields (val/toggled/indeterminate) are neutral for a group.
|
|
93
131
|
|
|
94
|
-
##
|
|
132
|
+
## 57 Built-in Recipes
|
|
95
133
|
|
|
96
134
|
### Vol. 1 -- 10 recipes
|
|
97
135
|
Toggles: SwarmToggle, LiquidToggle, NeonPulseToggle
|
|
@@ -125,6 +163,15 @@ Form feedback (decorateUIFX): FocusHalo, ErrorShake, SuccessBloom
|
|
|
125
163
|
// Re-homed to decorate mode (type 'decorate'): PasswordStrength (reads the live
|
|
126
164
|
// input's text), TypewriterField (an underline that grows with the typed text).
|
|
127
165
|
|
|
166
|
+
### U7 -- grouped controls (mountUIFXGroup: N native elements + one canvas)
|
|
167
|
+
Tabs (GroupType.TABS): PillTabs, SegmentedSlide (new in U7)
|
|
168
|
+
Radio (GroupType.RADIO): RadioOrbit
|
|
169
|
+
Stepper (GroupType.STEPPER): Stepper
|
|
170
|
+
Rating (GroupType.RATING): BubbleRating
|
|
171
|
+
// PillTabs/Stepper/RadioOrbit/BubbleRating RE-HOME from their vol.3 single-element
|
|
172
|
+
// fakes to real group types, so their native arrow-key selection is now correct;
|
|
173
|
+
// SegmentedSlide is new. Selection is state.index; onSelect(index, state) fires once.
|
|
174
|
+
|
|
128
175
|
## Writing Custom Recipes
|
|
129
176
|
See UIFX-RECIPE-GUIDE.md (included in package).
|
|
130
177
|
|
|
@@ -162,3 +209,12 @@ See UIFX-RECIPE-GUIDE.md (included in package).
|
|
|
162
209
|
offset box and removed on destroy (host byte-identical, additive-only). State is
|
|
163
210
|
wired from the host's own events (state.text/state.valid at event time). It is the
|
|
164
211
|
second mount mode + the surface the enrichment decorations build on.
|
|
212
|
+
- U7 grouped controls (mountUIFXGroup): the THIRD mount mode -- N native elements +
|
|
213
|
+
one canvas + one recipe. RADIO/RATING = a fieldset radiogroup of N radios (native
|
|
214
|
+
roving); TABS = a role=tablist of N role=tab buttons (hand-written APG roving
|
|
215
|
+
tabindex + arrows/Home/End); STEPPER = one <input type=number> spinbutton. The
|
|
216
|
+
recipe reads state.index/count + itemX/itemY/itemW/itemH geometry lanes; onSelect
|
|
217
|
+
(index, state) is the ninth, group-only hook, fired once per change; setIndex(i)
|
|
218
|
+
selects programmatically (fires onSelect once, no focus steal). Additive: the
|
|
219
|
+
single-element API is byte-identical; the four vol.3 fakes (PillTabs/Stepper/
|
|
220
|
+
RadioOrbit/BubbleRating) re-home to real group types, SegmentedSlide is new.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zakkster/lite-ui-fx",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.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",
|