@zakkster/lite-ui-fx 1.7.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 +99 -0
- package/README.md +323 -212
- 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/UIFXController.d.ts
CHANGED
|
@@ -14,6 +14,23 @@ export declare const UIType: Readonly<{
|
|
|
14
14
|
KNOB: 'knob';
|
|
15
15
|
}>;
|
|
16
16
|
|
|
17
|
+
export type GroupTypeValue = 'radio' | 'tabs' | 'stepper' | 'rating';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Grouped-control types (U7, decisions/0007). Each mounts N native elements + one
|
|
21
|
+
* canvas + one recipe via mountUIFXGroup (NOT a UIType -- routed separately).
|
|
22
|
+
*/
|
|
23
|
+
export declare const GroupType: Readonly<{
|
|
24
|
+
/** fieldset + N <input type=radio>; native roving arrow-key selection. */
|
|
25
|
+
RADIO: 'radio';
|
|
26
|
+
/** role=tablist + N role=tab buttons; hand-written APG roving tabindex + arrows/Home/End. */
|
|
27
|
+
TABS: 'tabs';
|
|
28
|
+
/** one <input type=number> spinbutton; native Up/Down + typing. */
|
|
29
|
+
STEPPER: 'stepper';
|
|
30
|
+
/** radiogroup of N radios (rating semantics); native roving selection. */
|
|
31
|
+
RATING: 'rating';
|
|
32
|
+
}>;
|
|
33
|
+
|
|
17
34
|
export interface UIFXState {
|
|
18
35
|
hover: boolean;
|
|
19
36
|
active: boolean;
|
|
@@ -215,4 +232,114 @@ export declare function decorateUIFX(
|
|
|
215
232
|
options?: DecorateOptions
|
|
216
233
|
): DecorateInstance;
|
|
217
234
|
|
|
235
|
+
// =========================================================
|
|
236
|
+
// Grouped controls (U7, decisions/0007)
|
|
237
|
+
// =========================================================
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Per-frame state for a grouped control: the scalar UIFXState SUPERSET plus the
|
|
241
|
+
* group fields. The single-value fields (val/toggled/indeterminate) are neutral
|
|
242
|
+
* for a group -- a group uses `index`/`count`. Geometry lanes are Float32Arrays of
|
|
243
|
+
* length `count` the recipe reads by index (zero per-frame allocation).
|
|
244
|
+
*/
|
|
245
|
+
export interface UIFXGroupState extends UIFXState {
|
|
246
|
+
/** The selected item index, 0..count-1. */
|
|
247
|
+
index: number;
|
|
248
|
+
/** The number of items (multi-element groups) or steps (stepper). */
|
|
249
|
+
count: number;
|
|
250
|
+
/** The item currently under the pointer, or -1 when none. */
|
|
251
|
+
hoverIndex: number;
|
|
252
|
+
/** The item label strings (the mount's `items`); read by the recipe, never mutated. */
|
|
253
|
+
labels: string[];
|
|
254
|
+
/** Per-item x offset in strip coordinates (length count). */
|
|
255
|
+
itemX: Float32Array;
|
|
256
|
+
/** Per-item y offset (length count). */
|
|
257
|
+
itemY: Float32Array;
|
|
258
|
+
/** Per-item width (length count). */
|
|
259
|
+
itemW: Float32Array;
|
|
260
|
+
/** Per-item height (length count). */
|
|
261
|
+
itemH: Float32Array;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* A group recipe: the eight standard hooks (with group state) PLUS onSelect, the
|
|
266
|
+
* ninth, group-only hook. onSelect is rejected by mountUIFX/decorateUIFX (fail
|
|
267
|
+
* closed), so a recipe carrying it mounts only as a group.
|
|
268
|
+
*/
|
|
269
|
+
export interface UIFXGroupRecipe {
|
|
270
|
+
init?(ctx: CanvasRenderingContext2D, w: number, h: number, padding: number): void;
|
|
271
|
+
tick(ctx: CanvasRenderingContext2D, dt: number, now: number, state: UIFXGroupState, pointer: UIFXPointer): void;
|
|
272
|
+
onHover?(state: UIFXGroupState, pointer: UIFXPointer): void;
|
|
273
|
+
onLeave?(state: UIFXGroupState, pointer: UIFXPointer): void;
|
|
274
|
+
onClick?(x: number, y: number, state: UIFXGroupState): void;
|
|
275
|
+
onToggle?(checked: boolean, state: UIFXGroupState): void;
|
|
276
|
+
onDrag?(value: number, velocity: number, state: UIFXGroupState): void;
|
|
277
|
+
/** U7: fired exactly once per selection change, with the new index. */
|
|
278
|
+
onSelect?(index: number, state: UIFXGroupState): void;
|
|
279
|
+
destroy?(): void;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export type GroupRecipeFactory = () => UIFXGroupRecipe;
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Options for mountUIFXGroup. `items` (the per-item labels) is REQUIRED, >=2
|
|
286
|
+
* strings; its length is the item/step count. The initial selection is `index`
|
|
287
|
+
* (an integer, distinct from the hijack float `value`). The hijack-only keys
|
|
288
|
+
* (value/checked/knobMode/announce) are rejected -- passing one throws.
|
|
289
|
+
*/
|
|
290
|
+
export interface GroupOptions extends HostClockOptions {
|
|
291
|
+
/** The per-item labels; >=2 strings. Length = item/step count. Required. */
|
|
292
|
+
items: string[];
|
|
293
|
+
/** Initial selected index, integer in [0, items.length-1] (default 0). */
|
|
294
|
+
index?: number;
|
|
295
|
+
/** Accessible group label (fieldset/tablist aria-label). */
|
|
296
|
+
label?: string;
|
|
297
|
+
/** Total strip width in px (default: per-type item width * count). */
|
|
298
|
+
width?: number;
|
|
299
|
+
/** Strip height in px (default per group type). */
|
|
300
|
+
height?: number;
|
|
301
|
+
/** Canvas overflow padding in px (default 40). */
|
|
302
|
+
padding?: number;
|
|
303
|
+
/** Disable every native element + set state.disabled. */
|
|
304
|
+
disabled?: boolean;
|
|
305
|
+
seed?: number;
|
|
306
|
+
colors?: string[];
|
|
307
|
+
theme?: { light: string; mid: string; dark: string };
|
|
308
|
+
text?: string;
|
|
309
|
+
font?: string;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export interface UIFXGroupInstance {
|
|
313
|
+
/** The native interactive elements (radios / tabs, or the single spinbutton). */
|
|
314
|
+
els: HTMLElement[];
|
|
315
|
+
canvas: HTMLCanvasElement;
|
|
316
|
+
wrapper: HTMLDivElement;
|
|
317
|
+
state: UIFXGroupState;
|
|
318
|
+
/** The selected index right now (convenience over state.index). */
|
|
319
|
+
readonly index: number;
|
|
320
|
+
/** Drive one frame by hand (U5). Callable ONLY with { driven: true }. */
|
|
321
|
+
tick(dtMs: number): void;
|
|
322
|
+
/**
|
|
323
|
+
* Programmatically select item i in [0, count-1]: updates the native
|
|
324
|
+
* element(s), state.index, and fires onSelect exactly once (no native event,
|
|
325
|
+
* so no double fire). Does NOT steal focus. Throws on a bad index.
|
|
326
|
+
*/
|
|
327
|
+
setIndex(i: number): void;
|
|
328
|
+
destroy(): void;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Mount a grouped control: N native elements (radios in a fieldset, tabs in a
|
|
333
|
+
* tablist, a spinbutton, a rating radiogroup) sharing ONE canvas and one recipe
|
|
334
|
+
* (decisions/0007). The native elements own selection + keyboard + a11y; the
|
|
335
|
+
* canvas paints the group by reading state.index/state.count and the per-item
|
|
336
|
+
* geometry lanes. Additive to mountUIFX/decorateUIFX -- neither is touched.
|
|
337
|
+
*/
|
|
338
|
+
export declare function mountUIFXGroup(
|
|
339
|
+
container: HTMLElement,
|
|
340
|
+
groupType: GroupTypeValue,
|
|
341
|
+
recipeFactory: GroupRecipeFactory,
|
|
342
|
+
options: GroupOptions
|
|
343
|
+
): UIFXGroupInstance;
|
|
344
|
+
|
|
218
345
|
export default mountUIFX;
|
package/UIFXController.js
CHANGED
|
@@ -22,7 +22,7 @@ import { Ticker } from '@zakkster/lite-ticker';
|
|
|
22
22
|
|
|
23
23
|
// Three-place version sync: this constant, package.json "version", and the
|
|
24
24
|
// VERSION line in llms.txt must always match. /release keeps them locked.
|
|
25
|
-
export const VERSION = '1.
|
|
25
|
+
export const VERSION = '1.9.0';
|
|
26
26
|
|
|
27
27
|
// ---------------------------------------------------------
|
|
28
28
|
// SHARED TICKER (ref-counted, one RAF for all UI components)
|
|
@@ -192,6 +192,52 @@ export const UIType = Object.freeze({
|
|
|
192
192
|
const _KNOWN_TYPES = new Set(Object.values(UIType));
|
|
193
193
|
|
|
194
194
|
|
|
195
|
+
// ---------------------------------------------------------
|
|
196
|
+
// GROUP TYPES (U7) -- N native elements, one canvas, one recipe
|
|
197
|
+
// ---------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
// A grouped control is N native elements sharing ONE canvas and ONE recipe
|
|
200
|
+
// (decisions/0007). Unlike a UIType (one element) a GroupType routes to
|
|
201
|
+
// mountUIFXGroup, which lays out `count` items in a horizontal strip and gives
|
|
202
|
+
// the recipe a SoA view of them: state.index (selected), state.count, and the
|
|
203
|
+
// itemX/itemY/itemW/itemH Float32Array lanes. onSelect(index, state) is the
|
|
204
|
+
// ninth, group-only recipe hook (mountUIFX/decorateUIFX reject it -- fail closed).
|
|
205
|
+
/** @enum {string} */
|
|
206
|
+
export const GroupType = Object.freeze({
|
|
207
|
+
RADIO: 'radio', // fieldset + N <input type=radio>; native roving selection
|
|
208
|
+
TABS: 'tabs', // role=tablist + N role=tab buttons; roving tabindex + arrows
|
|
209
|
+
STEPPER: 'stepper', // one <input type=number> spinbutton; native Up/Down
|
|
210
|
+
RATING: 'rating', // radiogroup of N radios; native roving selection
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// Valid group types, derived once from GroupType so the group mount guard and
|
|
214
|
+
// the recipe registry stay one source of truth (0003 pattern). Cold.
|
|
215
|
+
const _KNOWN_GROUP_TYPES = new Set(Object.values(GroupType));
|
|
216
|
+
|
|
217
|
+
// A group recipe accepts the eight existing hooks PLUS onSelect. onSelect is
|
|
218
|
+
// group-only: it is absent from KNOWN_HOOKS, so mountUIFX/decorateUIFX reject a
|
|
219
|
+
// recipe carrying it (fail closed), and it is present here so a group recipe may.
|
|
220
|
+
const KNOWN_GROUP_HOOKS = ['init', 'tick', 'onHover', 'onLeave', 'onClick', 'onToggle', 'onDrag', 'onSelect', 'destroy'];
|
|
221
|
+
|
|
222
|
+
// Options valid on a group mount. `items` (the per-item labels) is required; the
|
|
223
|
+
// initial selection is `index` (an integer, not the hijack float `value`). The
|
|
224
|
+
// hijack-only keys (value/checked/knobMode/announce) are absent -> did-you-mean.
|
|
225
|
+
const GROUP_OPTIONS = ['items', 'index', 'label', 'width', 'height', 'padding', 'disabled', 'seed', 'colors', 'theme', 'text', 'font', 'ticker', 'driven'];
|
|
226
|
+
|
|
227
|
+
// Per-group-type default item box [w, h]. The strip is `count` items wide for the
|
|
228
|
+
// multi-element types; STEPPER is a single spinbutton, so its pair is the whole
|
|
229
|
+
// control's default box (its `count` pips are drawn inside that width).
|
|
230
|
+
const _GROUP_ITEM_DEFAULT = {
|
|
231
|
+
radio: [56, 56],
|
|
232
|
+
tabs: [84, 38],
|
|
233
|
+
rating: [40, 44],
|
|
234
|
+
stepper: [132, 46], // total box (single element), not per item
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
// Monotonic id for unique radio `name` grouping across concurrent mounts. Cold.
|
|
238
|
+
let _groupUid = 0;
|
|
239
|
+
|
|
240
|
+
|
|
195
241
|
// =========================================================
|
|
196
242
|
// UIFXController -- The Canvas Hijacker
|
|
197
243
|
// =========================================================
|
|
@@ -1163,4 +1209,482 @@ export function decorateUIFX(el, recipeFactory, options = {}) {
|
|
|
1163
1209
|
}
|
|
1164
1210
|
}
|
|
1165
1211
|
|
|
1212
|
+
// =========================================================
|
|
1213
|
+
// mountUIFXGroup -- The third mount mode (N native elements, one canvas)
|
|
1214
|
+
// =========================================================
|
|
1215
|
+
|
|
1216
|
+
/**
|
|
1217
|
+
* Mount a grouped control: N native elements (radios in a fieldset, tabs in a
|
|
1218
|
+
* tablist, a spinbutton, a rating radiogroup) sharing ONE canvas and one recipe
|
|
1219
|
+
* (decisions/0007). The native elements own selection + keyboard + a11y; the
|
|
1220
|
+
* canvas paints the group by reading state.index/state.count and the per-item
|
|
1221
|
+
* geometry lanes. Additive to mountUIFX/decorateUIFX -- neither is touched.
|
|
1222
|
+
*
|
|
1223
|
+
* @param {HTMLElement} container Parent to mount into.
|
|
1224
|
+
* @param {string} groupType One of GroupType (radio|tabs|stepper|rating).
|
|
1225
|
+
* @param {Function} recipeFactory (options) => Recipe (may add onSelect).
|
|
1226
|
+
* @param {Object} options { items:string[] (>=2, required), index=0, ... }
|
|
1227
|
+
* @returns {{ els:HTMLElement[], canvas, wrapper, state, index, setIndex, tick, destroy }}
|
|
1228
|
+
*/
|
|
1229
|
+
export function mountUIFXGroup(container, groupType, recipeFactory, options = {}) {
|
|
1230
|
+
// =====================================================================
|
|
1231
|
+
// PHASE 1 -- VALIDATION ONLY (fail closed; mirrors mountUIFX). No DOM,
|
|
1232
|
+
// no ticker, no recipe.init until every check below has passed.
|
|
1233
|
+
// =====================================================================
|
|
1234
|
+
|
|
1235
|
+
if (!container || typeof container.appendChild !== 'function') {
|
|
1236
|
+
throw new Error('mountUIFXGroup: container must be a DOM element');
|
|
1237
|
+
}
|
|
1238
|
+
if (!_KNOWN_GROUP_TYPES.has(groupType)) {
|
|
1239
|
+
throw new Error('mountUIFXGroup: groupType must be one of GroupType.RADIO, TABS, STEPPER, RATING');
|
|
1240
|
+
}
|
|
1241
|
+
for (const k in options) {
|
|
1242
|
+
if (!Object.prototype.hasOwnProperty.call(options, k)) continue;
|
|
1243
|
+
if (GROUP_OPTIONS.indexOf(k) === -1) {
|
|
1244
|
+
throw new Error(_didYouMean('mountUIFXGroup: unknown option', k, GROUP_OPTIONS));
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// items: the per-item labels. Required, an array of >=2 strings (a group of
|
|
1249
|
+
// one is not a group). Its length IS the item/step count. Fail closed.
|
|
1250
|
+
const items = options.items;
|
|
1251
|
+
if (!Array.isArray(items) || items.length < 2 || items.some((s) => typeof s !== 'string')) {
|
|
1252
|
+
throw new Error('mountUIFXGroup: option "items" must be an array of >=2 label strings');
|
|
1253
|
+
}
|
|
1254
|
+
const count = items.length;
|
|
1255
|
+
|
|
1256
|
+
// index: the initial selection, an integer in [0, count-1] (default 0). This
|
|
1257
|
+
// is the group's value -- distinct from the hijack float "value" (fail closed).
|
|
1258
|
+
let initialIndex = options.index === undefined ? 0 : options.index;
|
|
1259
|
+
if (typeof initialIndex !== 'number' || !Number.isInteger(initialIndex) ||
|
|
1260
|
+
initialIndex < 0 || initialIndex >= count) {
|
|
1261
|
+
throw new Error('mountUIFXGroup: option "index" must be an integer in [0, items.length-1]');
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
const disabled = options.disabled === undefined ? false : !!options.disabled;
|
|
1265
|
+
const width = options.width;
|
|
1266
|
+
const height = options.height;
|
|
1267
|
+
const padding = options.padding === undefined ? 40 : options.padding;
|
|
1268
|
+
const label = options.label === undefined ? '' : options.label;
|
|
1269
|
+
|
|
1270
|
+
// Theming options (0002), same validators as the other two mounts. Cold.
|
|
1271
|
+
const _theme = options.theme;
|
|
1272
|
+
if (_theme !== undefined) {
|
|
1273
|
+
if (_theme === null || typeof _theme !== 'object' ||
|
|
1274
|
+
typeof _theme.light !== 'string' || typeof _theme.mid !== 'string' ||
|
|
1275
|
+
typeof _theme.dark !== 'string' || Object.keys(_theme).length !== 3) {
|
|
1276
|
+
throw new Error('mountUIFXGroup: option "theme" must be { light, mid, dark } of color strings');
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
const _colors = options.colors;
|
|
1280
|
+
if (_colors !== undefined &&
|
|
1281
|
+
(!Array.isArray(_colors) || _colors.some((c) => typeof c !== 'string'))) {
|
|
1282
|
+
throw new Error('mountUIFXGroup: option "colors" must be an array of color strings');
|
|
1283
|
+
}
|
|
1284
|
+
if (options.text !== undefined && typeof options.text !== 'string') {
|
|
1285
|
+
throw new Error('mountUIFXGroup: option "text" must be a string');
|
|
1286
|
+
}
|
|
1287
|
+
if (options.font !== undefined && typeof options.font !== 'string') {
|
|
1288
|
+
throw new Error('mountUIFXGroup: option "font" must be a string');
|
|
1289
|
+
}
|
|
1290
|
+
if (options.seed !== undefined &&
|
|
1291
|
+
(typeof options.seed !== 'number' || !Number.isFinite(options.seed))) {
|
|
1292
|
+
throw new Error('mountUIFXGroup: option "seed" must be a finite number');
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// Host clock (U5, 0005) -- identical three modes as the other two mounts.
|
|
1296
|
+
const callerTicker = options.ticker;
|
|
1297
|
+
if (options.driven !== undefined && typeof options.driven !== 'boolean') {
|
|
1298
|
+
throw new Error('mountUIFXGroup: option "driven" must be a boolean');
|
|
1299
|
+
}
|
|
1300
|
+
const driven = options.driven === true;
|
|
1301
|
+
if (callerTicker !== undefined) {
|
|
1302
|
+
if (driven) {
|
|
1303
|
+
throw new Error('mountUIFXGroup: options "ticker" and "driven" are mutually exclusive');
|
|
1304
|
+
}
|
|
1305
|
+
if (!callerTicker || typeof callerTicker.add !== 'function') {
|
|
1306
|
+
throw new Error('mountUIFXGroup: option "ticker" must be a ticker with an .add(fn) method');
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
if (typeof recipeFactory !== 'function') {
|
|
1311
|
+
throw new Error('mountUIFXGroup: recipeFactory must be a function');
|
|
1312
|
+
}
|
|
1313
|
+
const recipe = recipeFactory(options);
|
|
1314
|
+
if (!recipe || typeof recipe !== 'object') {
|
|
1315
|
+
throw new Error('mountUIFXGroup: recipe must be an object');
|
|
1316
|
+
}
|
|
1317
|
+
if (typeof recipe.tick !== 'function') {
|
|
1318
|
+
throw new Error('mountUIFXGroup: recipe.tick must be a function');
|
|
1319
|
+
}
|
|
1320
|
+
for (const k in recipe) {
|
|
1321
|
+
if (!Object.prototype.hasOwnProperty.call(recipe, k)) continue;
|
|
1322
|
+
if (typeof recipe[k] === 'function' && KNOWN_GROUP_HOOKS.indexOf(k) === -1) {
|
|
1323
|
+
throw new Error(_didYouMean('mountUIFXGroup: unknown recipe hook', k, KNOWN_GROUP_HOOKS));
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
// =====================================================================
|
|
1328
|
+
// PHASE 2 -- SIDE EFFECTS (fail-closed unwind, mirrors mountUIFX).
|
|
1329
|
+
// =====================================================================
|
|
1330
|
+
let wrapperAppended = false;
|
|
1331
|
+
let acCreated = false;
|
|
1332
|
+
let tickerAcquired = false;
|
|
1333
|
+
let wrapper = null;
|
|
1334
|
+
let ac = null;
|
|
1335
|
+
let removeTick = null;
|
|
1336
|
+
let _moveTab = null; // TABS roving mover, shared with setIndex (not on state)
|
|
1337
|
+
|
|
1338
|
+
try {
|
|
1339
|
+
// -- Geometry. A horizontal strip of `count` item slots. Multi-element types
|
|
1340
|
+
// (radio/tabs/rating) size the strip = count * itemW; STEPPER is one
|
|
1341
|
+
// spinbutton whose default box holds `count` pips. width/height override
|
|
1342
|
+
// the total. All geometry is ARITHMETIC (no getBoundingClientRect): it
|
|
1343
|
+
// works headless and forces ZERO reflow (better than the U-11 one-read). --
|
|
1344
|
+
const def = _GROUP_ITEM_DEFAULT[groupType];
|
|
1345
|
+
const _single = groupType === GroupType.STEPPER;
|
|
1346
|
+
const w = width || (_single ? def[0] : def[0] * count);
|
|
1347
|
+
const h = height || def[1];
|
|
1348
|
+
let dpr = window.devicePixelRatio || 1;
|
|
1349
|
+
|
|
1350
|
+
// Per-item geometry lanes, preallocated once (the recipe reads them by index;
|
|
1351
|
+
// zero per-frame allocation). Uniform slots: itemW = w/count across the strip.
|
|
1352
|
+
const iw = w / count;
|
|
1353
|
+
const itemX = new Float32Array(count);
|
|
1354
|
+
const itemY = new Float32Array(count);
|
|
1355
|
+
const itemW = new Float32Array(count);
|
|
1356
|
+
const itemH = new Float32Array(count);
|
|
1357
|
+
for (let i = 0; i < count; i++) {
|
|
1358
|
+
itemX[i] = i * iw; itemY[i] = 0; itemW[i] = iw; itemH[i] = h;
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
// -- Build the native group. `root` holds the interactive elements; `els` is
|
|
1362
|
+
// the array of them (radios/buttons, or the single number input). --
|
|
1363
|
+
const uid = _groupUid++;
|
|
1364
|
+
let root;
|
|
1365
|
+
const els = [];
|
|
1366
|
+
if (groupType === GroupType.RADIO || groupType === GroupType.RATING) {
|
|
1367
|
+
root = document.createElement('fieldset');
|
|
1368
|
+
root.setAttribute('role', 'radiogroup');
|
|
1369
|
+
if (label) root.setAttribute('aria-label', label);
|
|
1370
|
+
Object.assign(root.style, {
|
|
1371
|
+
position: 'relative', display: 'inline-block',
|
|
1372
|
+
width: `${w}px`, height: `${h}px`,
|
|
1373
|
+
margin: '0', padding: '0', border: 'none', minWidth: '0',
|
|
1374
|
+
});
|
|
1375
|
+
const name = 'uifx-group-' + uid;
|
|
1376
|
+
for (let i = 0; i < count; i++) {
|
|
1377
|
+
const r = document.createElement('input');
|
|
1378
|
+
r.type = 'radio';
|
|
1379
|
+
r.name = name;
|
|
1380
|
+
r.setAttribute('aria-label', items[i]);
|
|
1381
|
+
if (i === initialIndex) r.checked = true;
|
|
1382
|
+
if (disabled) r.disabled = true;
|
|
1383
|
+
Object.assign(r.style, {
|
|
1384
|
+
position: 'absolute', top: '0', left: `${itemX[i]}px`,
|
|
1385
|
+
width: `${itemW[i]}px`, height: `${h}px`,
|
|
1386
|
+
opacity: '0', margin: '0', cursor: 'pointer', zIndex: '2',
|
|
1387
|
+
});
|
|
1388
|
+
root.appendChild(r);
|
|
1389
|
+
els.push(r);
|
|
1390
|
+
}
|
|
1391
|
+
} else if (groupType === GroupType.TABS) {
|
|
1392
|
+
root = document.createElement('div');
|
|
1393
|
+
root.setAttribute('role', 'tablist');
|
|
1394
|
+
if (label) root.setAttribute('aria-label', label);
|
|
1395
|
+
Object.assign(root.style, {
|
|
1396
|
+
position: 'relative', display: 'inline-block',
|
|
1397
|
+
width: `${w}px`, height: `${h}px`,
|
|
1398
|
+
});
|
|
1399
|
+
for (let i = 0; i < count; i++) {
|
|
1400
|
+
const b = document.createElement('button');
|
|
1401
|
+
b.type = 'button';
|
|
1402
|
+
b.setAttribute('role', 'tab');
|
|
1403
|
+
b.textContent = items[i]; // accessible name
|
|
1404
|
+
b.setAttribute('aria-selected', i === initialIndex ? 'true' : 'false');
|
|
1405
|
+
b.tabIndex = i === initialIndex ? 0 : -1; // roving tabindex
|
|
1406
|
+
if (disabled) b.disabled = true;
|
|
1407
|
+
Object.assign(b.style, {
|
|
1408
|
+
position: 'absolute', top: '0', left: `${itemX[i]}px`,
|
|
1409
|
+
width: `${itemW[i]}px`, height: `${h}px`,
|
|
1410
|
+
opacity: '0', margin: '0', padding: '0', border: 'none',
|
|
1411
|
+
background: 'transparent', cursor: 'pointer', zIndex: '2',
|
|
1412
|
+
WebkitAppearance: 'none', appearance: 'none',
|
|
1413
|
+
});
|
|
1414
|
+
root.appendChild(b);
|
|
1415
|
+
els.push(b);
|
|
1416
|
+
}
|
|
1417
|
+
} else { // STEPPER -- one spinbutton across the whole box; count pips drawn inside
|
|
1418
|
+
root = document.createElement('div');
|
|
1419
|
+
Object.assign(root.style, {
|
|
1420
|
+
position: 'relative', display: 'inline-block',
|
|
1421
|
+
width: `${w}px`, height: `${h}px`,
|
|
1422
|
+
});
|
|
1423
|
+
const inp = document.createElement('input');
|
|
1424
|
+
inp.type = 'number';
|
|
1425
|
+
inp.min = '0';
|
|
1426
|
+
inp.max = String(count - 1);
|
|
1427
|
+
inp.step = '1';
|
|
1428
|
+
inp.value = String(initialIndex);
|
|
1429
|
+
if (label) inp.setAttribute('aria-label', label);
|
|
1430
|
+
if (disabled) inp.disabled = true;
|
|
1431
|
+
Object.assign(inp.style, {
|
|
1432
|
+
position: 'absolute', top: '0', left: '0',
|
|
1433
|
+
width: `${w}px`, height: `${h}px`,
|
|
1434
|
+
opacity: '0', margin: '0', padding: '0', border: 'none',
|
|
1435
|
+
background: 'transparent', cursor: 'pointer', zIndex: '2',
|
|
1436
|
+
WebkitAppearance: 'none', appearance: 'none',
|
|
1437
|
+
});
|
|
1438
|
+
root.appendChild(inp);
|
|
1439
|
+
els.push(inp);
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
// -- Canvas overlay (DPR-aware), sized to the strip + padding. --
|
|
1443
|
+
const canvas = document.createElement('canvas');
|
|
1444
|
+
const cw = w + padding * 2;
|
|
1445
|
+
const ch = h + padding * 2;
|
|
1446
|
+
canvas.width = cw * dpr;
|
|
1447
|
+
canvas.height = ch * dpr;
|
|
1448
|
+
Object.assign(canvas.style, {
|
|
1449
|
+
position: 'absolute', top: '0', left: '0',
|
|
1450
|
+
width: `${cw}px`, height: `${ch}px`,
|
|
1451
|
+
transform: `translate(-${padding}px, -${padding}px)`,
|
|
1452
|
+
pointerEvents: 'none', zIndex: '1',
|
|
1453
|
+
});
|
|
1454
|
+
const ctx = canvas.getContext('2d');
|
|
1455
|
+
ctx.scale(dpr, dpr);
|
|
1456
|
+
|
|
1457
|
+
wrapper = document.createElement('div');
|
|
1458
|
+
Object.assign(wrapper.style, {
|
|
1459
|
+
position: 'relative', display: 'inline-block',
|
|
1460
|
+
width: `${w}px`, height: `${h}px`,
|
|
1461
|
+
});
|
|
1462
|
+
wrapper.appendChild(root);
|
|
1463
|
+
wrapper.appendChild(canvas);
|
|
1464
|
+
container.appendChild(wrapper);
|
|
1465
|
+
wrapperAppended = true;
|
|
1466
|
+
|
|
1467
|
+
const rmq = _reducedMotionQuery();
|
|
1468
|
+
|
|
1469
|
+
// -- State: a SUPERSET of the scalar per-frame state (every field present, so
|
|
1470
|
+
// a single-element recipe never breaks) PLUS the group fields. The single-
|
|
1471
|
+
// value fields (val/toggled/indeterminate) are neutral here; a group uses
|
|
1472
|
+
// index/count. Geometry lanes are references (zero per-frame alloc). --
|
|
1473
|
+
const state = {
|
|
1474
|
+
hover: false,
|
|
1475
|
+
active: false,
|
|
1476
|
+
focused: false,
|
|
1477
|
+
toggled: false, // scalar-superset neutral (a group has no single toggle)
|
|
1478
|
+
indeterminate: false, // scalar-superset neutral
|
|
1479
|
+
disabled,
|
|
1480
|
+
val: 0, // scalar-superset neutral (a group uses index/count)
|
|
1481
|
+
reducedMotion: rmq ? !!rmq.matches : false,
|
|
1482
|
+
budget: 1,
|
|
1483
|
+
w, h, padding, dpr,
|
|
1484
|
+
// group fields:
|
|
1485
|
+
index: initialIndex, // selected item (0..count-1)
|
|
1486
|
+
count, // number of items/steps
|
|
1487
|
+
hoverIndex: -1, // item under the pointer, -1 when none
|
|
1488
|
+
labels: items, // the item label strings (reference; cold-set)
|
|
1489
|
+
itemX, itemY, itemW, itemH, // per-item geometry lanes (Float32Array)
|
|
1490
|
+
};
|
|
1491
|
+
const pointer = { x: -999, y: -999, vx: 0, vy: 0 };
|
|
1492
|
+
|
|
1493
|
+
if (recipe.init) recipe.init(ctx, w, h, padding);
|
|
1494
|
+
|
|
1495
|
+
// -- Events (all via AbortController). --
|
|
1496
|
+
ac = new AbortController();
|
|
1497
|
+
acCreated = true;
|
|
1498
|
+
const signal = ac.signal;
|
|
1499
|
+
|
|
1500
|
+
// Selection: set state.index, fire onSelect exactly once when asked. A
|
|
1501
|
+
// programmatic native write emits no native event, so setIndex's explicit
|
|
1502
|
+
// fire is the only one (no double fire) -- same discipline as setValue (U4a).
|
|
1503
|
+
function select(i, fireHook) {
|
|
1504
|
+
state.index = i;
|
|
1505
|
+
if (fireHook && recipe.onSelect) recipe.onSelect(i, state);
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
// Per-element hover -> hoverIndex, and group hover. Cold pointer handlers on
|
|
1509
|
+
// the native elements (canvas is pointerEvents:none). Zero layout reads.
|
|
1510
|
+
// Iterate els.length, NOT count: STEPPER is one native element for N steps.
|
|
1511
|
+
for (let i = 0; i < els.length; i++) {
|
|
1512
|
+
const idx = i;
|
|
1513
|
+
els[i].addEventListener('pointerenter', () => {
|
|
1514
|
+
state.hover = true; state.hoverIndex = idx;
|
|
1515
|
+
if (recipe.onHover) recipe.onHover(state, pointer);
|
|
1516
|
+
}, { signal });
|
|
1517
|
+
els[i].addEventListener('pointerleave', () => {
|
|
1518
|
+
state.hoverIndex = -1;
|
|
1519
|
+
if (recipe.onLeave) recipe.onLeave(state, pointer);
|
|
1520
|
+
}, { signal });
|
|
1521
|
+
}
|
|
1522
|
+
// Group focus tracking (focusin/out bubble; any element focused == focused).
|
|
1523
|
+
root.addEventListener('focusin', () => { state.focused = true; }, { signal });
|
|
1524
|
+
root.addEventListener('focusout', () => { state.focused = false; state.hover = false; }, { signal });
|
|
1525
|
+
|
|
1526
|
+
// Per-type selection wiring.
|
|
1527
|
+
if (groupType === GroupType.RADIO || groupType === GroupType.RATING) {
|
|
1528
|
+
// Native radios: arrow keys move focus AND check the newly-focused radio,
|
|
1529
|
+
// firing 'change' on it (one change per move). Click checks + fires change.
|
|
1530
|
+
for (let i = 0; i < count; i++) {
|
|
1531
|
+
const idx = i;
|
|
1532
|
+
els[i].addEventListener('change', () => {
|
|
1533
|
+
if (els[idx].checked) select(idx, true);
|
|
1534
|
+
}, { signal });
|
|
1535
|
+
}
|
|
1536
|
+
} else if (groupType === GroupType.TABS) {
|
|
1537
|
+
// Hand-written APG roving tabindex: only the selected tab is tabbable;
|
|
1538
|
+
// Left/Right (+ Up/Down) and Home/End move selection + focus.
|
|
1539
|
+
_moveTab = function moveTab(i, focusIt) {
|
|
1540
|
+
for (let j = 0; j < count; j++) {
|
|
1541
|
+
els[j].tabIndex = j === i ? 0 : -1;
|
|
1542
|
+
els[j].setAttribute('aria-selected', j === i ? 'true' : 'false');
|
|
1543
|
+
}
|
|
1544
|
+
if (focusIt && els[i].focus) els[i].focus();
|
|
1545
|
+
select(i, true);
|
|
1546
|
+
};
|
|
1547
|
+
for (let i = 0; i < count; i++) {
|
|
1548
|
+
const idx = i;
|
|
1549
|
+
els[i].addEventListener('click', () => _moveTab(idx, true), { signal });
|
|
1550
|
+
}
|
|
1551
|
+
root.addEventListener('keydown', (e) => {
|
|
1552
|
+
let ni = state.index;
|
|
1553
|
+
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') ni = (state.index + 1) % count;
|
|
1554
|
+
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') ni = (state.index - 1 + count) % count;
|
|
1555
|
+
else if (e.key === 'Home') ni = 0;
|
|
1556
|
+
else if (e.key === 'End') ni = count - 1;
|
|
1557
|
+
else return;
|
|
1558
|
+
e.preventDefault();
|
|
1559
|
+
_moveTab(ni, true);
|
|
1560
|
+
}, { signal });
|
|
1561
|
+
} else { // STEPPER
|
|
1562
|
+
// Native spinbutton: ArrowUp/Down + typing fire 'input'. Read + clamp to
|
|
1563
|
+
// [0,count-1]; 'change' (blur) would double-fire, so listen 'input' only.
|
|
1564
|
+
els[0].addEventListener('input', () => {
|
|
1565
|
+
let v = parseInt(els[0].value, 10);
|
|
1566
|
+
if (!Number.isFinite(v)) return; // mid-edit empty field: ignore
|
|
1567
|
+
if (v < 0) v = 0; else if (v > count - 1) v = count - 1;
|
|
1568
|
+
if (String(v) !== els[0].value) els[0].value = String(v); // reflect the clamp
|
|
1569
|
+
select(v, true);
|
|
1570
|
+
}, { signal });
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
// -- DPR re-read on display change (cold; absent matchMedia is a silent
|
|
1574
|
+
// no-op -- fail closed). --
|
|
1575
|
+
if (typeof window.matchMedia === 'function') {
|
|
1576
|
+
const mq = window.matchMedia('(resolution: ' + dpr + 'dppx)');
|
|
1577
|
+
mq.addEventListener('change', () => {
|
|
1578
|
+
const nd = window.devicePixelRatio || 1;
|
|
1579
|
+
dpr = nd;
|
|
1580
|
+
canvas.width = cw * nd;
|
|
1581
|
+
canvas.height = ch * nd;
|
|
1582
|
+
ctx.setTransform(nd, 0, 0, nd, 0, 0);
|
|
1583
|
+
state.dpr = nd;
|
|
1584
|
+
}, { signal });
|
|
1585
|
+
}
|
|
1586
|
+
if (rmq) {
|
|
1587
|
+
rmq.addEventListener('change', () => { state.reducedMotion = !!rmq.matches; }, { signal });
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
// -- Render loop. ONE named frame body; three clock modes invoke it with no
|
|
1591
|
+
// wrapper; same budget update + quarantine-on-throw as the other mounts. --
|
|
1592
|
+
let destroyed = false;
|
|
1593
|
+
let quarantined = false;
|
|
1594
|
+
|
|
1595
|
+
function frame(dtMs) {
|
|
1596
|
+
if (destroyed || quarantined) return;
|
|
1597
|
+
const dt = dtMs / 1000;
|
|
1598
|
+
const now = performance.now();
|
|
1599
|
+
if (dt > 0) {
|
|
1600
|
+
let inst = _TARGET_DT / dt;
|
|
1601
|
+
if (inst > 1) inst = 1; else if (inst < 0) inst = 0;
|
|
1602
|
+
state.budget += (inst - state.budget) * _BUDGET_SMOOTH;
|
|
1603
|
+
}
|
|
1604
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
1605
|
+
ctx.clearRect(0, 0, cw, ch);
|
|
1606
|
+
ctx.save();
|
|
1607
|
+
ctx.translate(padding, padding); // Origin = the strip's top-left
|
|
1608
|
+
try {
|
|
1609
|
+
recipe.tick(ctx, dt, now, state, pointer);
|
|
1610
|
+
} catch (err) {
|
|
1611
|
+
quarantined = true;
|
|
1612
|
+
console.error('mountUIFXGroup: recipe.tick threw for groupType "' + groupType + '"; group quarantined', err);
|
|
1613
|
+
ctx.restore();
|
|
1614
|
+
ctx.clearRect(0, 0, cw, ch);
|
|
1615
|
+
return;
|
|
1616
|
+
}
|
|
1617
|
+
ctx.restore();
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
if (driven) {
|
|
1621
|
+
// no ticker acquired; removeTick stays null
|
|
1622
|
+
} else if (callerTicker !== undefined) {
|
|
1623
|
+
removeTick = callerTicker.add(frame);
|
|
1624
|
+
} else {
|
|
1625
|
+
const ticker = acquireTicker();
|
|
1626
|
+
tickerAcquired = true;
|
|
1627
|
+
removeTick = ticker.add(frame);
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
// -- Public API --
|
|
1631
|
+
return {
|
|
1632
|
+
/** The native interactive elements (radios / tabs, or the single spinbutton). */
|
|
1633
|
+
els,
|
|
1634
|
+
/** The overlay canvas. */
|
|
1635
|
+
canvas,
|
|
1636
|
+
/** The wrapper div. */
|
|
1637
|
+
wrapper,
|
|
1638
|
+
/** Current state (read-only reference; state.index is the live selection). */
|
|
1639
|
+
state,
|
|
1640
|
+
/** The selected index right now (convenience over state.index). */
|
|
1641
|
+
get index() { return state.index; },
|
|
1642
|
+
|
|
1643
|
+
/** Drive one frame by hand (U5). Callable ONLY in { driven: true } mode. */
|
|
1644
|
+
tick: driven ? frame : _drivenOnly,
|
|
1645
|
+
|
|
1646
|
+
/**
|
|
1647
|
+
* Programmatically select item i in [0, count-1]: updates the native
|
|
1648
|
+
* element(s), state.index, and fires onSelect exactly once (a programmatic
|
|
1649
|
+
* native write emits no native event, so no double fire). Does NOT steal
|
|
1650
|
+
* focus. Fail closed on a bad index.
|
|
1651
|
+
*/
|
|
1652
|
+
setIndex(i) {
|
|
1653
|
+
if (destroyed) return;
|
|
1654
|
+
if (typeof i !== 'number' || !Number.isInteger(i) || i < 0 || i >= count) {
|
|
1655
|
+
throw new Error('setIndex: i must be an integer in [0, count-1]');
|
|
1656
|
+
}
|
|
1657
|
+
if (groupType === GroupType.RADIO || groupType === GroupType.RATING) {
|
|
1658
|
+
els[i].checked = true; // no native 'change' from a programmatic set
|
|
1659
|
+
} else if (groupType === GroupType.TABS) {
|
|
1660
|
+
_moveTab(i, false); // roving update without focus; fires onSelect
|
|
1661
|
+
return;
|
|
1662
|
+
} else { // STEPPER
|
|
1663
|
+
els[0].value = String(i); // no native 'input' from a programmatic set
|
|
1664
|
+
}
|
|
1665
|
+
select(i, true);
|
|
1666
|
+
},
|
|
1667
|
+
|
|
1668
|
+
/** Destroy everything. Idempotent. */
|
|
1669
|
+
destroy() {
|
|
1670
|
+
if (destroyed) return;
|
|
1671
|
+
destroyed = true;
|
|
1672
|
+
ac.abort();
|
|
1673
|
+
if (removeTick) removeTick();
|
|
1674
|
+
if (recipe.destroy) recipe.destroy();
|
|
1675
|
+
if (tickerAcquired) releaseTicker();
|
|
1676
|
+
wrapper.remove();
|
|
1677
|
+
},
|
|
1678
|
+
};
|
|
1679
|
+
} catch (err) {
|
|
1680
|
+
// A phase-2 step threw (realistically recipe.init). Unwind ONLY what was
|
|
1681
|
+
// acquired, reverse order, each flag-guarded. recipe.destroy is NOT called.
|
|
1682
|
+
if (removeTick) removeTick();
|
|
1683
|
+
if (tickerAcquired) releaseTicker();
|
|
1684
|
+
if (acCreated) ac.abort();
|
|
1685
|
+
if (wrapperAppended) wrapper.remove();
|
|
1686
|
+
throw err;
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1166
1690
|
export default mountUIFX;
|