tweakit 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +31 -29
- package/README.md +55 -29
- package/THIRD-PARTY-NOTICES.md +0 -1
- package/dist/tweaks/auto.js +2 -0
- package/dist/tweaks/auto.js.map +6 -0
- package/dist/tweaks/bezier-JFEZMCLP.js +2 -0
- package/dist/tweaks/bezier-JFEZMCLP.js.map +6 -0
- package/dist/tweaks/chunk-6UO4DHBN.js +2 -0
- package/dist/tweaks/chunk-6UO4DHBN.js.map +6 -0
- package/dist/tweaks/chunk-DIQMLBAS.js +2 -0
- package/dist/tweaks/chunk-DIQMLBAS.js.map +6 -0
- package/dist/tweaks/chunk-XE6OUJBG.js +2 -0
- package/dist/tweaks/chunk-XE6OUJBG.js.map +6 -0
- package/dist/tweaks/clip-WW7JYO4X.js +2 -0
- package/dist/tweaks/clip-WW7JYO4X.js.map +6 -0
- package/dist/tweaks/color-YMMFURP6.js +2 -0
- package/dist/tweaks/color-YMMFURP6.js.map +6 -0
- package/dist/tweaks/core.js +2 -1
- package/dist/tweaks/core.js.map +6 -0
- package/dist/tweaks/{fold-UGN7TXGX.js → fold-U6YRZLCJ.js} +2 -1
- package/dist/tweaks/fold-U6YRZLCJ.js.map +6 -0
- package/dist/tweaks/gradient-JER7PW4D.js +2 -0
- package/dist/tweaks/gradient-JER7PW4D.js.map +6 -0
- package/dist/tweaks/heavy-CLD7UAVI.js +2 -0
- package/dist/tweaks/heavy-CLD7UAVI.js.map +6 -0
- package/dist/tweaks/image-H6QSLGO3.js +4 -0
- package/dist/tweaks/image-H6QSLGO3.js.map +6 -0
- package/dist/tweaks/image-store-WRKY2NPU.js +2 -0
- package/dist/tweaks/image-store-WRKY2NPU.js.map +6 -0
- package/dist/tweaks/interval-TEUHQGZN.js +2 -0
- package/dist/tweaks/interval-TEUHQGZN.js.map +6 -0
- package/dist/tweaks/markup-NIBJD6E2.js +2 -0
- package/dist/tweaks/markup-NIBJD6E2.js.map +6 -0
- package/dist/tweaks/monitor-3BY7YCGX.js +3 -0
- package/dist/tweaks/monitor-3BY7YCGX.js.map +6 -0
- package/dist/tweaks/motion-FQPUBKKM.js +2 -0
- package/dist/tweaks/motion-FQPUBKKM.js.map +6 -0
- package/dist/tweaks/plot-2HBNBZSG.js +3 -0
- package/dist/tweaks/plot-2HBNBZSG.js.map +6 -0
- package/dist/tweaks/point-VV6XV3I6.js +2 -0
- package/dist/tweaks/point-VV6XV3I6.js.map +6 -0
- package/dist/tweaks/shadow-5E4E55I2.js +2 -0
- package/dist/tweaks/shadow-5E4E55I2.js.map +6 -0
- package/dist/tweaks/sides-P3STEVYY.js +2 -0
- package/dist/tweaks/sides-P3STEVYY.js.map +6 -0
- package/dist/tweaks/spring-HUVSXNVN.js +2 -0
- package/dist/tweaks/spring-HUVSXNVN.js.map +6 -0
- package/dist/tweaks/tabs-76FW7LQ7.js +2 -0
- package/dist/tweaks/tabs-76FW7LQ7.js.map +6 -0
- package/dist/tweaks.css +1 -1
- package/dist/tweaks.js +6 -3
- package/dist/tweaks.js.map +6 -0
- package/dist/types/tweaks/easing.d.ts +8 -11
- package/dist/types/tweaks/panel.d.ts +6 -4
- package/dist/types/tweaks/schema.d.ts +4 -8
- package/dist/types/tweaks/standalone.d.ts +3 -3
- package/dist/types/tweaks/types.d.ts +278 -101
- package/package.json +27 -5
- package/src/tweaks/auto.ts +12 -0
- package/src/tweaks/clip.ts +94 -0
- package/src/tweaks/controls/basic.ts +403 -0
- package/src/tweaks/controls/bezier.ts +99 -0
- package/src/tweaks/controls/color.ts +412 -0
- package/src/tweaks/controls/gradient.ts +240 -0
- package/src/tweaks/controls/image.ts +299 -0
- package/src/tweaks/controls/interval.ts +104 -0
- package/src/tweaks/controls/monitor.ts +105 -0
- package/src/tweaks/controls/motion.ts +141 -0
- package/src/tweaks/controls/plot.ts +220 -0
- package/src/tweaks/controls/point.ts +119 -0
- package/src/tweaks/controls/shadow.ts +83 -0
- package/src/tweaks/controls/sides.ts +86 -0
- package/src/tweaks/controls/spring.ts +148 -0
- package/src/tweaks/controls/tabs.ts +47 -0
- package/src/tweaks/core.ts +9 -0
- package/src/tweaks/easing.ts +93 -0
- package/src/tweaks/enhance.ts +20 -0
- package/src/tweaks/feedback.ts +119 -0
- package/src/tweaks/fold.ts +21 -0
- package/src/tweaks/heavy.ts +302 -0
- package/src/tweaks/icons.ts +19 -0
- package/src/tweaks/image-store.ts +101 -0
- package/src/tweaks/lazy.ts +46 -0
- package/src/tweaks/markup.ts +154 -0
- package/src/tweaks/panel.ts +869 -0
- package/src/tweaks/schema.ts +189 -0
- package/src/tweaks/shared.ts +525 -0
- package/src/tweaks/single.ts +22 -0
- package/src/tweaks/standalone.ts +89 -0
- package/src/tweaks/types.ts +526 -0
- package/src/tweaks.css +959 -0
- package/src/wide-gamut.ts +310 -0
- package/dist/tweaks/bezier-2RABC6LD.js +0 -1
- package/dist/tweaks/chunk-4ATI34PB.js +0 -1
- package/dist/tweaks/chunk-7OSCMZ2J.js +0 -1
- package/dist/tweaks/chunk-W3JNIJ2F.js +0 -1
- package/dist/tweaks/clip-ECGCECEC.js +0 -1
- package/dist/tweaks/colour-2RE3FQ5E.js +0 -1
- package/dist/tweaks/gradient-DB6XTDHO.js +0 -1
- package/dist/tweaks/heavy-PKOMHB3C.js +0 -1
- package/dist/tweaks/image-7MCLBOZ3.js +0 -1
- package/dist/tweaks/interval-L2E4L3AZ.js +0 -1
- package/dist/tweaks/markup-ZJJ6CCWM.js +0 -1
- package/dist/tweaks/monitor-IEK742PG.js +0 -2
- package/dist/tweaks/motion-N5UNVR55.js +0 -1
- package/dist/tweaks/plot-VVTOY3RA.js +0 -2
- package/dist/tweaks/point-YMMHSLO7.js +0 -1
- package/dist/tweaks/shadow-IKYOV3VN.js +0 -1
- package/dist/tweaks/sides-FH227Z54.js +0 -1
- package/dist/tweaks/spring-3PKAFIH7.js +0 -1
- package/dist/tweaks/tabs-BKK5NIIY.js +0 -1
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tweakit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"main": "./dist/tweaks.js",
|
|
5
|
+
"main": "./dist/tweaks/core.js",
|
|
6
6
|
"types": "./dist/types/tweaks/core.d.ts",
|
|
7
|
-
"description": "A dependency-free, code-split real-time parameter panel — sliders, toggles, a wide-gamut OKLCH
|
|
7
|
+
"description": "A dependency-free, code-split real-time parameter panel — sliders, toggles, a wide-gamut OKLCH color picker, curve editors and more, from a plain schema.",
|
|
8
8
|
"license": "MIT",
|
|
9
9
|
"author": "Ryan Kiley",
|
|
10
10
|
"repository": {
|
|
@@ -28,12 +28,19 @@
|
|
|
28
28
|
],
|
|
29
29
|
"scripts": {
|
|
30
30
|
"build": "node build.mjs",
|
|
31
|
-
"test": "node build.mjs && node --test \"test/*.test.mjs\"",
|
|
31
|
+
"test": "node build.mjs && node --enable-source-maps --test \"test/*.test.mjs\"",
|
|
32
32
|
"prepublishOnly": "npm test",
|
|
33
|
-
"
|
|
33
|
+
"prepack": "node build.mjs",
|
|
34
|
+
"serve": "node build.mjs && python3 -m http.server 4330 --directory dist",
|
|
35
|
+
"test:browser": "node build.mjs && node --test \"test/browser/*.test.mjs\""
|
|
34
36
|
},
|
|
35
37
|
"exports": {
|
|
36
38
|
".": {
|
|
39
|
+
"types": "./dist/types/tweaks/core.d.ts",
|
|
40
|
+
"import": "./dist/tweaks/core.js",
|
|
41
|
+
"default": "./dist/tweaks/core.js"
|
|
42
|
+
},
|
|
43
|
+
"./single": {
|
|
37
44
|
"types": "./dist/types/tweaks/core.d.ts",
|
|
38
45
|
"import": "./dist/tweaks.js",
|
|
39
46
|
"default": "./dist/tweaks.js"
|
|
@@ -43,24 +50,39 @@
|
|
|
43
50
|
"import": "./dist/tweaks/core.js",
|
|
44
51
|
"default": "./dist/tweaks/core.js"
|
|
45
52
|
},
|
|
53
|
+
"./auto": {
|
|
54
|
+
"types": "./dist/types/tweaks/core.d.ts",
|
|
55
|
+
"import": "./dist/tweaks/auto.js",
|
|
56
|
+
"default": "./dist/tweaks/auto.js"
|
|
57
|
+
},
|
|
46
58
|
"./css": {
|
|
47
59
|
"types": "./dist/tweaks.css.d.ts",
|
|
48
60
|
"default": "./dist/tweaks.css"
|
|
49
61
|
},
|
|
50
62
|
"./package.json": "./package.json"
|
|
51
63
|
},
|
|
64
|
+
"sideEffects": [
|
|
65
|
+
"./dist/tweaks.css",
|
|
66
|
+
"./dist/tweaks/auto.js",
|
|
67
|
+
"./dist/tweaks/*-*.js",
|
|
68
|
+
"./src/**"
|
|
69
|
+
],
|
|
52
70
|
"files": [
|
|
53
71
|
"/AGENTS.md",
|
|
54
72
|
"dist/tweaks",
|
|
55
73
|
"dist/tweaks.js",
|
|
74
|
+
"dist/tweaks.js.map",
|
|
56
75
|
"dist/tweaks.css",
|
|
57
76
|
"dist/tweaks.css.d.ts",
|
|
58
77
|
"dist/types",
|
|
78
|
+
"src",
|
|
59
79
|
"THIRD-PARTY-NOTICES.md"
|
|
60
80
|
],
|
|
61
81
|
"devDependencies": {
|
|
62
82
|
"esbuild": "^0.28.0",
|
|
83
|
+
"fake-indexeddb": "^6.2.6",
|
|
63
84
|
"jsdom": "^30.1.2",
|
|
85
|
+
"playwright": "^1.64.1",
|
|
64
86
|
"typescript": "^7.0.2"
|
|
65
87
|
}
|
|
66
88
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/* tweakit/auto: the code-split API, plus enhance(document) once the page has parsed. The bare import binds nothing, so a
|
|
2
|
+
* page of [data-tw] markup with no script of its own imports this entry instead. Built on its own, unbundled, so it stays a
|
|
3
|
+
* few lines over ./core.js rather than reshaping the split build's chunks. */
|
|
4
|
+
import { enhance } from "./core.js";
|
|
5
|
+
export * from "./core.js";
|
|
6
|
+
|
|
7
|
+
// A page with nothing to bind doesn't fetch the markup chunk: no [data-tw] host, and no static showcase panel (enhance() wraps one).
|
|
8
|
+
if (typeof document !== "undefined") {
|
|
9
|
+
const run = () => { if (document.querySelector('[data-tw], .tw-panel[data-mode="inline"] > .tw-controls')) enhance(document); };
|
|
10
|
+
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", run);
|
|
11
|
+
else run();
|
|
12
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/* The paste's parse, checks and report: a lazy chunk the first paste fetches, so none of it rides in a basic
|
|
2
|
+
* panel's download. A runtime import here of a module only core reaches (schema.ts, feedback.ts) would split
|
|
3
|
+
* that module into a shared chunk of its own, so the caller lends what it needs (Report, ClipCtx); shared.ts is
|
|
4
|
+
* already shared with the lazy controls. */
|
|
5
|
+
import type { Entry, Snapshot } from "./panel.js";
|
|
6
|
+
import type { PanelState } from "./types.js";
|
|
7
|
+
import type { Meta } from "./schema.js";
|
|
8
|
+
import type { announce } from "./feedback.js";
|
|
9
|
+
import { isObj, isColorStr, optValue, hasOwn, isReservedKey } from "./shared.js";
|
|
10
|
+
|
|
11
|
+
/** Where a pasted value can land: a control's path, its current value, its meta and its row (a disabled one takes nothing). */
|
|
12
|
+
export interface Slot { path: string[]; get: () => unknown; m: Meta; el?: Element }
|
|
13
|
+
/** How a paste reports: the words a screen reader hears, and whether its panel is still there. */
|
|
14
|
+
export interface Report { announce: typeof announce; alive: () => boolean }
|
|
15
|
+
/** What a panel lends the paste. */
|
|
16
|
+
export interface ClipCtx extends Report { panel: HTMLElement; entries: Entry[]; fromJSON: (state: PanelState) => void }
|
|
17
|
+
/** What a scoped ⌘C / ⌘V acts on: one row, or every row under a folder's path. */
|
|
18
|
+
export type Scope = { kind: "row"; entry: Entry; label: string } | { kind: "folder"; path: string[]; label: string };
|
|
19
|
+
|
|
20
|
+
const atPath = (obj: unknown, path: string[]): unknown => path.reduce<any>((o, k) => (isObj(o) && hasOwn(o, k) ? o[k] : undefined), obj);
|
|
21
|
+
|
|
22
|
+
/** ⇧-copy's output is a flat map by dotted path; nested, "folder.child" lands where set() would put it. A reserved
|
|
23
|
+
* segment is dropped, so a pasted "__proto__.x" can't write onto Object.prototype. */
|
|
24
|
+
const unflatten = (flat: Record<string, unknown>): Snapshot => {
|
|
25
|
+
const out: Snapshot = {};
|
|
26
|
+
for (const k of Object.keys(flat)) {
|
|
27
|
+
const path = k.split("."); if (path.some(isReservedKey)) continue;
|
|
28
|
+
let o = out; for (const s of path.slice(0, -1)) o = isObj(o[s]) ? o[s] : (o[s] = {});
|
|
29
|
+
o[path[path.length - 1]] = flat[k];
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// A pasted value fits where it's going when it has the current value's shape: the same JSON type all the way down, a
|
|
35
|
+
// finite number, a tuple (an interval, a curve) of the same length, a list (a gradient's stops) of 2 to 64 items each
|
|
36
|
+
// shaped like the first, a color the browser can parse wherever a color stands (a color or gradient control), and
|
|
37
|
+
// for a list control one of its options. Anything else would be coerced or ignored by set() — null to 0, a stray string
|
|
38
|
+
// to black, a short tuple dropped — so it's skipped, and counted as not pasted; so is anything for a disabled row.
|
|
39
|
+
const isCssColor = (v: unknown) => isColorStr(v) && (typeof CSS === "undefined" || !CSS.supports || CSS.supports("color", v as string));
|
|
40
|
+
const sameShape = (cur: unknown, v: unknown, col: boolean): boolean => {
|
|
41
|
+
if (Array.isArray(cur)) return Array.isArray(v) && (cur.every((x) => !isObj(x)) ? v.length === cur.length && v.every((x, i) => sameShape(cur[i], x, col)) : v.length >= 2 && v.length <= 64 && v.every((x) => sameShape(cur[0], x, col)));
|
|
42
|
+
if (isObj(cur)) return isObj(v) && !Array.isArray(v) && Object.keys(cur).every((k) => sameShape(cur[k], v[k], col));
|
|
43
|
+
return typeof v === typeof cur && (typeof v !== "number" || Number.isFinite(v)) && (!col || !isColorStr(cur) || isCssColor(v));
|
|
44
|
+
};
|
|
45
|
+
const fits = (s: Slot, v: unknown) => v != null && !s.el?.closest(".is-disabled") && sameShape(s.get(), v, s.m.type === "color" || s.m.type === "gradient")
|
|
46
|
+
&& (!s.m.options || s.m.options.some((o) => JSON.stringify(optValue(o)) === JSON.stringify(v)));
|
|
47
|
+
|
|
48
|
+
/** The values in `values` that fit their slots, each slot read at its path below `base`. */
|
|
49
|
+
export const pick = <S extends Slot>(slots: S[], values: unknown, base: string[] = []): Array<[S, unknown]> =>
|
|
50
|
+
slots.map((s): [S, unknown] => [s, atPath(values, s.path.slice(base.length))]).filter(([s, v]) => fits(s, v));
|
|
51
|
+
|
|
52
|
+
// Only what fits is written, through fromJSON: a bag key or a value set() would coerce stays as it is.
|
|
53
|
+
const write = (ctx: ClipCtx, hits: Array<[Entry, unknown]>) => {
|
|
54
|
+
const out: Snapshot = {};
|
|
55
|
+
for (const [e, v] of hits) { let o = out; for (const k of e.path.slice(0, -1)) o = o[k] ||= {}; o[e.path[e.path.length - 1]] = v; }
|
|
56
|
+
ctx.fromJSON({ values: out });
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** ⌘V on a row or folder. A row takes a bare value or its own one-key object. A folder takes its values object, or any
|
|
60
|
+
* copy that holds the folder at its own path (a whole-panel copy, a changed-only map), only into its own rows: each
|
|
61
|
+
* reading is tried, as it stands and unflattened, at the folder's path and as the folder's own, and the one that lands
|
|
62
|
+
* most wins (a folder whose subfolder shares its name reads its own copy right). */
|
|
63
|
+
export function pasteScope(ctx: ClipCtx, s: Scope, text: string): number | false {
|
|
64
|
+
const data = JSON.parse(text);
|
|
65
|
+
let hits: Array<[Entry, unknown]>;
|
|
66
|
+
if (s.kind === "row") {
|
|
67
|
+
const e = s.entry, v = isObj(data) && !Array.isArray(data) && Object.keys(data).length === 1 && hasOwn(data, e.key) ? data[e.key] : data;
|
|
68
|
+
hits = fits(e, v) ? [[e, v]] : [];
|
|
69
|
+
} else {
|
|
70
|
+
if (!isObj(data) || Array.isArray(data)) return false;
|
|
71
|
+
const rows = ctx.entries.filter((e) => e.path.length > s.path.length && s.path.every((k, i) => e.path[i] === k));
|
|
72
|
+
hits = [];
|
|
73
|
+
for (const d of [data, unflatten(data)]) for (const v of [atPath(d, s.path), d]) { const h = isObj(v) ? pick(rows, v, s.path) : []; if (h.length > hits.length) hits = h; }
|
|
74
|
+
}
|
|
75
|
+
if (!hits.length) return false;
|
|
76
|
+
write(ctx, hits);
|
|
77
|
+
return hits.length;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const flashes = new WeakMap<Element, ReturnType<typeof setTimeout>>();
|
|
81
|
+
/** Every paste ends here. `text` is what the browser's paste event carried (null: none); `apply` returns how many
|
|
82
|
+
* values it took, or false for nothing usable (a throw counts the same). A screen reader hears which, and a picked
|
|
83
|
+
* `band` flashes it; the result is whether anything landed, or undefined when the panel went while this chunk loaded. */
|
|
84
|
+
export function pasteInto(text: string | null, apply: (text: string) => number | false, r: Report, band?: Element) {
|
|
85
|
+
if (!r.alive()) return;
|
|
86
|
+
let n: number | false = false;
|
|
87
|
+
if (text !== null) try { n = apply(text); } catch {}
|
|
88
|
+
r.announce(!n ? (text === null ? "Couldn't read the clipboard" : "Nothing to paste") : `${n} value${n === 1 ? "" : "s"} pasted`);
|
|
89
|
+
if (band) { // a quick second paste on the same row restarts the flash: the first one's timer mustn't cut it short
|
|
90
|
+
const c = n ? "is-pasted" : "is-refused"; band.classList.remove("is-pasted", "is-refused"); band.classList.add(c);
|
|
91
|
+
clearTimeout(flashes.get(band)); flashes.set(band, setTimeout(() => band.classList.remove(c), 450));
|
|
92
|
+
}
|
|
93
|
+
return !!n;
|
|
94
|
+
}
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
/* The core controls — always-registered constructors (slider, toggle, radio grid, select,
|
|
2
|
+
* buttons, text, folder) plus createControl. Statically imported by the entry, so basic panels build synchronously. */
|
|
3
|
+
import {
|
|
4
|
+
el, btn, txt, clamp, stepPrecision, gridEnds, roundToStep, normalizeRange, rangeStep, overlapsText, optValue, optLabel, json,
|
|
5
|
+
popover, radioButton, setRadioActive, activeIndex, navIndex, createSegmented, blade, foldBody,
|
|
6
|
+
quietFocus, wireHoverClass, onReady, onLive, registerControl, getControl, rubberBand, rubberRelease,
|
|
7
|
+
EASE_SPRING, EASE_GLIDE, REDUCE_MOTION,
|
|
8
|
+
} from "../shared.js";
|
|
9
|
+
import { ICON_CHEVRON, chevronIcon } from "../icons.js";
|
|
10
|
+
import type { OnChange } from "../shared.js";
|
|
11
|
+
import type { Meta } from "../schema.js";
|
|
12
|
+
import type { Control, Option } from "../types.js";
|
|
13
|
+
|
|
14
|
+
// ── Slider control ──
|
|
15
|
+
const CLICK_THRESHOLD = 3;
|
|
16
|
+
function createSlider(meta: Meta, onChange: OnChange): Control {
|
|
17
|
+
const label = meta.label;
|
|
18
|
+
let { min, max, step } = normalizeRange(meta.min, meta.max, meta.step, [+(meta.value as number)], label);
|
|
19
|
+
// A hard slider's range is its reachable grid: the ends move in to the first/last grid point,
|
|
20
|
+
// so notches, hashmarks, keyboard ends, aria bounds and the emitted value agree. A soft slider keeps its authored range.
|
|
21
|
+
if (!meta.soft) [min, max] = gridEnds(min, max, step);
|
|
22
|
+
const snap = (max - min) / step <= 6; // past ~6 stops, snapping at every step felt notchy
|
|
23
|
+
const seed = Number.isFinite(+meta.value) ? +meta.value : min; // a non-finite seed → min
|
|
24
|
+
// A soft slider keeps an out-of-range default, as set() does; pull is the detent's tension offset read by render().
|
|
25
|
+
let value = meta.soft && !snap ? seed : clamp(seed, min, max), pull = 0;
|
|
26
|
+
const decimals = stepPrecision(step);
|
|
27
|
+
|
|
28
|
+
const wrap = el("div", "tw-slider-wrap");
|
|
29
|
+
const track = el("div", "tw-slider");
|
|
30
|
+
const hashes = el("div", "tw-slider-hashmarks");
|
|
31
|
+
const fill = el("div", "tw-slider-fill");
|
|
32
|
+
const handle = el("div", "tw-slider-handle");
|
|
33
|
+
const labelEl = txt("span", "tw-slider-label", label);
|
|
34
|
+
const valueEl = el("span", "tw-slider-value");
|
|
35
|
+
// The readout is a text node plus a muted unit suffix; the param stays the bare number.
|
|
36
|
+
const unit = typeof meta.unit === "string" ? meta.unit.trim() : "", numText = document.createTextNode("");
|
|
37
|
+
const joined = !unit ? "" : /^[%°]$/.test(unit) ? unit : " " + unit; // "50%", "90°", but "24 px", "300 ms"
|
|
38
|
+
valueEl.append(numText); if (unit) valueEl.append(txt("span", "tw-slider-unit", joined));
|
|
39
|
+
// A format that throws or returns a non-string falls back to the digits, so it can't blank the slider.
|
|
40
|
+
const format = meta.format, parse = meta.parse;
|
|
41
|
+
const show = (v: number) => { if (format) { try { const s = format(v); if (typeof s === "string" && s) return s; } catch {} } return v.toFixed(decimals); };
|
|
42
|
+
track.append(hashes, fill, handle, labelEl, valueEl);
|
|
43
|
+
wrap.append(track);
|
|
44
|
+
|
|
45
|
+
track.tabIndex = 0;
|
|
46
|
+
track.setAttribute("role", "slider");
|
|
47
|
+
track.setAttribute("aria-label", label);
|
|
48
|
+
track.setAttribute("aria-valuemin", String(min));
|
|
49
|
+
track.setAttribute("aria-valuemax", String(max));
|
|
50
|
+
|
|
51
|
+
const q = (v: number) => roundToStep(v, min, step); // min/max sit on the grid, so a clamped value rounds inside
|
|
52
|
+
const marks = snap ? Array.from({ length: Math.max(0, Math.round((max - min) / step) - 1) }, (_, i) => ((i + 1) * step) / (max - min) * 100) : [];
|
|
53
|
+
for (const pct of marks) { const m = el("div", "tw-slider-hashmark"); m.style.left = pct + "%"; hashes.append(m); }
|
|
54
|
+
|
|
55
|
+
// value stays continuous; only the readout + emitted value round to step.
|
|
56
|
+
const render = () => {
|
|
57
|
+
const pct = clamp(((value - min) / ((max - min) || 1)) * 100, 0, 100); // clamp the visual; a soft readout shows its real number
|
|
58
|
+
const off = pull ? ` + ${pull.toFixed(1)}px` : "";
|
|
59
|
+
const edge = `${pct}%${off}`;
|
|
60
|
+
fill.style.width = pull ? `calc(${edge})` : pct + "%";
|
|
61
|
+
handle.style.left = `max(5px, calc(${edge} - 9px))`; // the inset hairline rides just inside the fill edge
|
|
62
|
+
const qv = q(value), qvText = show(qv);
|
|
63
|
+
numText.data = qvText;
|
|
64
|
+
track.setAttribute("aria-valuenow", String(qv));
|
|
65
|
+
track.setAttribute("aria-valuetext", qvText + joined);
|
|
66
|
+
// Value-dodge: the handle dims only while its real pixel span overlaps the label or value text.
|
|
67
|
+
const trackW = wrap.offsetWidth, valueW = valueEl.offsetWidth;
|
|
68
|
+
if (trackW) {
|
|
69
|
+
// The label's room is what the readout leaves: 12px inset + 8px gap + the readout's 10px inset.
|
|
70
|
+
if (valueW) labelEl.style.maxWidth = Math.max(0, trackW - valueW - 30) + "px";
|
|
71
|
+
const hx = Math.max(5, (pct / 100) * trackW + pull - 9);
|
|
72
|
+
track.classList.toggle("is-dodge", overlapsText(labelEl, valueEl, hx, 3));
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
render();
|
|
76
|
+
|
|
77
|
+
let rect: DOMRect | null = null, scale = 1, downPos: { x: number; y: number } | null = null, isClick = true, snapTimer: number, fineAnchor: { x: number; v: number } | null = null, downId: number | null = null;
|
|
78
|
+
const GLIDE_FILL = `width 0.34s ${EASE_GLIDE}`;
|
|
79
|
+
const GLIDE_HANDLE = `left 0.34s ${EASE_GLIDE}, opacity 0.15s, transform 0.2s ${EASE_SPRING}`;
|
|
80
|
+
// Discrete detent: the value commits at 30% of the gap and the handle springs to the notch,
|
|
81
|
+
// leaning toward the cursor. Driven by springFrame below, only while dragging or settling.
|
|
82
|
+
const FINE_GAIN = 0.2;
|
|
83
|
+
const valFromX = (clientX: number) => {
|
|
84
|
+
if (!rect) return value;
|
|
85
|
+
const native = wrap.offsetWidth || rect.width;
|
|
86
|
+
const pct = clamp((clientX - rect.left) / scale / native, 0, 1);
|
|
87
|
+
return clamp(min + pct * (max - min), min, max);
|
|
88
|
+
};
|
|
89
|
+
const set = (v: number | string, fire = true) => { v = +v; if (!Number.isFinite(v)) return; value = snap ? clamp(q(v), min, max) : (meta.soft ? v : clamp(v, min, max)); render(); if (fire) onChange(q(value)); }; // a non-finite value is ignored; soft may exceed the range
|
|
90
|
+
|
|
91
|
+
// ── Spring-commit detent (snap sliders) ──
|
|
92
|
+
// Physics in step-index space (notches at integers).
|
|
93
|
+
const STEPS = Math.max(1, Math.round((max - min) / step));
|
|
94
|
+
const C_COMMIT = 0.2, C_LEAN = 0.3, C_K = 520, C_D = 27; // C_COMMIT = 0.5 − 0.30 (commit fraction)
|
|
95
|
+
const toIdx = (v: number) => (v - min) / step;
|
|
96
|
+
let sx = 0, sv = 0, sRAF = 0, sDrag = false, sPrevEi: number | null = null, cursorVal = value, sPrevT = 0;
|
|
97
|
+
const springStop = () => { if (sRAF) cancelAnimationFrame(sRAF); sRAF = 0; sPrevT = 0; };
|
|
98
|
+
const springFrame = (now: number) => {
|
|
99
|
+
if (!track.isConnected) { springStop(); return; } // panel torn down mid-drag — bail
|
|
100
|
+
const dt = sPrevT ? Math.min((now - sPrevT) / 1000, 0.05) : 1 / 60; sPrevT = now;
|
|
101
|
+
const trackW = wrap.offsetWidth || (rect && rect.width) || 1;
|
|
102
|
+
const ui = toIdx(clamp(cursorVal, min, max));
|
|
103
|
+
const ei = clamp(Math.round(ui + C_COMMIT), 0, STEPS); // eager commit: 30% past the notch
|
|
104
|
+
const tx = sDrag ? ei + C_LEAN * clamp(ui - ei, -0.5, 0.5) : ei; // lean toward the cursor, then settle on the notch
|
|
105
|
+
// Reduced motion: the handle sits on its target every frame. The CSS kill-switch can't reach
|
|
106
|
+
// this, since the detent drives left/width from rAF, not a transition. Read live.
|
|
107
|
+
if (REDUCE_MOTION.matches) { sx = tx; sv = 0; }
|
|
108
|
+
else { let t = dt; const h = 1 / 240; while (t > 1e-4) { const s = Math.min(h, t); t -= s; const a = C_K * (tx - sx) - C_D * sv; sv += a * s; sx += sv * s; if (sx < 0) { sx = 0; sv = 0; } if (sx > STEPS) { sx = STEPS; sv = 0; } } }
|
|
109
|
+
pull = (sx - ei) * step / ((max - min) || 1) * trackW; // spring offset from the committed notch, in px
|
|
110
|
+
value = clamp(min + ei * step, min, max); render();
|
|
111
|
+
if (ei !== sPrevEi) { sPrevEi = ei; onChange(q(value)); } // emit once per commit, not per frame
|
|
112
|
+
if (!sDrag && Math.abs(sv) < 0.004 && Math.abs(sx - ei) < 0.004) { sx = ei; sv = 0; pull = 0; render(); springStop(); track.classList.remove("is-active"); }
|
|
113
|
+
else sRAF = requestAnimationFrame(springFrame);
|
|
114
|
+
};
|
|
115
|
+
const springStart = (clientX: number) => { sx = toIdx(value); sv = 0; sDrag = true; sPrevEi = Math.round(toIdx(value)); cursorVal = valFromX(clientX); if (!sRAF) sRAF = requestAnimationFrame(springFrame); };
|
|
116
|
+
|
|
117
|
+
track.addEventListener("pointerdown", (e) => {
|
|
118
|
+
if (valueEl.classList.contains("is-editing")) return;
|
|
119
|
+
if (e.button !== 0 || downPos) return; // primary button only; no second-pointer hijack
|
|
120
|
+
e.preventDefault();
|
|
121
|
+
// preventDefault suppressed click-to-focus; restore it without the keyboard ring (programmatic focus otherwise reads as keyboard).
|
|
122
|
+
track.focus({ focusVisible: false });
|
|
123
|
+
downId = e.pointerId;
|
|
124
|
+
try { (e.target as Element).setPointerCapture(e.pointerId); } catch {}
|
|
125
|
+
clearTimeout(snapTimer); track.style.transition = "";
|
|
126
|
+
downPos = { x: e.clientX, y: e.clientY }; isClick = true;
|
|
127
|
+
track.classList.add("is-active");
|
|
128
|
+
rect = wrap.getBoundingClientRect(); scale = rect.width / (wrap.offsetWidth || rect.width);
|
|
129
|
+
// Snap springs to the pressed notch; continuous glides there, the glide cleared on the first move so dragging stays 1:1.
|
|
130
|
+
if (snap) {
|
|
131
|
+
fill.style.transition = ""; handle.style.transition = "";
|
|
132
|
+
springStart(e.clientX);
|
|
133
|
+
} else {
|
|
134
|
+
fill.style.transition = GLIDE_FILL; handle.style.transition = GLIDE_HANDLE;
|
|
135
|
+
set(valFromX(e.clientX));
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
track.addEventListener("pointermove", (e) => {
|
|
139
|
+
if (!downPos || e.pointerId !== downId) return;
|
|
140
|
+
// Released outside the window: a captured pointerup never reaches us, so bail on the next move.
|
|
141
|
+
if (e.buttons === 0) { up(); return; }
|
|
142
|
+
if (isClick && Math.hypot(e.clientX - downPos.x, e.clientY - downPos.y) > CLICK_THRESHOLD) {
|
|
143
|
+
isClick = false; track.classList.add("is-dragging");
|
|
144
|
+
if (!snap) { fill.style.transition = ""; handle.style.transition = ""; }
|
|
145
|
+
}
|
|
146
|
+
if (!isClick) {
|
|
147
|
+
rubberBand(track, rect, e.clientX);
|
|
148
|
+
if (snap) {
|
|
149
|
+
cursorVal = valFromX(e.clientX); // read by the spring loop
|
|
150
|
+
} else {
|
|
151
|
+
// Alt = fine scrub: a low-gain relative drag, re-anchored when Alt engages.
|
|
152
|
+
let raw: number;
|
|
153
|
+
if (e.altKey) {
|
|
154
|
+
if (!fineAnchor) fineAnchor = { x: e.clientX, v: value };
|
|
155
|
+
const native = wrap.offsetWidth || rect.width;
|
|
156
|
+
raw = clamp(fineAnchor.v + ((e.clientX - fineAnchor.x) / native) * (max - min) * FINE_GAIN, min, max);
|
|
157
|
+
} else {
|
|
158
|
+
fineAnchor = null;
|
|
159
|
+
raw = valFromX(e.clientX);
|
|
160
|
+
}
|
|
161
|
+
set(raw);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
const up = (e?: PointerEvent) => {
|
|
166
|
+
if (!downPos || (e && e.pointerId !== downId)) return;
|
|
167
|
+
if (snap) {
|
|
168
|
+
// Let the spring settle; springFrame drops is-active once it lands.
|
|
169
|
+
sDrag = false; track.classList.remove("is-dragging");
|
|
170
|
+
if (!sRAF) sRAF = requestAnimationFrame(springFrame);
|
|
171
|
+
} else {
|
|
172
|
+
// Drop the press glide once it's home.
|
|
173
|
+
snapTimer = setTimeout(() => { fill.style.transition = ""; handle.style.transition = ""; }, 360);
|
|
174
|
+
track.classList.remove("is-active", "is-dragging");
|
|
175
|
+
}
|
|
176
|
+
rubberRelease(track);
|
|
177
|
+
downPos = null; fineAnchor = null; downId = null;
|
|
178
|
+
};
|
|
179
|
+
track.addEventListener("pointerup", up);
|
|
180
|
+
track.addEventListener("pointercancel", up);
|
|
181
|
+
track.addEventListener("lostpointercapture", up); // capture loss ends the drag like a release
|
|
182
|
+
wireHoverClass(track, render); // re-measure the dodge on first hover
|
|
183
|
+
// Re-measure the dodge once fonts settle (a web-font swap shifts the widths) and on resize.
|
|
184
|
+
onReady(render);
|
|
185
|
+
onLive(track, [[window, "resize"]], render); // self-cleans once the panel leaves the DOM
|
|
186
|
+
track.addEventListener("keydown", (e) => {
|
|
187
|
+
if (e.target !== track) return; // keys typed into the inline editor are its own
|
|
188
|
+
if (downPos) return; // keys don't steer a pointer drag
|
|
189
|
+
if (e.key === "Enter") { e.preventDefault(); return openEditor(); }
|
|
190
|
+
const nv = rangeStep(e, value, step, min, max, (max - min) / 10 || step * 10);
|
|
191
|
+
if (nv == null) return;
|
|
192
|
+
e.preventDefault();
|
|
193
|
+
if (snap) { springStop(); pull = 0; track.classList.remove("is-active"); } // instant step: cancel an in-flight settle + its styling
|
|
194
|
+
set(clamp(nv, min, max));
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// value: hover 800ms → editable; click → inline input
|
|
198
|
+
let hoverTimer: number;
|
|
199
|
+
valueEl.addEventListener("mouseenter", () => { hoverTimer = setTimeout(() => valueEl.classList.add("is-editable"), 800); });
|
|
200
|
+
valueEl.addEventListener("mouseleave", () => { clearTimeout(hoverTimer); if (!valueEl.classList.contains("is-editing")) valueEl.classList.remove("is-editable"); });
|
|
201
|
+
valueEl.addEventListener("pointerdown", (e) => {
|
|
202
|
+
// Touch/pen have no hover to arm the edit gate, so a tap on the readout arms it directly.
|
|
203
|
+
if (e.pointerType && e.pointerType !== "mouse") { valueEl.classList.add("is-editable"); e.stopPropagation(); }
|
|
204
|
+
else if (valueEl.classList.contains("is-editable")) e.stopPropagation();
|
|
205
|
+
});
|
|
206
|
+
// The inline editor, reached from the armed readout's click or Enter on the focused track. Enter
|
|
207
|
+
// commits and Escape cancels, both returning focus to the track; a blur commits and leaves focus where the user sent it.
|
|
208
|
+
const openEditor = () => {
|
|
209
|
+
if (valueEl.classList.contains("is-editing")) return;
|
|
210
|
+
const input = el("input", "tw-slider-input"); input.type = "text"; input.inputMode = parse ? "text" : "decimal"; quietFocus(input); /* click-to-type stays ringless; Enter from the track rings it */
|
|
211
|
+
input.value = parse ? show(q(value)) : q(value).toFixed(decimals); // with a parse, the field edits the formatted text
|
|
212
|
+
const opened = input.value; input.style.width = opened.length + 1 + "ch"; // read back: a text field drops line breaks
|
|
213
|
+
valueEl.classList.add("is-editing"); valueEl.replaceWith(input); input.focus(); input.select();
|
|
214
|
+
const restore = () => { input.replaceWith(valueEl); valueEl.classList.remove("is-editing", "is-editable"); };
|
|
215
|
+
// Typed text is read by `parse`, or else strictly as a whole number (not parseFloat, which reads
|
|
216
|
+
// "1,000" as 1). Only a finite number commits, and never text left as it opened: a format coarser
|
|
217
|
+
// than the step would move the value just by opening the editor.
|
|
218
|
+
const commit = () => {
|
|
219
|
+
const t = input.value; let p = NaN;
|
|
220
|
+
if (t !== opened) try { p = parse ? parse(t) : Number(t.replace(unit, "").trim() || NaN); } catch {}
|
|
221
|
+
if (Number.isFinite(p)) set(meta.soft ? p : clamp(p, min, max));
|
|
222
|
+
restore();
|
|
223
|
+
};
|
|
224
|
+
input.addEventListener("pointerdown", (e) => e.stopPropagation());
|
|
225
|
+
input.addEventListener("blur", commit);
|
|
226
|
+
// Focus moves to the track BEFORE the input leaves the DOM (blur-commit unhooked so it doesn't commit
|
|
227
|
+
// twice): removing a focused node first lands focus on <body>, and a focus() in the same task was dropped.
|
|
228
|
+
input.addEventListener("keydown", (e) => {
|
|
229
|
+
if (e.key === "Enter") { input.removeEventListener("blur", commit); track.focus(); commit(); }
|
|
230
|
+
else if (e.key === "Escape") { e.stopPropagation(); input.removeEventListener("blur", commit); track.focus(); restore(); } // an Escape here mustn't also close a hosting popover
|
|
231
|
+
});
|
|
232
|
+
};
|
|
233
|
+
valueEl.addEventListener("click", () => { if (valueEl.classList.contains("is-editable")) openEditor(); });
|
|
234
|
+
|
|
235
|
+
return { el: wrap, set: (v: number | string) => set(v, false), get: () => q(value), range: [min, max] };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function createToggle(meta: Meta, onChange: OnChange): Control {
|
|
239
|
+
const row = el("div", "tw-row");
|
|
240
|
+
// The segmented control IS the state; this maps boolean ↔ "on"/"off" at the rim.
|
|
241
|
+
const seg = createSegmented([{ value: "off", label: "Off" }, { value: "on", label: "On" }], meta.value ? "on" : "off", (v) => onChange(v === "on"), meta.label, "on");
|
|
242
|
+
row.append(txt("span", "tw-row-label", meta.label), seg.el);
|
|
243
|
+
return { el: row, set: (v: unknown) => seg.set(v ? "on" : "off"), get: () => seg.get() === "on" };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── Radio grid — a segmented control wrapped into a 2- or 3-column grid for a small set of short presets. ──
|
|
247
|
+
function createRadiogrid(meta: Meta, onChange: OnChange): Control {
|
|
248
|
+
const options: Option[] = meta.options || [];
|
|
249
|
+
const cols = Math.min(3, Math.max(2, meta.cols || (options.length <= 3 ? options.length : options.length === 4 ? 2 : 3)));
|
|
250
|
+
const row = el("div", options.length > cols ? "tw-radiogrid is-grid" : "tw-radiogrid"); // several rows keep equal columns
|
|
251
|
+
const grid = el("div", "tw-radiogrid-grid"); grid.style.setProperty("--tw-rg-cols", cols);
|
|
252
|
+
grid.setAttribute("role", "radiogroup"); grid.setAttribute("aria-label", meta.label);
|
|
253
|
+
let value = meta.value ?? optValue(options[0]);
|
|
254
|
+
const btns = options.map((o) => { const b = radioButton("tw-radiogrid-btn", o, (v) => set(v)); grid.append(b); return b; }); // lazy `set` — it's declared below
|
|
255
|
+
const reflect = () => setRadioActive(btns, value);
|
|
256
|
+
const set = (v: unknown, fire = true) => { if (v != null && v !== meta.value && !btns.some((b) => b._twVal === v)) return; value = v; reflect(); if (fire) onChange(v); }; // null clears the selection; an unknown value is ignored, bar the default written off the options, so reset() restores it
|
|
257
|
+
grid.addEventListener("keydown", (e) => {
|
|
258
|
+
const i = activeIndex(btns, value); if (i < 0) return;
|
|
259
|
+
const j = navIndex(e.key, i, btns.length, cols); if (j < 0) return;
|
|
260
|
+
e.preventDefault(); if (j !== i) { set(btns[j]._twVal); btns[j].focus(); } // _twVal, not dataset.value (which stringifies)
|
|
261
|
+
});
|
|
262
|
+
reflect();
|
|
263
|
+
row.append(txt("span", "tw-radiogrid-label", meta.label), grid);
|
|
264
|
+
return { el: row, set: (v: unknown) => set(v, false), get: () => value };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ── Select ──
|
|
268
|
+
// An option's color: one token, no nesting, so a `)` can't close it early and splice a url() into the background shorthand.
|
|
269
|
+
const COLOR_TOKEN = /^(?:#[0-9a-f]{3,8}|[a-z]+|(?:oklch|oklab|rgba?|hsla?|hwb|lab|lch|color|var)\([^()]*\))$/i;
|
|
270
|
+
const CHEVRON = chevronIcon("tw-select-chevron");
|
|
271
|
+
function createSelect(meta: Meta, onChange: OnChange): Control {
|
|
272
|
+
let value = meta.value;
|
|
273
|
+
const opts = meta.options.map((o) => ({ value: optValue(o), label: optLabel(o), color: o && typeof o === "object" && typeof o.color === "string" && COLOR_TOKEN.test(o.color.trim()) ? o.color.trim() : "" }));
|
|
274
|
+
const root = el("div", "tw-select");
|
|
275
|
+
const trigger = btn("tw-select-trigger"); trigger.setAttribute("aria-haspopup", "listbox"); trigger.setAttribute("aria-expanded", "false");
|
|
276
|
+
const right = el("span", "tw-select-right");
|
|
277
|
+
// Option swatches sit over a checker so alpha reads.
|
|
278
|
+
const swatchCss = (c: string) => `linear-gradient(${c}, ${c}), var(--tw-checker)`;
|
|
279
|
+
const swatch = (c: string) => { const s = el("span", "tw-option-swatch"); s.style.background = swatchCss(c); return s; };
|
|
280
|
+
const valSwatch = el("span", "tw-option-swatch"), valEl = el("span", "tw-select-value");
|
|
281
|
+
right.append(valSwatch, valEl);
|
|
282
|
+
right.insertAdjacentHTML("beforeend", CHEVRON);
|
|
283
|
+
trigger.append(txt("span", "tw-select-label", meta.label), right);
|
|
284
|
+
const dropdown = el("div", "tw-select-dropdown"); dropdown.setAttribute("role", "listbox");
|
|
285
|
+
const optButtons = opts.map((o) => {
|
|
286
|
+
const b = btn("tw-select-option"); b.setAttribute("role", "option"); if (o.color) b.append(swatch(o.color)); b.append(txt("span", "tw-option-label", o.label)); b.dataset.value = String(o.value);
|
|
287
|
+
b.addEventListener("click", () => { set(o.value); pop.close(); });
|
|
288
|
+
dropdown.append(b); return b;
|
|
289
|
+
});
|
|
290
|
+
root.append(trigger, dropdown);
|
|
291
|
+
const reflect = () => { const cur = opts.find((o) => o.value === value); valEl.textContent = cur?.label ?? value; valSwatch.style.display = cur?.color ? "" : "none"; if (cur?.color) valSwatch.style.background = swatchCss(cur.color); optButtons.forEach((b) => { const sel = b.dataset.value === String(value); b.dataset.selected = String(sel); b.setAttribute("aria-selected", String(sel)); }); }; // dataset stringifies, so compare as strings
|
|
292
|
+
const set = (v: unknown, fire = true) => { if (v != null && v !== meta.value && !opts.some((o) => o.value === v)) return; value = v; reflect(); if (fire) onChange(v); }; // null clears the selection; an unknown value is ignored, bar the default written off the options, so reset() restores it
|
|
293
|
+
// The shared popover shell: portaled to <body>, closes on outside-press / Esc / scroll-away,
|
|
294
|
+
// and opening it closes any other popover.
|
|
295
|
+
let typed = "", typedAt = 0; // the type-ahead buffer; a fresh open starts it over, as a native popup does
|
|
296
|
+
const pop = popover(root, trigger, dropdown, {
|
|
297
|
+
fallbackH: 200, movable: false, // a menu: it closes on a pick, so it never stays in the way
|
|
298
|
+
onOpen: () => { typed = ""; (optButtons.find((b) => b.dataset.value === String(value)) || optButtons[0])?.focus(); },
|
|
299
|
+
});
|
|
300
|
+
// Enter/Space on a focused option selects it natively via click; Tab away closes by the shell's focusout rule.
|
|
301
|
+
trigger.addEventListener("keydown", (e) => {
|
|
302
|
+
if (!pop.isOpen() && (e.key === "ArrowDown" || e.key === "ArrowUp")) { e.preventDefault(); pop.open(); }
|
|
303
|
+
});
|
|
304
|
+
dropdown.addEventListener("keydown", (e) => {
|
|
305
|
+
const i = optButtons.indexOf(document.activeElement as HTMLButtonElement); let j = i;
|
|
306
|
+
const inBeat = !!typed && performance.now() - typedAt < 600; // monotonic, so a wall-clock step can't pin the beat open
|
|
307
|
+
if (e.key === "ArrowDown") j = i < 0 ? 0 : Math.min(optButtons.length - 1, i + 1);
|
|
308
|
+
else if (e.key === "ArrowUp") j = i < 0 ? optButtons.length - 1 : Math.max(0, i - 1);
|
|
309
|
+
else if (e.key === "Home") j = 0;
|
|
310
|
+
else if (e.key === "End") j = optButtons.length - 1;
|
|
311
|
+
else if (e.key.length === 1 && (e.key !== " " || inBeat) && !e.ctrlKey && !e.metaKey && !e.altKey && !e.isComposing) {
|
|
312
|
+
// Type-ahead, as a native select: keys within a beat form a prefix that moves focus to the first label starting with it,
|
|
313
|
+
// the same key over and over cycles its matches, and no match leaves focus where it is. A space joins a prefix under way
|
|
314
|
+
// and is never also the option's activation; on its own it stays the option's.
|
|
315
|
+
const k = e.key.toLowerCase(); typed = (inBeat ? typed : "") + k; typedAt = performance.now();
|
|
316
|
+
const one = typed === k.repeat(typed.length / k.length), needle = one ? k : typed; // compared per key, since "İ" lowercases to two code units
|
|
317
|
+
const hit = (lo: number) => opts.findIndex((o, n) => n >= lo && String(o.label).toLowerCase().trimStart().startsWith(needle));
|
|
318
|
+
j = hit(one ? i + 1 : i); if (j < 0) j = hit(0); // a lone key moves past the focused option, a growing prefix re-tests it; the second pass wraps
|
|
319
|
+
if (j < 0 && e.key !== " ") return; // a prefix's space is swallowed even unmatched, so it never also picks
|
|
320
|
+
}
|
|
321
|
+
else return;
|
|
322
|
+
e.preventDefault(); optButtons[j]?.focus();
|
|
323
|
+
});
|
|
324
|
+
reflect();
|
|
325
|
+
return { el: root, set: (v: unknown) => set(v, false), get: () => value };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function createButton(meta: Meta) {
|
|
329
|
+
const b = txt("button", "tw-button", meta.label);
|
|
330
|
+
b.addEventListener("click", () => typeof meta.action === "function" && meta.action()); // a verbose form can carry a non-function action; as the group does, ignore it
|
|
331
|
+
return blade(b);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ── Button group — a row of compact actions under one label. `buttons` is { label: fn } or [{label, action}]. ──
|
|
335
|
+
function createButtonGroup(meta: Meta) {
|
|
336
|
+
const row = el("div", "tw-row tw-buttongroup");
|
|
337
|
+
if (meta.label) row.append(txt("span", "tw-row-label", meta.label));
|
|
338
|
+
const group = el("div", "tw-buttongroup-btns");
|
|
339
|
+
const list: [string, () => void][] = Array.isArray(meta.buttons) ? meta.buttons.map((b) => [b.label ?? "Button", b.action]) : Object.entries(meta.buttons || {}); // an entry with no label is still a button, not an empty pill
|
|
340
|
+
for (const [lab, fn] of list) {
|
|
341
|
+
const b = txt("button", "tw-buttongroup-btn", lab);
|
|
342
|
+
b.addEventListener("click", () => typeof fn === "function" && fn());
|
|
343
|
+
group.append(b);
|
|
344
|
+
}
|
|
345
|
+
row.append(group);
|
|
346
|
+
return blade(row);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ── Separator — a thin divider to break a long panel into sections. ──
|
|
350
|
+
const createSeparator = () => blade(el("div", "tw-separator"));
|
|
351
|
+
|
|
352
|
+
// ── String — a labeled text input ──
|
|
353
|
+
function createString(meta: Meta, onChange: OnChange): Control {
|
|
354
|
+
// A non-string default holds its string form so get() and reset() agree with the input.
|
|
355
|
+
const str = (v: unknown) => (v == null ? "" : typeof v === "object" ? json(v) : String(v));
|
|
356
|
+
let value = str(meta.value);
|
|
357
|
+
const multi = meta.rows > 0;
|
|
358
|
+
const row = el("div", multi ? "tw-row tw-row-multiline" : "tw-row");
|
|
359
|
+
const input = el(multi ? "textarea" : "input", multi ? "tw-text tw-textarea" : "tw-text");
|
|
360
|
+
if (multi) (input as HTMLTextAreaElement).rows = meta.rows; else (input as HTMLInputElement).type = "text";
|
|
361
|
+
input.value = value;
|
|
362
|
+
quietFocus(input); // click-to-type stays ringless; Tab rings
|
|
363
|
+
if (meta.placeholder) input.placeholder = meta.placeholder;
|
|
364
|
+
input.addEventListener("input", () => { value = input.value; onChange(value); });
|
|
365
|
+
// Long text clips softly: the edge with more past it fades (a mask, so the fade is the panel's own backdrop).
|
|
366
|
+
const clip = () => { const more = input.scrollHeight - input.clientHeight > 1; input.classList.toggle("is-clip-top", more && input.scrollTop > 1); input.classList.toggle("is-clip-bottom", more && input.scrollTop + input.clientHeight < input.scrollHeight - 1); };
|
|
367
|
+
if (multi) { input.addEventListener("scroll", clip); input.addEventListener("input", clip); if (typeof ResizeObserver === "function") new ResizeObserver(clip).observe(input); }
|
|
368
|
+
row.append(txt("span", "tw-row-label", meta.label), input);
|
|
369
|
+
return { el: row, set: (v: unknown) => { value = str(v); input.value = value; if (multi) clip(); }, get: () => value }; // null → "", not the literal "undefined"
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ── Folder — a collapsible titled group; `body` is the inner container the caller fills. ──
|
|
373
|
+
function createFolder(meta: { label: string }) {
|
|
374
|
+
const root = el("div", "tw-folder");
|
|
375
|
+
const header = btn("tw-folder-header"); header.setAttribute("aria-expanded", "true");
|
|
376
|
+
header.append(txt("span", "tw-folder-title", meta.label));
|
|
377
|
+
header.insertAdjacentHTML("beforeend", ICON_CHEVRON); // chevron trails the title
|
|
378
|
+
const body = el("div", "tw-folder-body");
|
|
379
|
+
const inner = el("div", "tw-controls"); body.append(inner);
|
|
380
|
+
root.append(header, body);
|
|
381
|
+
const collapse = (c: boolean) => foldBody(root, header, body, c); // the shared fold: class + aria-expanded + inert on the body
|
|
382
|
+
header.addEventListener("click", () => collapse(!root.classList.contains("is-collapsed")));
|
|
383
|
+
return { el: root, body: inner, setCollapsed: collapse };
|
|
384
|
+
}
|
|
385
|
+
/** Builds a control from the registry; a constructor that throws degrades to null so one bad control can't abort the panel. */
|
|
386
|
+
function createControl(meta: Meta, onChange: OnChange): Control | null {
|
|
387
|
+
const make = getControl(meta.type);
|
|
388
|
+
if (!make) return null;
|
|
389
|
+
try { return make(meta, onChange); }
|
|
390
|
+
catch (e) { console.error("[tweaks] control failed to build:", meta && meta.type, e); return null; }
|
|
391
|
+
}
|
|
392
|
+
// Core controls register synchronously; lazy ones register when imported.
|
|
393
|
+
registerControl("slider", createSlider);
|
|
394
|
+
registerControl("checkbox", createToggle);
|
|
395
|
+
registerControl("radiogrid", createRadiogrid);
|
|
396
|
+
registerControl("list", createSelect);
|
|
397
|
+
registerControl("button", createButton);
|
|
398
|
+
registerControl("buttongroup", createButtonGroup);
|
|
399
|
+
registerControl("separator", createSeparator);
|
|
400
|
+
registerControl("text", createString);
|
|
401
|
+
registerControl("folder", createFolder);
|
|
402
|
+
|
|
403
|
+
export { createFolder, createControl };
|