@zakkster/lite-ui-fx 1.8.0 → 1.9.1

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/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
- const sizes = new Float32Array(5);
1811
- let lastW = 200;
1812
- function spawnPops(cx) {
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, Math.PI * 2), v = rng.range(20, 50);
1818
- p.x = cx; p.y = 14; p.vx = Math.cos(a) * v; p.vy = Math.sin(a) * v; p.life = 1;
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
- lastW = st.w;
1827
- const rating = Math.round(st.val * 5);
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 < 5; i++) {
1831
- const active = i < rating;
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 = gap * i + gap / 2, cy = st.h / 2;
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, Math.PI * 2); ctx.fill();
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, Math.PI * 2); ctx.stroke();
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, Math.PI * 2); ctx.fill();
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, Math.PI * 2); ctx.fill();
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, R5[rating], st.w / 2, st.h + 12, rating >= 4 ? P.accent : P.dim);
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 indicatorX=0, indicatorW=0, selected=0;
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 tw=st.w/3;
2197
- const tx=selected*tw, targetW=tw;
2198
- indicatorX=lerp(indicatorX,tx,dt*12);
2199
- indicatorW=lerp(indicatorW,targetW,dt*12);
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,indicatorX+2,2,indicatorW-4,st.h-4,st.h/2-2);c.fill();c.stroke();
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<3;i++){
2211
- c.fillStyle=i===selected?P.accent:P.dim;
2212
- c.fillText(labels[i],tw*i+tw/2,st.h/2);
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
- /** 7. Stepper -- +/- buttons with spring counter. */
2220
- export function Stepper(o = {}) {
2221
- const P = resolveTheme(o, { accent: '#6ee7b6', accent2: '#ff6b6b', dim: '#e2e2f0' });
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 minusFlash = themed ? rgbaOf(P.accent2, .15) : 'rgba(255,100,100,.15)';
2224
- const plusFlash = themed ? rgbaOf(P.accent, .15) : 'rgba(110,231,182,.15)';
2225
- const FONT = pickFont(o, "700 18px 'JetBrains Mono',monospace");
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
- onClick(x,y,st) {
2229
- if(x<st.w*0.33){count=Math.max(0,count-1);flashDir=-1;}
2230
- else if(x>st.w*0.66){count++;flashDir=1;}
2231
- flashTimer=0.3;
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
- displayCount=lerp(displayCount,count,dt*10);
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
- // Minus zone
2241
- const third=st.w/3;
2242
- c.fillStyle=flashDir===-1&&flashTimer>0?minusFlash:'rgba(255,255,255,.03)';
2243
- rr(c,2,2,third-4,st.h-4,8);c.fill();
2244
- c.fillStyle=P.accent2;c.font=FONT;c.textAlign='center';c.textBaseline='middle';
2245
- c.fillText('\u2212',third/2,st.h/2);
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
- // Plus zone
2248
- c.fillStyle=flashDir===1&&flashTimer>0?plusFlash:'rgba(255,255,255,.03)';
2249
- rr(c,third*2+2,2,third-4,st.h-4,8);c.fill();
2250
- c.fillStyle=P.accent;c.fillText('+',third*2+third/2,st.h/2);
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
- // Counter
2253
- c.fillStyle=P.dim;c.font="700 20px 'JetBrains Mono',monospace";
2254
- c.fillText(Math.round(displayCount),st.w/2,st.h/2);
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 -- 4 options arranged in a circle. Slider picks one. */
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
- let selectedGlow=new Float32Array(4);
2264
- const names=['A','B','C','D'];
2265
- // `colors` is the 4-node palette; `theme` seeds it. Normalised to length 4 so
2266
- // the body's colors[i] stays valid for any override.
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 colors = base.length >= 4 ? base : [base[0], base[1 % base.length], base[2 % base.length], base[3 % base.length]];
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=Math.round(st.val*3);
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<4;i++){
2280
- const a=-Math.PI/2+i*Math.PI/2;
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
- selectedGlow[i]=lerp(selectedGlow[i],active?1:0,dt*10);
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+selectedGlow[i]*.05;c.lineWidth=1;
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+selectedGlow[i]*4;
2291
- c.fillStyle=active?colors[i]:'rgba(255,255,255,.06)';
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=colors[i];c.lineWidth=1;c.beginPath();c.arc(ox,oy,sz+3,0,PI2);c.stroke()}
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(names[i],ox,oy);
2327
+ c.fillText(st.labels[i],ox,oy);
2297
2328
  }
2298
- lbl(c,OPTS[sel],cx,st.h+10,colors[sel]);
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
  };
@@ -2540,8 +2571,14 @@ export function ReactionPicker(o = {}) {
2540
2571
  c.fillStyle=active?colors30[i]:'rgba(255,255,255,.04)';
2541
2572
  c.beginPath();c.arc(cx,cy-sizes[i]+10,sizes[i],0,PI2);c.fill();
2542
2573
 
2543
- // Emoji face (simplified) -- font from a const-string LUT
2574
+ // Emoji face -- font from a const-string LUT. Set an OPAQUE fill
2575
+ // first: the circle's fillStyle above is translucent (4% at rest),
2576
+ // and a colour glyph inherits that alpha, so without this the faces
2577
+ // are invisible until hover. colors[i] keeps a mono-emoji fallback
2578
+ // tinted per reaction; a colour-emoji font ignores the hue and only
2579
+ // takes the full alpha. Precomputed array read -- zero alloc.
2544
2580
  c.font=FONTS[Math.round(sizes[i]*1.2)]||FONTS[48];c.textAlign='center';c.textBaseline='middle';
2581
+ c.fillStyle=colors[i];
2545
2582
  c.fillText(emojis[i],cx,cy-sizes[i]+10);
2546
2583
  }
2547
2584
 
@@ -3021,6 +3058,14 @@ export const UIFXRecipes5 = {
3021
3058
  FocusHalo, ErrorShake, SuccessBloom,
3022
3059
  };
3023
3060
 
3061
+ // U7 additions -- grouped controls (N native elements + one canvas, mounted via
3062
+ // mountUIFXGroup). PillTabs/Stepper/RadioOrbit/BubbleRating re-homed from their
3063
+ // vol.3 single-element fakes to real group types (see RECIPE_META); SegmentedSlide
3064
+ // is new. Reachable via RECIPES / RECIPE_META and their named exports regardless.
3065
+ export const UIFXRecipes6 = {
3066
+ SegmentedSlide,
3067
+ };
3068
+
3024
3069
 
3025
3070
  // ===========================================================
3026
3071
  // DEFAULT EXPORT -- combined all-53 namespace
@@ -3066,6 +3111,7 @@ export default {
3066
3111
  SignalMeter,
3067
3112
  LiquidFill,
3068
3113
  PillTabs,
3114
+ SegmentedSlide,
3069
3115
  Stepper,
3070
3116
  RadioOrbit,
3071
3117
  PasswordStrength,
@@ -3135,6 +3181,7 @@ export const RECIPES = Object.assign(Object.create(null), {
3135
3181
  signalMeter: SignalMeter,
3136
3182
  liquidFill: LiquidFill,
3137
3183
  pillTabs: PillTabs,
3184
+ segmentedSlide: SegmentedSlide,
3138
3185
  stepper: Stepper,
3139
3186
  radioOrbit: RadioOrbit,
3140
3187
  passwordStrength: PasswordStrength,
@@ -3198,16 +3245,17 @@ export const RECIPE_META = [
3198
3245
  { id: 'indeterminateScan', name: 'Indeterminate Scan', type: 'checkbox', family: 'Checkboxes', themeable: true, motionSafe: false },
3199
3246
  { id: 'flameCounter', name: 'Flame Counter', type: 'slider', family: 'Counters', themeable: true, motionSafe: false },
3200
3247
  { id: 'glitchCounter', name: 'Glitch Counter', type: 'slider', family: 'Counters', themeable: true, motionSafe: false },
3201
- { id: 'bubbleRating', name: 'Bubble Rating', type: 'slider', family: 'Rating', themeable: true, motionSafe: false },
3248
+ { id: 'bubbleRating', name: 'Bubble Rating', type: 'rating', family: 'Rating', themeable: true, motionSafe: false },
3202
3249
  { id: 'volumeKnob', name: 'Volume Knob', type: 'knob', family: 'Knobs', themeable: true, motionSafe: false },
3203
3250
  { id: 'compassKnob', name: 'Compass Knob', type: 'knob', family: 'Knobs', themeable: true, motionSafe: false },
3204
3251
  { id: 'ringProgress', name: 'Ring Progress', type: 'progress', family: 'Progress', themeable: true, motionSafe: false },
3205
3252
  { id: 'batteryGauge', name: 'Battery Gauge', type: 'progress', family: 'Progress', themeable: true, motionSafe: false },
3206
3253
  { id: 'signalMeter', name: 'Signal Meter', type: 'progress', family: 'Progress', themeable: true, motionSafe: false },
3207
3254
  { id: 'liquidFill', name: 'Liquid Fill', type: 'progress', family: 'Progress', themeable: true, motionSafe: false },
3208
- { id: 'pillTabs', name: 'Pill Tabs', type: 'button', family: 'Controls', themeable: true, motionSafe: false },
3209
- { id: 'stepper', name: 'Stepper', type: 'button', family: 'Controls', themeable: true, motionSafe: false },
3210
- { id: 'radioOrbit', name: 'Radio Orbit', type: 'slider', family: 'Controls', themeable: true, motionSafe: false },
3255
+ { id: 'pillTabs', name: 'Pill Tabs', type: 'tabs', family: 'Controls', themeable: true, motionSafe: false },
3256
+ { id: 'segmentedSlide', name: 'Segmented Slide', type: 'tabs', family: 'Controls', themeable: true, motionSafe: false },
3257
+ { id: 'stepper', name: 'Stepper', type: 'stepper', family: 'Controls', themeable: true, motionSafe: false },
3258
+ { id: 'radioOrbit', name: 'Radio Orbit', type: 'radio', family: 'Controls', themeable: true, motionSafe: false },
3211
3259
  { id: 'passwordStrength', name: 'Password Strength', type: 'decorate', family: 'Indicators', themeable: true, motionSafe: true },
3212
3260
  { id: 'waterLevel', name: 'Water Level', type: 'slider', family: 'Indicators', themeable: true, motionSafe: false },
3213
3261
  { id: 'heatMap', name: 'Heat Map', type: 'slider', family: 'Indicators', themeable: true, motionSafe: false },
@@ -3228,14 +3276,15 @@ export const RECIPE_META = [
3228
3276
  /** Names of every built-in recipe (the keys of RECIPES at load time). */
3229
3277
  export const RECIPE_NAMES = Object.freeze(Object.keys(RECIPES));
3230
3278
 
3231
- // The valid recipe/mount types, taken from the controller's UIType so the
3232
- // registry's fail-closed check and the controller's mount guard are one source
3233
- // of truth (they cannot drift as U4 adds types). Built once at load (cold).
3234
- // Plus the ONE non-UIType routing tag: 'decorate' (U4b) creates no native
3235
- // element -- it is mounted AROUND a live element by decorateUIFX, not by
3236
- // mountUIFX -- so it is not a UIType, but it is a valid RECIPE_META.type that
3237
- // mountRecipe routes on (see below). It is the only member not from UIType.
3238
- const VALID_META_TYPES = new Set([...Object.values(UIType), 'decorate']);
3279
+ // The valid recipe/mount types, taken from the controller's UIType + GroupType so
3280
+ // the registry's fail-closed check and the controller's mount guards are one
3281
+ // source of truth (they cannot drift as U4/U7 add types). Built once at load
3282
+ // (cold). Beyond the UITypes there are two non-UIType routing tags: 'decorate'
3283
+ // (U4b, mounted AROUND a live element by decorateUIFX) and the four GroupTypes
3284
+ // (U7: radio/tabs/stepper/rating, mounted as N native elements + one canvas by
3285
+ // mountUIFXGroup). mountRecipe routes each family to the right mount (see below).
3286
+ const _GROUP_TYPES = new Set(Object.values(GroupType));
3287
+ const VALID_META_TYPES = new Set([...Object.values(UIType), 'decorate', ..._GROUP_TYPES]);
3239
3288
 
3240
3289
  /**
3241
3290
  * Register a custom recipe, or override a built-in. Instantly usable via
@@ -3265,7 +3314,7 @@ export function registerRecipe(id, factory, meta) {
3265
3314
  // valid set is UIType (VALID_META_TYPES), so registry + controller never
3266
3315
  // disagree about what a type is.
3267
3316
  if (!VALID_META_TYPES.has(type)) {
3268
- throw new TypeError('registerRecipe: type must be one of "button", "toggle", "slider", "checkbox", "progress", "knob"');
3317
+ throw new TypeError('registerRecipe: type must be a UIType ("button", "toggle", "slider", "checkbox", "progress", "knob"), "decorate", or a GroupType ("radio", "tabs", "stepper", "rating")');
3269
3318
  }
3270
3319
 
3271
3320
  RECIPES[id] = factory;
@@ -3367,10 +3416,18 @@ export function mountRecipe(container, id, options) {
3367
3416
  mountOptions = {};
3368
3417
  for (const k in options) if (k !== 'type') mountOptions[k] = options[k];
3369
3418
  }
3419
+ // A grouped control (U7) is N native elements + one canvas, so it routes to
3420
+ // mountUIFXGroup with `container` as the parent and the group type selecting
3421
+ // the native structure (radios / tabs / spinbutton). It needs `items` in
3422
+ // options (fail closed inside mountUIFXGroup). mountUIFX/decorateUIFX reject a
3423
+ // group type via their own guards, so the three mount paths cannot cross.
3424
+ if (_GROUP_TYPES.has(type)) {
3425
+ return mountUIFXGroup(container, type, factory, mountOptions);
3426
+ }
3370
3427
  // A decoration is mounted AROUND a live element (no native element created),
3371
3428
  // so it routes to decorateUIFX with `container` as the host element. Every
3372
3429
  // other type is a hijack mount. mountUIFX keeps rejecting 'decorate' via its
3373
- // own _KNOWN_TYPES guard, so the two paths cannot cross.
3430
+ // own _KNOWN_TYPES guard, so the paths cannot cross.
3374
3431
  if (type === 'decorate') {
3375
3432
  return decorateUIFX(container, factory, mountOptions);
3376
3433
  }
package/llms.txt CHANGED
@@ -1,7 +1,7 @@
1
1
  # @zakkster/lite-ui-fx
2
- > Canvas-hijacked UI components with pluggable recipe system. 56 built-in recipes.
2
+ > Canvas-hijacked UI components with pluggable recipe system. 57 built-in recipes.
3
3
 
4
- VERSION 1.8.0
4
+ VERSION 1.9.1
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, 56 total, tree-shakeable)
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
- ## 56 Built-in Recipes
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.8.0",
3
+ "version": "1.9.1",
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",