tweakit 0.6.0 → 0.7.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 +5 -5
- package/README.md +3 -3
- package/dist/tweaks/auto.js +1 -1
- package/dist/tweaks/auto.js.map +1 -1
- package/dist/tweaks/bezier-S2BOXQBM.js +2 -0
- package/dist/tweaks/bezier-S2BOXQBM.js.map +6 -0
- package/dist/tweaks/chunk-3MZAAMC2.js +2 -0
- package/dist/tweaks/chunk-3MZAAMC2.js.map +6 -0
- package/dist/tweaks/chunk-ETB35KFV.js +2 -0
- package/dist/tweaks/chunk-ETB35KFV.js.map +6 -0
- package/dist/tweaks/chunk-YB5GKWZI.js +2 -0
- package/dist/tweaks/chunk-YB5GKWZI.js.map +6 -0
- package/dist/tweaks/clip-A3VI53BK.js +2 -0
- package/dist/tweaks/clip-A3VI53BK.js.map +6 -0
- package/dist/tweaks/color-UGYMAF3W.js +2 -0
- package/dist/tweaks/core.js +1 -1
- package/dist/tweaks/core.js.map +3 -3
- package/dist/tweaks/fold-GY47HIQA.js +2 -0
- package/dist/tweaks/fold-GY47HIQA.js.map +6 -0
- package/dist/tweaks/gradient-BTX25WSH.js +2 -0
- package/dist/tweaks/gradient-BTX25WSH.js.map +6 -0
- package/dist/tweaks/{heavy-CLD7UAVI.js → heavy-NPFIGHRM.js} +2 -2
- package/dist/tweaks/image-HTQQ4WX6.js +4 -0
- package/dist/tweaks/image-HTQQ4WX6.js.map +6 -0
- package/dist/tweaks/{image-store-WRKY2NPU.js → image-store-QD5SMZXG.js} +1 -1
- package/dist/tweaks/image-store-QD5SMZXG.js.map +6 -0
- package/dist/tweaks/interval-BHPI4LRN.js +2 -0
- package/dist/tweaks/interval-BHPI4LRN.js.map +6 -0
- package/dist/tweaks/markup-QM53TQJZ.js +2 -0
- package/dist/tweaks/markup-QM53TQJZ.js.map +6 -0
- package/dist/tweaks/monitor-722WIYGW.js +3 -0
- package/dist/tweaks/monitor-722WIYGW.js.map +6 -0
- package/dist/tweaks/motion-HMCZK6HJ.js +2 -0
- package/dist/tweaks/motion-HMCZK6HJ.js.map +6 -0
- package/dist/tweaks/plot-D3NQ6XNN.js +3 -0
- package/dist/tweaks/plot-D3NQ6XNN.js.map +6 -0
- package/dist/tweaks/point-XDXKMDXH.js +2 -0
- package/dist/tweaks/point-XDXKMDXH.js.map +6 -0
- package/dist/tweaks/presets-EUWNSCUW.js +2 -0
- package/dist/tweaks/presets-EUWNSCUW.js.map +6 -0
- package/dist/tweaks/shadow-AM7VSRNS.js +2 -0
- package/dist/tweaks/shadow-AM7VSRNS.js.map +6 -0
- package/dist/tweaks/shadow-root-RYUV3AGS.js +3 -0
- package/dist/tweaks/shadow-root-RYUV3AGS.js.map +6 -0
- package/dist/tweaks/sides-JWIURDA4.js +2 -0
- package/dist/tweaks/sides-JWIURDA4.js.map +6 -0
- package/dist/tweaks/spring-BF5GBTPM.js +2 -0
- package/dist/tweaks/spring-BF5GBTPM.js.map +6 -0
- package/dist/tweaks/tabs-PRVUUZXZ.js +2 -0
- package/dist/tweaks/tabs-PRVUUZXZ.js.map +6 -0
- package/dist/tweaks.css +1 -1
- package/dist/tweaks.js +5 -4
- package/dist/tweaks.js.map +3 -3
- package/dist/types/tweaks/enhance.d.ts +3 -2
- package/dist/types/tweaks/types.d.ts +278 -72
- package/package.json +5 -2
- package/src/tweaks/auto.ts +9 -5
- package/src/tweaks/clip.ts +84 -22
- package/src/tweaks/controls/basic.ts +463 -139
- package/src/tweaks/controls/bezier.ts +152 -47
- package/src/tweaks/controls/color.ts +610 -161
- package/src/tweaks/controls/gradient.ts +256 -77
- package/src/tweaks/controls/image.ts +388 -132
- package/src/tweaks/controls/interval.ts +126 -41
- package/src/tweaks/controls/monitor.ts +142 -36
- package/src/tweaks/controls/motion.ts +231 -56
- package/src/tweaks/controls/plot.ts +321 -101
- package/src/tweaks/controls/point.ts +134 -45
- package/src/tweaks/controls/shadow.ts +103 -30
- package/src/tweaks/controls/sides.ts +150 -40
- package/src/tweaks/controls/spring.ts +220 -60
- package/src/tweaks/controls/tabs.ts +39 -18
- package/src/tweaks/core.ts +1 -1
- package/src/tweaks/easing.ts +45 -20
- package/src/tweaks/enhance.ts +21 -6
- package/src/tweaks/feedback.ts +133 -34
- package/src/tweaks/fold.ts +22 -10
- package/src/tweaks/heavy.ts +461 -137
- package/src/tweaks/icons.ts +12 -5
- package/src/tweaks/image-store.ts +131 -43
- package/src/tweaks/lazy.ts +36 -19
- package/src/tweaks/markup.ts +255 -92
- package/src/tweaks/panel.ts +1262 -438
- package/src/tweaks/presets.ts +85 -0
- package/src/tweaks/schema.ts +213 -51
- package/src/tweaks/shadow-root.ts +178 -0
- package/src/tweaks/shared.ts +802 -190
- package/src/tweaks/standalone.ts +120 -30
- package/src/tweaks/types.ts +644 -143
- package/src/tweaks/undo.ts +104 -0
- package/src/tweaks.css +556 -550
- package/src/wide-gamut.ts +473 -125
- package/dist/tweaks/bezier-JFEZMCLP.js +0 -2
- package/dist/tweaks/bezier-JFEZMCLP.js.map +0 -6
- package/dist/tweaks/chunk-6UO4DHBN.js +0 -2
- package/dist/tweaks/chunk-6UO4DHBN.js.map +0 -6
- package/dist/tweaks/chunk-DIQMLBAS.js +0 -2
- package/dist/tweaks/chunk-DIQMLBAS.js.map +0 -6
- package/dist/tweaks/chunk-XE6OUJBG.js +0 -2
- package/dist/tweaks/chunk-XE6OUJBG.js.map +0 -6
- package/dist/tweaks/clip-WW7JYO4X.js +0 -2
- package/dist/tweaks/clip-WW7JYO4X.js.map +0 -6
- package/dist/tweaks/color-YMMFURP6.js +0 -2
- package/dist/tweaks/fold-U6YRZLCJ.js +0 -2
- package/dist/tweaks/fold-U6YRZLCJ.js.map +0 -6
- package/dist/tweaks/gradient-JER7PW4D.js +0 -2
- package/dist/tweaks/gradient-JER7PW4D.js.map +0 -6
- package/dist/tweaks/image-H6QSLGO3.js +0 -4
- package/dist/tweaks/image-H6QSLGO3.js.map +0 -6
- package/dist/tweaks/image-store-WRKY2NPU.js.map +0 -6
- package/dist/tweaks/interval-TEUHQGZN.js +0 -2
- package/dist/tweaks/interval-TEUHQGZN.js.map +0 -6
- package/dist/tweaks/markup-NIBJD6E2.js +0 -2
- package/dist/tweaks/markup-NIBJD6E2.js.map +0 -6
- package/dist/tweaks/monitor-3BY7YCGX.js +0 -3
- package/dist/tweaks/monitor-3BY7YCGX.js.map +0 -6
- package/dist/tweaks/motion-FQPUBKKM.js +0 -2
- package/dist/tweaks/motion-FQPUBKKM.js.map +0 -6
- package/dist/tweaks/plot-2HBNBZSG.js +0 -3
- package/dist/tweaks/plot-2HBNBZSG.js.map +0 -6
- package/dist/tweaks/point-VV6XV3I6.js +0 -2
- package/dist/tweaks/point-VV6XV3I6.js.map +0 -6
- package/dist/tweaks/shadow-5E4E55I2.js +0 -2
- package/dist/tweaks/shadow-5E4E55I2.js.map +0 -6
- package/dist/tweaks/sides-P3STEVYY.js +0 -2
- package/dist/tweaks/sides-P3STEVYY.js.map +0 -6
- package/dist/tweaks/spring-HUVSXNVN.js +0 -2
- package/dist/tweaks/spring-HUVSXNVN.js.map +0 -6
- package/dist/tweaks/tabs-76FW7LQ7.js +0 -2
- package/dist/tweaks/tabs-76FW7LQ7.js.map +0 -6
- /package/dist/tweaks/{color-YMMFURP6.js.map → color-UGYMAF3W.js.map} +0 -0
- /package/dist/tweaks/{heavy-CLD7UAVI.js.map → heavy-NPFIGHRM.js.map} +0 -0
|
@@ -3,45 +3,66 @@ import { el, txt, onReady, measurePill, navIndex, requestReflow, registerControl
|
|
|
3
3
|
import type { Meta } from "../schema.js";
|
|
4
4
|
|
|
5
5
|
/** The tabs handle: a page body per tab for build() to fill, and the active tab for the panel's UI state. */
|
|
6
|
-
export interface TabsControl {
|
|
6
|
+
export interface TabsControl {
|
|
7
|
+
el: HTMLDivElement;
|
|
8
|
+
bodies: HTMLDivElement[];
|
|
9
|
+
activate(i: number): void;
|
|
10
|
+
active(): number;
|
|
11
|
+
}
|
|
7
12
|
|
|
8
13
|
let tabsSeq = 0; // unique ids for the tab ↔ tabpanel aria pairing
|
|
9
14
|
function createTabs(meta: Meta): TabsControl {
|
|
10
|
-
const uid = `
|
|
11
|
-
const root = el("div", "
|
|
12
|
-
const bar = el("div", "
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
+
const uid = `twk-tabs-${++tabsSeq}`;
|
|
16
|
+
const root = el("div", "twk-tabs");
|
|
17
|
+
const bar = el("div", "twk-tabs-bar");
|
|
18
|
+
bar.setAttribute("role", "tablist");
|
|
19
|
+
const pill = el("div", "twk-tabs-pill");
|
|
20
|
+
bar.append(pill);
|
|
21
|
+
const pagesWrap = el("div", "twk-tabs-pages");
|
|
15
22
|
const bodies: HTMLDivElement[] = [];
|
|
16
23
|
const tabs = meta.pages.map((page, i) => {
|
|
17
|
-
const tab = txt("button", "
|
|
18
|
-
tab.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
24
|
+
const tab = txt("button", "twk-tabs-tab", page.title);
|
|
25
|
+
tab.setAttribute("role", "tab");
|
|
26
|
+
tab.id = `${uid}-tab-${i}`;
|
|
27
|
+
tab.setAttribute("aria-controls", `${uid}-page-${i}`);
|
|
28
|
+
const body = el("div", "twk-tabs-page twk-controls");
|
|
29
|
+
body.setAttribute("role", "tabpanel");
|
|
30
|
+
body.id = `${uid}-page-${i}`;
|
|
31
|
+
body.setAttribute("aria-labelledby", tab.id);
|
|
32
|
+
bodies.push(body);
|
|
33
|
+
pagesWrap.append(body);
|
|
22
34
|
tab.addEventListener("click", () => activate(i));
|
|
23
|
-
bar.append(tab);
|
|
35
|
+
bar.append(tab);
|
|
36
|
+
return tab;
|
|
24
37
|
});
|
|
25
38
|
root.append(bar, pagesWrap);
|
|
26
39
|
const measure = (animate?: boolean) => measurePill(bar, pill, animate); // slide the pill to the active tab
|
|
27
40
|
// The active tab and page; the roving tabindex is set from the build, not only after a switch.
|
|
28
41
|
const mark = (i: number) => {
|
|
29
|
-
tabs.forEach((b, k) => {
|
|
42
|
+
tabs.forEach((b, k) => {
|
|
43
|
+
b.dataset.active = String(k === i);
|
|
44
|
+
b.setAttribute("aria-selected", String(k === i));
|
|
45
|
+
b.tabIndex = k === i ? 0 : -1;
|
|
46
|
+
});
|
|
30
47
|
bodies.forEach((b, k) => (b.dataset.active = String(k === i)));
|
|
31
48
|
};
|
|
32
49
|
mark(0);
|
|
33
50
|
function activate(i: number) {
|
|
34
|
-
mark(i);
|
|
51
|
+
mark(i);
|
|
52
|
+
measure(true);
|
|
35
53
|
requestReflow(); // controls built on a display:none page measured 0; let them re-measure
|
|
36
54
|
}
|
|
37
55
|
bar.addEventListener("keydown", (e) => {
|
|
38
|
-
const i = tabs.findIndex((b) => b.dataset.active === "true");
|
|
39
|
-
|
|
40
|
-
e.
|
|
56
|
+
const i = tabs.findIndex((b) => b.dataset.active === "true");
|
|
57
|
+
if (i < 0) return;
|
|
58
|
+
const j = navIndex(e.key, i, tabs.length, -1);
|
|
59
|
+
if (j < 0) return; // cols −1: ↑/↓ stay the page's scroll keys
|
|
60
|
+
e.preventDefault();
|
|
61
|
+
activate(j);
|
|
62
|
+
tabs[j].focus();
|
|
41
63
|
});
|
|
42
64
|
onReady(measure);
|
|
43
65
|
return { el: root, bodies, activate, active: () => tabs.findIndex((t) => t.dataset.active === "true") };
|
|
44
66
|
}
|
|
45
67
|
|
|
46
68
|
registerControl("tabs", createTabs);
|
|
47
|
-
|
package/src/tweaks/core.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/* Tweaks — a dependency-free real-time parameter panel. The public entry: tweaks() builds a
|
|
2
|
-
* live panel from a plain schema object, enhance() turns [data-
|
|
2
|
+
* live panel from a plain schema object, enhance() turns [data-twk] markup into live controls,
|
|
3
3
|
* and mountControl() / createColorPicker() / gradientCss() are the controls without the panel. */
|
|
4
4
|
export { tweaks } from "./panel.js";
|
|
5
5
|
export { enhance } from "./enhance.js";
|
package/src/tweaks/easing.ts
CHANGED
|
@@ -9,9 +9,13 @@ import type { GradientEasing, GradientStop, GradientValue } from "./types.js";
|
|
|
9
9
|
/** cubic-bezier control points [x1, y1, x2, y2]. */
|
|
10
10
|
export type Bez = [number, number, number, number];
|
|
11
11
|
|
|
12
|
-
// The CSS easing keywords as cubic-beziers. Null-prototype, so "constructor" can't read a function off
|
|
12
|
+
// The CSS easing keywords as cubic-beziers. Null-prototype, so "constructor" can't read a function off
|
|
13
|
+
// Object.prototype.
|
|
13
14
|
export const KEYWORDS: Record<string, Bez> = Object.assign(Object.create(null), {
|
|
14
|
-
ease: [0.25, 0.1, 0.25, 1],
|
|
15
|
+
ease: [0.25, 0.1, 0.25, 1],
|
|
16
|
+
"ease-in": [0.42, 0, 1, 1],
|
|
17
|
+
"ease-out": [0, 0, 0.58, 1],
|
|
18
|
+
"ease-in-out": [0.42, 0, 0.58, 1],
|
|
15
19
|
});
|
|
16
20
|
const clamp01 = (n: number) => Math.min(1, Math.max(0, n));
|
|
17
21
|
const SAMPLES = 15; // 16 per segment, Larsen's count; the joins vanish
|
|
@@ -22,7 +26,8 @@ export function parseEasing(e: unknown): Bez | null {
|
|
|
22
26
|
if (typeof e !== "string") return null;
|
|
23
27
|
const s = e.trim().toLowerCase();
|
|
24
28
|
if (KEYWORDS[s]) return KEYWORDS[s];
|
|
25
|
-
const m = /^cubic-bezier\(([^()]*)\)$/.exec(s);
|
|
29
|
+
const m = /^cubic-bezier\(([^()]*)\)$/.exec(s);
|
|
30
|
+
if (!m) return null;
|
|
26
31
|
const n = m[1].split(",").map((x) => (x.trim() ? Number(x) : NaN)); // an empty field is NaN, not Number("") → 0
|
|
27
32
|
if (n.length !== 4 || !n.every(Number.isFinite)) return null;
|
|
28
33
|
return [clamp01(n[0]), n[1], clamp01(n[2]), n[3]];
|
|
@@ -32,7 +37,11 @@ export function parseEasing(e: unknown): Bez | null {
|
|
|
32
37
|
export function easingSamples(b: Bez): Array<[number, number]> {
|
|
33
38
|
const out: Array<[number, number]> = [];
|
|
34
39
|
for (let k = 0; k <= SAMPLES; k++) {
|
|
35
|
-
const t = k / SAMPLES,
|
|
40
|
+
const t = k / SAMPLES,
|
|
41
|
+
u = 1 - t,
|
|
42
|
+
p1 = 3 * u * u * t,
|
|
43
|
+
p2 = 3 * u * t * t,
|
|
44
|
+
p3 = t * t * t;
|
|
36
45
|
out.push([p1 * b[0] + p2 * b[2] + p3, clamp01(p1 * b[1] + p2 * b[3] + p3)]);
|
|
37
46
|
}
|
|
38
47
|
return out;
|
|
@@ -40,22 +49,31 @@ export function easingSamples(b: Bez): Array<[number, number]> {
|
|
|
40
49
|
|
|
41
50
|
const pct = (n: number) => +(n * 100).toFixed(2);
|
|
42
51
|
|
|
43
|
-
// A stop's color is written into the gradient as it is, so it must be one color token: a comma or a stray ")" splices
|
|
44
|
-
// another layer (a url() the browser fetches), and var() can stand for anything. DOM-free, so by shape: a hex, a
|
|
45
|
-
// or one function with no nested parens; the editor's picker checks the grammar itself.
|
|
46
|
-
const STOP_COLOR =
|
|
52
|
+
// A stop's color is written into the gradient as it is, so it must be one color token: a comma or a stray ")" splices
|
|
53
|
+
// in another layer (a url() the browser fetches), and var() can stand for anything. DOM-free, so by shape: a hex, a
|
|
54
|
+
// keyword, or one function with no nested parens; the editor's picker checks the grammar itself.
|
|
55
|
+
const STOP_COLOR =
|
|
56
|
+
/^(?:#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})|(?!(?:url|var)\()[a-z][a-z-]*(?:\([\w\s.,%+\-\/]*\))?)$/i;
|
|
47
57
|
// The blend spaces CSS takes, and a hue method on the polar ones; anything else (`in junk`, `in 5`) falls to
|
|
48
58
|
// oklch, the default.
|
|
49
|
-
const INTERP =
|
|
50
|
-
|
|
59
|
+
const INTERP =
|
|
60
|
+
/^(?:(?:hsl|hwb|lch|oklch)(?: (?:shorter|longer|increasing|decreasing) hue)?|srgb(?:-linear)?|oklab|lab|display-p3|rec2020|a98-rgb|prophoto-rgb|xyz(?:-d50|-d65)?)$/i;
|
|
61
|
+
const blend = (v: GradientValue) =>
|
|
62
|
+
typeof v.interpolation === "string" && INTERP.test(v.interpolation) ? v.interpolation : "oklch";
|
|
51
63
|
|
|
52
64
|
/** The stops a value can draw, sorted. A hand-built value can hold no stops, or stops without a
|
|
53
65
|
* color or a numeric pos, and any of those builds CSS the browser drops whole, silently;
|
|
54
66
|
* so they're set aside with a warning (heavy.ts badGradient, one per distinct fault). */
|
|
55
67
|
function drawable(value: unknown, fn: string): GradientStop[] {
|
|
56
68
|
const all: unknown = value && typeof value === "object" ? (value as GradientValue).stops : null;
|
|
57
|
-
const ok = Array.isArray(all)
|
|
58
|
-
|
|
69
|
+
const ok = Array.isArray(all)
|
|
70
|
+
? all.filter((s) => s && typeof s.color === "string" && STOP_COLOR.test(s.color.trim()) && Number.isFinite(s.pos))
|
|
71
|
+
: [];
|
|
72
|
+
if (!ok.length || ok.length < (all as unknown[]).length)
|
|
73
|
+
import("./heavy.js").then(
|
|
74
|
+
(m) => m.badGradient(fn, value, all, ok.length),
|
|
75
|
+
() => {},
|
|
76
|
+
);
|
|
59
77
|
return ok.sort((a, b) => a.pos - b.pos);
|
|
60
78
|
}
|
|
61
79
|
|
|
@@ -68,18 +86,23 @@ export function gradientStops(value: GradientValue): string {
|
|
|
68
86
|
|
|
69
87
|
function stopList(value: GradientValue, stops: GradientStop[]): string {
|
|
70
88
|
const plain = (s: GradientStop) => `${s.color} ${pct(s.pos)}%`;
|
|
71
|
-
|
|
89
|
+
// one stop isn't a stop list; doubled, it's a solid fill
|
|
90
|
+
if (stops.length < 2) return `${stops[0].color}, ${stops[0].color}`;
|
|
72
91
|
const bez = parseEasing(value.easing);
|
|
73
92
|
if (!bez) return stops.map(plain).join(", ");
|
|
74
|
-
const samples = easingSamples(bez),
|
|
93
|
+
const samples = easingSamples(bez),
|
|
94
|
+
space = blend(value);
|
|
75
95
|
const out = [plain(stops[0])];
|
|
76
96
|
for (let i = 1; i < stops.length; i++) {
|
|
77
|
-
const a = stops[i - 1],
|
|
97
|
+
const a = stops[i - 1],
|
|
98
|
+
b = stops[i],
|
|
99
|
+
span = b.pos - a.pos;
|
|
78
100
|
// Two stops at one position are a hard edge: no samples between.
|
|
79
|
-
if (span > 1e-6)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
101
|
+
if (span > 1e-6)
|
|
102
|
+
for (let k = 1; k < SAMPLES; k++) {
|
|
103
|
+
const [x, y] = samples[k];
|
|
104
|
+
out.push(`color-mix(in ${space}, ${a.color}, ${b.color} ${pct(y)}%) ${pct(a.pos + span * x)}%`);
|
|
105
|
+
}
|
|
83
106
|
out.push(plain(b));
|
|
84
107
|
}
|
|
85
108
|
return out.join(", ");
|
|
@@ -89,5 +112,7 @@ function stopList(value: GradientValue, stops: GradientStop[]): string {
|
|
|
89
112
|
* One stop draws a solid fill and no stops is `none`, both still valid for background-image. */
|
|
90
113
|
export function gradientCss(value: GradientValue, direction: number | string = "to right"): string {
|
|
91
114
|
const stops = drawable(value, "gradientCss");
|
|
92
|
-
return stops.length
|
|
115
|
+
return stops.length
|
|
116
|
+
? `linear-gradient(in ${blend(value)} ${typeof direction === "number" ? `${direction}deg` : direction}, ${stopList(value, stops)})`
|
|
117
|
+
: "none";
|
|
93
118
|
}
|
package/src/tweaks/enhance.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
/* Markup-driven enhancement, core's side: enhance(). The work lives in markup.ts, a lazy chunk of the code-split build
|
|
1
|
+
/* Markup-driven enhancement, core's side: enhance(). The work lives in markup.ts, a lazy chunk of the code-split build
|
|
2
|
+
* that
|
|
2
3
|
* only a page with markup fetches; core lends it what it shares with the panel, as a kit. Importing core binds nothing:
|
|
3
4
|
* the page scan on load is opt-in, in auto.ts (tweakit/auto), so the package can declare core free of side effects. */
|
|
4
5
|
import { getMarkup } from "./shared.js";
|
|
@@ -7,14 +8,28 @@ import { ensureForMetas, LAZY_IMPORT } from "./lazy.js";
|
|
|
7
8
|
import { createFolder, createControl } from "./controls/basic.js";
|
|
8
9
|
import { makeCopyBtn, makeResetBtn, addHintMarker } from "./feedback.js";
|
|
9
10
|
import type { MarkupKit } from "./markup.js";
|
|
11
|
+
import type { EnhanceOptions } from "./types.js";
|
|
10
12
|
|
|
11
13
|
declare const TW_SPLIT: boolean;
|
|
12
|
-
const kit: MarkupKit = {
|
|
14
|
+
const kit: MarkupKit = {
|
|
15
|
+
metaFor,
|
|
16
|
+
ensureForMetas,
|
|
17
|
+
LAZY_IMPORT,
|
|
18
|
+
createFolder,
|
|
19
|
+
createControl,
|
|
20
|
+
makeCopyBtn,
|
|
21
|
+
makeResetBtn,
|
|
22
|
+
addHintMarker,
|
|
23
|
+
};
|
|
13
24
|
|
|
14
|
-
/** Bind every unbound [data-
|
|
15
|
-
export function enhance(root: Document | Element = document): Promise<void> {
|
|
25
|
+
/** Bind every unbound [data-twk] host under `root` (the document by default). */
|
|
26
|
+
export function enhance(root: Document | Element = document, opts: EnhanceOptions = {}): Promise<void> {
|
|
16
27
|
const run = getMarkup<MarkupKit>();
|
|
17
|
-
|
|
28
|
+
// the single-file build registered it on load: synchronous, as ever
|
|
29
|
+
if (run || !TW_SPLIT) return run ? run(root, kit, opts.strings) : Promise.resolve();
|
|
18
30
|
// A chunk that fails to load skips the markup, as a failed control chunk skips its control, instead of rejecting.
|
|
19
|
-
return import("./markup.js").then(
|
|
31
|
+
return import("./markup.js").then(
|
|
32
|
+
() => getMarkup<MarkupKit>()!(root, kit, opts.strings),
|
|
33
|
+
(e) => console.error("[tweaks] markup chunk failed to load:", e),
|
|
34
|
+
);
|
|
20
35
|
}
|
package/src/tweaks/feedback.ts
CHANGED
|
@@ -1,32 +1,63 @@
|
|
|
1
1
|
/* Panel feedback chrome — the toolbar button factories (copy / reset and their one-shot
|
|
2
2
|
* animations), the hint tooltip and the clipboard helper. The screen-reader announcer they
|
|
3
3
|
* speak through lives in shared.ts, where the lazy controls reach it too. */
|
|
4
|
-
import { el, btn, carrySkin, placeBelow, announce } from "./shared.js";
|
|
4
|
+
import { el, btn, carrySkin, placeBelow, announce, portalTo, words } from "./shared.js";
|
|
5
|
+
import type { Tr } from "./shared.js";
|
|
5
6
|
import { ICON_COPY, ICON_CHECK, ICON_RESET, ICON_INFO } from "./icons.js";
|
|
6
7
|
|
|
7
8
|
/** A toolbar button with its one-shot animation state: timer, spin settle hook, last click time. */
|
|
8
|
-
interface ToolbarBtn extends HTMLButtonElement {
|
|
9
|
+
interface ToolbarBtn extends HTMLButtonElement {
|
|
10
|
+
_t?: number;
|
|
11
|
+
_spinSettle?: () => void;
|
|
12
|
+
_spinT?: number;
|
|
13
|
+
}
|
|
9
14
|
|
|
10
15
|
/** An icon toolbar button with matching title and aria-label. */
|
|
11
|
-
const toolbarBtn = (cls: string, icon: string, label: string): ToolbarBtn => {
|
|
16
|
+
const toolbarBtn = (cls: string, icon: string, label: string): ToolbarBtn => {
|
|
17
|
+
const b = btn("twk-toolbar-btn" + (cls ? " " + cls : ""), icon);
|
|
18
|
+
b.title = label;
|
|
19
|
+
b.setAttribute("aria-label", label);
|
|
20
|
+
return b;
|
|
21
|
+
};
|
|
12
22
|
/** Copy: puts `text(changedOnly)` on the clipboard and confirms with the check swap, and to a screen reader in words.
|
|
13
23
|
* `text` returns the payload and a count (-1 for the full snapshot; 0 copies nothing).
|
|
14
24
|
* Both handlers bail while `disabled`: a synthetic dispatchEvent(click) reaches a disabled button. */
|
|
15
|
-
const makeCopyBtn = (text: (changedOnly: boolean) => [string, number]) => {
|
|
16
|
-
const b = toolbarBtn(
|
|
25
|
+
const makeCopyBtn = (text: (changedOnly: boolean) => [string, number], tr: Tr = words()) => {
|
|
26
|
+
const b = toolbarBtn(
|
|
27
|
+
"twk-toolbar-btn--swap",
|
|
28
|
+
`<span class="twk-toolbar-btn__icons">${ICON_COPY}${ICON_CHECK}</span>`,
|
|
29
|
+
tr("copy", "Copy values (⇧: changed only)"),
|
|
30
|
+
);
|
|
17
31
|
b.addEventListener("click", async (e) => {
|
|
18
32
|
if (b.disabled) return;
|
|
19
33
|
const [t, n] = text(e.shiftKey);
|
|
20
|
-
if (!n) return announce("Nothing has changed");
|
|
21
|
-
if (!(await copyText(t))) return announce("Copy failed");
|
|
22
|
-
flashCopied(b);
|
|
34
|
+
if (!n) return announce(tr("nothingChanged", "Nothing has changed"));
|
|
35
|
+
if (!(await copyText(t))) return announce(tr("copyFailed", "Copy failed"));
|
|
36
|
+
flashCopied(b);
|
|
37
|
+
announce(
|
|
38
|
+
n > 0
|
|
39
|
+
? tr("copiedChanged", (v) => `${v.n} changed value${v.n === 1 ? "" : "s"} copied`, { n })
|
|
40
|
+
: tr("copied", "Values copied"),
|
|
41
|
+
);
|
|
23
42
|
});
|
|
24
43
|
return b;
|
|
25
44
|
};
|
|
26
45
|
/** Reset: runs `onReset` behind the icon's one-shot spin. */
|
|
27
|
-
const makeResetBtn = (onReset: () => void
|
|
28
|
-
const
|
|
29
|
-
|
|
46
|
+
const makeResetBtn = (onReset: () => void, tr: Tr = words()) => {
|
|
47
|
+
const b = toolbarBtn("twk-toolbar-btn--reset", ICON_RESET, tr("reset", "Reset"));
|
|
48
|
+
b.addEventListener("click", () => {
|
|
49
|
+
if (b.disabled) return;
|
|
50
|
+
spinReset(b);
|
|
51
|
+
onReset();
|
|
52
|
+
});
|
|
53
|
+
return b;
|
|
54
|
+
};
|
|
55
|
+
const flashCopied = (btn: ToolbarBtn) => {
|
|
56
|
+
btn.classList.add("is-copied");
|
|
57
|
+
clearTimeout(btn._t);
|
|
58
|
+
btn._t = setTimeout(() => btn.classList.remove("is-copied"), 1400);
|
|
59
|
+
};
|
|
60
|
+
/** Reset spin — an accumulated rotation on --twk-spin driven by the transform transition, so a
|
|
30
61
|
* second click mid-spin retargets smoothly instead of snapping back. Once settled the counter
|
|
31
62
|
* renormalizes to 0 with the transition suppressed (−n·360° is the same angle). "Settled" is the
|
|
32
63
|
* transform's own transitionend, not a timer: the hover wind-up shares the transform and restarts
|
|
@@ -38,56 +69,96 @@ const spinReset = (btn: ToolbarBtn) => {
|
|
|
38
69
|
btn._spinSettle = () => {
|
|
39
70
|
clearTimeout(btn._t);
|
|
40
71
|
btn.classList.remove("is-spinning");
|
|
41
|
-
svg.style.transition = "none";
|
|
72
|
+
svg.style.transition = "none";
|
|
73
|
+
svg.style.setProperty("--twk-spin", "0deg");
|
|
42
74
|
// getBoundingClientRect, not offsetWidth: on an SVG element offsetWidth is undefined, so the
|
|
43
75
|
// reflow idiom flushed nothing and the −n·360° → 0 change animated as a visible unwind.
|
|
44
76
|
void svg.getBoundingClientRect();
|
|
45
77
|
svg.style.transition = "";
|
|
46
78
|
};
|
|
47
79
|
svg.addEventListener("transitionend", (e) => {
|
|
48
|
-
if (
|
|
80
|
+
if (
|
|
81
|
+
e.propertyName === "transform" &&
|
|
82
|
+
btn.classList.contains("is-spinning") &&
|
|
83
|
+
performance.now() - btn._spinT > 250
|
|
84
|
+
)
|
|
85
|
+
btn._spinSettle();
|
|
49
86
|
});
|
|
50
87
|
}
|
|
51
88
|
btn._spinT = performance.now();
|
|
52
|
-
svg.style.setProperty("--
|
|
89
|
+
svg.style.setProperty("--twk-spin", `${(parseFloat(svg.style.getPropertyValue("--twk-spin")) || 0) - 360}deg`);
|
|
53
90
|
btn.classList.add("is-spinning");
|
|
54
91
|
clearTimeout(btn._t);
|
|
55
|
-
const fallback = () => {
|
|
92
|
+
const fallback = () => {
|
|
93
|
+
if (svg.getAnimations && svg.getAnimations().length) btn._t = setTimeout(fallback, 250);
|
|
94
|
+
else btn._spinSettle();
|
|
95
|
+
};
|
|
56
96
|
btn._t = setTimeout(fallback, 600);
|
|
57
97
|
};
|
|
58
98
|
|
|
59
|
-
// Hint tooltip — one shared bubble, portaled
|
|
60
|
-
|
|
61
|
-
|
|
99
|
+
// Hint tooltip — one shared bubble, portaled where its panel's portals go (<body>, or a shadow panel's portal root) so
|
|
100
|
+
// it clears the panel's overflow clip.
|
|
101
|
+
let hintTip: HTMLDivElement | null = null,
|
|
102
|
+
hintTimer = 0,
|
|
103
|
+
hintAnchor: Element | null = null;
|
|
104
|
+
// bound only while open; WCAG 1.4.13 (dismissable)
|
|
105
|
+
const onHintKey = (e: KeyboardEvent) => {
|
|
106
|
+
if (e.key === "Escape") {
|
|
107
|
+
e.stopPropagation();
|
|
108
|
+
hideHintNow();
|
|
109
|
+
}
|
|
110
|
+
};
|
|
62
111
|
/** Hide the tip now; with `owner` (panel.destroy()), only a tip anchored inside it — the node is shared. */
|
|
63
|
-
const hideHintNow = (owner?: Element) => {
|
|
112
|
+
const hideHintNow = (owner?: Element) => {
|
|
113
|
+
if (owner && hintAnchor && !owner.contains(hintAnchor)) return;
|
|
114
|
+
clearTimeout(hintTimer);
|
|
115
|
+
document.removeEventListener("keydown", onHintKey);
|
|
116
|
+
if (hintTip) hintTip.classList.remove("is-open");
|
|
117
|
+
};
|
|
64
118
|
function showHint(anchor: Element, text: string) {
|
|
65
|
-
if (!hintTip) {
|
|
66
|
-
|
|
119
|
+
if (!hintTip) {
|
|
120
|
+
hintTip = el("div", "twk-tip twk-portal");
|
|
121
|
+
hintTip.setAttribute("role", "tooltip");
|
|
122
|
+
}
|
|
123
|
+
// each show: the last hint may have been another root's, or a host replaced <body>'s content
|
|
124
|
+
portalTo(hintTip, anchor);
|
|
67
125
|
clearTimeout(hintTimer);
|
|
68
126
|
hintTip.textContent = text;
|
|
69
127
|
carrySkin(hintTip, anchor); // resolved at show time: setTheme may have run since build
|
|
70
128
|
const wasOpen = hintTip.classList.contains("is-open");
|
|
71
|
-
hintTip.style.visibility = "hidden";
|
|
129
|
+
hintTip.style.visibility = "hidden";
|
|
130
|
+
hintTip.classList.add("is-open");
|
|
72
131
|
placeBelow(anchor, hintTip, { align: "center", prefer: "above", gap: 8 });
|
|
73
132
|
hintTip.style.visibility = "";
|
|
74
133
|
document.addEventListener("keydown", onHintKey);
|
|
75
134
|
hintAnchor = anchor;
|
|
76
135
|
// Unmount watchdog: a host removing the panel mid-hover would strand the open tip. One rAF per frame while open.
|
|
77
|
-
if (!wasOpen)
|
|
136
|
+
if (!wasOpen)
|
|
137
|
+
requestAnimationFrame(function watch() {
|
|
138
|
+
if (!hintTip.classList.contains("is-open")) return;
|
|
139
|
+
if (!hintAnchor.isConnected) return hideHintNow();
|
|
140
|
+
requestAnimationFrame(watch);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
// a short grace, so crossing marker → tip doesn't flicker
|
|
144
|
+
function hideHint() {
|
|
145
|
+
if (hintTip) {
|
|
146
|
+
clearTimeout(hintTimer);
|
|
147
|
+
hintTimer = setTimeout(hideHintNow, 80);
|
|
148
|
+
}
|
|
78
149
|
}
|
|
79
|
-
function hideHint() { if (hintTip) { clearTimeout(hintTimer); hintTimer = setTimeout(hideHintNow, 80); } } // a short grace, so crossing marker → tip doesn't flicker
|
|
80
150
|
/** Every control's label element — the one list behind the hint marker's home and the per-control
|
|
81
151
|
* reset target (panel.ts wireReset), so the two can't drift. */
|
|
82
|
-
const LABEL_SEL =
|
|
152
|
+
const LABEL_SEL =
|
|
153
|
+
".twk-slider-label, .twk-row-label, .twk-select-label, .twk-trigger-label, .twk-radiogrid-label, .twk-field-label, .twk-folder-title, .twk-fps-label, .twk-plot-label, .twk-spring-label, .twk-bezier-label";
|
|
83
154
|
/** A control's `hint` becomes a ⓘ marker beside its label that reveals the tooltip on hover/focus. */
|
|
84
155
|
function addHintMarker(node: Element, hint: string) {
|
|
85
156
|
const label = node.querySelector(LABEL_SEL) || node;
|
|
86
|
-
if (label === node && node.classList.contains("
|
|
157
|
+
if (label === node && node.classList.contains("twk-separator")) return; // a divider has no label to carry a marker
|
|
87
158
|
// Inside a <button> (select trigger, folder header), which can't contain interactive content, the
|
|
88
159
|
// ⓘ is a decorative span and the hint rides the host button's aria-description for screen readers.
|
|
89
160
|
const hostBtn = label.closest("button");
|
|
90
|
-
const mark = hostBtn ? el("span", "
|
|
161
|
+
const mark = hostBtn ? el("span", "twk-hint", ICON_INFO) : btn("twk-hint", ICON_INFO);
|
|
91
162
|
const show = () => showHint(mark, hint);
|
|
92
163
|
if (hostBtn) {
|
|
93
164
|
mark.setAttribute("aria-hidden", "true");
|
|
@@ -97,23 +168,51 @@ function addHintMarker(node: Element, hint: string) {
|
|
|
97
168
|
mark.setAttribute("aria-label", hint);
|
|
98
169
|
mark.addEventListener("focus", show);
|
|
99
170
|
mark.addEventListener("blur", hideHint);
|
|
100
|
-
|
|
171
|
+
// keyboard activation stays on the marker
|
|
172
|
+
mark.addEventListener("keydown", (e) => {
|
|
173
|
+
if (e.key === "Enter" || e.key === " ") e.stopPropagation();
|
|
174
|
+
});
|
|
101
175
|
}
|
|
102
176
|
mark.addEventListener("pointerenter", show);
|
|
103
177
|
mark.addEventListener("pointerleave", hideHint);
|
|
104
178
|
mark.addEventListener("pointerdown", (e) => e.stopPropagation()); // mustn't start a slider scrub or panel drag
|
|
105
|
-
|
|
179
|
+
// and (inside a control button) mustn't toggle the parent
|
|
180
|
+
mark.addEventListener("click", (e) => {
|
|
181
|
+
e.preventDefault();
|
|
182
|
+
e.stopPropagation();
|
|
183
|
+
});
|
|
106
184
|
label.appendChild(mark);
|
|
107
185
|
}
|
|
108
186
|
async function copyText(text: string) {
|
|
109
|
-
try {
|
|
187
|
+
try {
|
|
188
|
+
await navigator.clipboard.writeText(text);
|
|
189
|
+
return true;
|
|
190
|
+
} catch {}
|
|
110
191
|
// Fallback when the clipboard API is blocked (no user activation): textarea + execCommand.
|
|
111
192
|
const ta = document.createElement("textarea");
|
|
112
|
-
ta.value = text;
|
|
113
|
-
|
|
114
|
-
|
|
193
|
+
ta.value = text;
|
|
194
|
+
ta.style.position = "fixed";
|
|
195
|
+
ta.style.top = "-9999px";
|
|
196
|
+
document.body.appendChild(ta);
|
|
197
|
+
ta.focus();
|
|
198
|
+
ta.select();
|
|
199
|
+
let ok = false;
|
|
200
|
+
try {
|
|
201
|
+
ok = document.execCommand("copy");
|
|
202
|
+
} catch {}
|
|
115
203
|
document.body.removeChild(ta);
|
|
116
204
|
return ok;
|
|
117
205
|
}
|
|
118
206
|
|
|
119
|
-
export {
|
|
207
|
+
export {
|
|
208
|
+
toolbarBtn,
|
|
209
|
+
makeCopyBtn,
|
|
210
|
+
makeResetBtn,
|
|
211
|
+
flashCopied,
|
|
212
|
+
copyText,
|
|
213
|
+
spinReset,
|
|
214
|
+
announce,
|
|
215
|
+
hideHintNow,
|
|
216
|
+
addHintMarker,
|
|
217
|
+
LABEL_SEL,
|
|
218
|
+
};
|
package/src/tweaks/fold.ts
CHANGED
|
@@ -7,15 +7,27 @@ import { REDUCE_MOTION } from "./shared.js";
|
|
|
7
7
|
|
|
8
8
|
/** Fold `node` shut or open; reduced motion, no Animations API or a row with nothing drawn just toggle the class. */
|
|
9
9
|
export const fold = (node: HTMLElement & { _fold?: Animation }, hide: boolean) => {
|
|
10
|
-
const cs = getComputedStyle(node),
|
|
11
|
-
|
|
10
|
+
const cs = getComputedStyle(node),
|
|
11
|
+
at = node._fold?.playState == "running" && {
|
|
12
|
+
marginBottom: cs.marginBottom,
|
|
13
|
+
clipPath: cs.clipPath,
|
|
14
|
+
opacity: cs.opacity,
|
|
15
|
+
};
|
|
16
|
+
node._fold?.cancel();
|
|
17
|
+
node.classList.remove("twk-cond-hidden");
|
|
12
18
|
const h = !REDUCE_MOTION.matches && node.animate && node.offsetHeight;
|
|
13
|
-
node.classList.toggle("
|
|
14
|
-
if (h)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
node.classList.toggle("twk-cond-hidden", hide);
|
|
20
|
+
if (h)
|
|
21
|
+
try {
|
|
22
|
+
// d is the room the row takes: its height, the 6px stack gap (tweaks.css) and its own top margin.
|
|
23
|
+
const d = h + 6 + parseFloat(cs.marginTop),
|
|
24
|
+
H = h + "px",
|
|
25
|
+
shut = { height: H, marginBottom: -d + "px", clipPath: `inset(0 0 ${d}px)`, opacity: 0 },
|
|
26
|
+
open = { height: H, clipPath: "inset(0)" };
|
|
27
|
+
(node._fold = node.animate([at || (hide ? open : shut), hide ? shut : open], {
|
|
28
|
+
duration: 350,
|
|
29
|
+
easing: cs.getPropertyValue("--twk-ease-fold").trim(),
|
|
30
|
+
})).onfinish = () => node.classList.toggle("twk-cond-hidden", hide);
|
|
31
|
+
node.classList.remove("twk-cond-hidden"); // after animate(): a refused easing leaves the toggle done
|
|
32
|
+
} catch {}
|
|
21
33
|
};
|