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
package/src/tweaks/shared.ts
CHANGED
|
@@ -1,29 +1,59 @@
|
|
|
1
1
|
/* Tweaks — shared building blocks: DOM + math helpers, theming, the popover shell and
|
|
2
2
|
* the control registry. core.js and the lazy controls/*.js import what they each use. */
|
|
3
|
-
import type { Control, Option, Panel, Theme } from "./types.js";
|
|
3
|
+
import type { Control, Option, Panel, Theme, Strings } from "./types.js";
|
|
4
4
|
import type { Meta } from "./schema.js";
|
|
5
5
|
|
|
6
6
|
// ── Internal types ──
|
|
7
7
|
/** A control's change callback. */
|
|
8
8
|
type OnChange = (value: unknown) => void;
|
|
9
|
-
/** The resolved `--
|
|
9
|
+
/** The resolved `--twk-*` custom-property map a theme turns into (resolveTheme). */
|
|
10
10
|
type ThemeVars = Record<string, string>;
|
|
11
11
|
/** A panel root; panel.ts hangs its live theme, edit hook and ready promise on the node. */
|
|
12
|
-
interface PanelEl extends HTMLElement {
|
|
12
|
+
interface PanelEl extends HTMLElement {
|
|
13
|
+
_twTheme?: ThemeVars | null;
|
|
14
|
+
ready?: Promise<Panel>;
|
|
15
|
+
}
|
|
13
16
|
/** A node applyThemeVars has written to; remembers which properties, to clear them next time. */
|
|
14
|
-
interface ThemedEl extends HTMLElement {
|
|
17
|
+
interface ThemedEl extends HTMLElement {
|
|
18
|
+
_twAppliedVars?: string[] | null;
|
|
19
|
+
}
|
|
15
20
|
/** A single-select radio button carrying its option's real value (dataset stringifies). */
|
|
16
|
-
interface RadioBtn extends HTMLButtonElement {
|
|
21
|
+
interface RadioBtn extends HTMLButtonElement {
|
|
22
|
+
_twVal?: string | number;
|
|
23
|
+
}
|
|
17
24
|
/** setAttribute() / style.setProperty() taking a number as well as a string (the DOM
|
|
18
25
|
* stringifies either). Type-level only. */
|
|
19
|
-
interface Stringifying {
|
|
26
|
+
interface Stringifying {
|
|
27
|
+
setAttribute(name: string, value: string | number): void;
|
|
28
|
+
style: { setProperty(name: string, value: string | number | null, priority?: string): void };
|
|
29
|
+
}
|
|
20
30
|
type Built<E extends Element> = E & Stringifying;
|
|
21
31
|
/** The labeled numeric field's spec (numField). */
|
|
22
|
-
|
|
32
|
+
// unit: muted suffix, also in the accessible name
|
|
33
|
+
interface NumSpec {
|
|
34
|
+
label: string;
|
|
35
|
+
value?: unknown;
|
|
36
|
+
min?: unknown;
|
|
37
|
+
max?: unknown;
|
|
38
|
+
step?: unknown;
|
|
39
|
+
soft?: boolean;
|
|
40
|
+
row?: boolean;
|
|
41
|
+
unit?: string;
|
|
42
|
+
}
|
|
23
43
|
/** The numeric field's handle: set() coerces and fits, get() reads the fitted number. */
|
|
24
|
-
interface NumField {
|
|
44
|
+
interface NumField {
|
|
45
|
+
el: HTMLDivElement;
|
|
46
|
+
set(v: number | string): void;
|
|
47
|
+
get(): number;
|
|
48
|
+
range: [number, number];
|
|
49
|
+
}
|
|
25
50
|
/** The popover shell's handle (popover). */
|
|
26
|
-
interface Popover {
|
|
51
|
+
interface Popover {
|
|
52
|
+
open(): void;
|
|
53
|
+
close(): void;
|
|
54
|
+
isOpen(): boolean;
|
|
55
|
+
reflow(): void;
|
|
56
|
+
}
|
|
27
57
|
/** A control constructor: normalized meta in, handle out. The registry holds every one under
|
|
28
58
|
* the loose form; a lookup names the shape it expects — `getControl<TabsControl>("tabs")`. */
|
|
29
59
|
type ControlCtor<M = Meta, C = Control> = (meta: M, onChange?: OnChange) => C;
|
|
@@ -33,12 +63,21 @@ type ControlCtor<M = Meta, C = Control> = (meta: M, onChange?: OnChange) => C;
|
|
|
33
63
|
// A key opening on one lowercase letter and capitals, or on an Apple OS, is a name written as meant ("sRGB", "iOS",
|
|
34
64
|
// "macOS", not "S RGB"), kept whole and the rest split after it ("sRGBGamut" → "sRGB Gamut"); a word ending in an
|
|
35
65
|
// acronym splits as any other ("showFPS" → "Show FPS").
|
|
36
|
-
const humanize = (s: string) =>
|
|
37
|
-
|
|
66
|
+
const humanize = (s: string) =>
|
|
67
|
+
s
|
|
68
|
+
.replace(/([a-z])(?=[A-Z])|([A-Z\d])(?=[A-Z][a-z])/g, "$1$2 ")
|
|
69
|
+
.replace(/^./, (c) => c.toUpperCase())
|
|
70
|
+
.trim();
|
|
71
|
+
const titleCase = (s: string) => {
|
|
72
|
+
const m = /^(?:[a-z][A-Z]{2,}?|(?:mac|tv|watch|iPad|vision)OS)(?=[A-Z][a-z]|[^A-Za-z]|$)/.exec(s);
|
|
73
|
+
return m ? `${m[0]} ${humanize(s.slice(m[0].length))}`.trim() : humanize(s);
|
|
74
|
+
};
|
|
38
75
|
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
|
|
39
76
|
const isObj = (v: unknown): v is Record<string, any> => !!v && typeof v === "object";
|
|
40
77
|
// Hex or any CSS color function: routes a schema string to the color control.
|
|
41
|
-
const isColorStr = (v: unknown) =>
|
|
78
|
+
const isColorStr = (v: unknown) =>
|
|
79
|
+
typeof v === "string" &&
|
|
80
|
+
/^(#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$|(oklch|oklab|rgba?|hsla?|hwb|lab|lch|color)\()/i.test(v.trim());
|
|
42
81
|
// toFixed() throws past 100 digits, and a readout past 20 decimals is noise.
|
|
43
82
|
const MAX_FIXED = 20;
|
|
44
83
|
const stepPrecision = (step: number) => {
|
|
@@ -53,64 +92,112 @@ const stepPrecision = (step: number) => {
|
|
|
53
92
|
// finer min put every grid point half a decimal off, and each re-round walked the value up.
|
|
54
93
|
const roundToStep = (v: number, min: number, step: number) => {
|
|
55
94
|
if (!(step > 0)) return v;
|
|
56
|
-
const p = stepPrecision(step),
|
|
57
|
-
|
|
95
|
+
const p = stepPrecision(step),
|
|
96
|
+
a = Number((+min).toFixed(p));
|
|
97
|
+
// a huge value over a tiny step overflows; keep the raw
|
|
98
|
+
const n = Math.round((v - a) / step);
|
|
99
|
+
if (!Number.isFinite(n)) return v;
|
|
58
100
|
return Number((a + n * step).toFixed(p));
|
|
59
101
|
};
|
|
60
102
|
// The reachable ends of a [min, max] step grid, so a clamp never lands off-grid; a range too
|
|
61
103
|
// narrow to hold a grid point pins to min. ±Infinity = open.
|
|
62
104
|
const gridEnds = (min: number, max: number, step: number, anchor = min): [number, number] => {
|
|
63
105
|
const r = (v: number) => roundToStep(v, anchor, step);
|
|
64
|
-
let lo = Number.isFinite(min) ? r(min) : -Infinity,
|
|
65
|
-
|
|
106
|
+
let lo = Number.isFinite(min) ? r(min) : -Infinity,
|
|
107
|
+
hi = Number.isFinite(max) ? r(max) : Infinity;
|
|
108
|
+
// A millionth of a step is float noise, not overshoot: 0.1 * 3's min rounds to 0.3, a hair under it, and trimmed to
|
|
109
|
+
// 0.31.
|
|
66
110
|
const eps = step * 1e-6;
|
|
67
111
|
if (lo < min - eps) lo = r(lo + step);
|
|
68
112
|
if (hi > max + eps) hi = r(hi - step);
|
|
69
113
|
return lo > hi ? [min, min] : [lo, hi];
|
|
70
114
|
};
|
|
71
115
|
// By the span, then finer, a tenth at a time and at most twice, until each value the range was built around sits on the
|
|
72
|
-
// grid from min: a coarser grid
|
|
116
|
+
// grid from min: a coarser grid would snap the default off what was written (37 over 0–110 to 40). A value finer still
|
|
73
117
|
// (3.14159) rounds to the last of those. A range across zero counts 0 among them, so -45's -135–135 steps by 1, not by
|
|
74
|
-
// 10 from -135 (…, -5, 5, …), which
|
|
118
|
+
// 10 from -135 (…, -5, 5, …), which steps over 0. roundToStep anchors the grid at min rounded to the step's decimals,
|
|
75
119
|
// so min must keep its own decimals too (0.25 over 0.1 anchored at 0.3, and 1.25 came back 1.2); max joins them when a
|
|
76
120
|
// step within reach fits it, else the grid trims it as before.
|
|
77
121
|
const inferStep = (min: number, max: number, ...vals: number[]) => {
|
|
78
122
|
const range = max - min;
|
|
79
123
|
if (min < 0 && max > 0) vals.push(0);
|
|
80
|
-
// A span under the finest step the bands reach (0.0001) starts at a hundredth of its own decade, or [5e-6, 0, 1e-5]
|
|
81
|
-
|
|
124
|
+
// A span under the finest step the bands reach (0.0001) starts at a hundredth of its own decade, or [5e-6, 0, 1e-5]
|
|
125
|
+
// was 0–0.
|
|
126
|
+
const base =
|
|
127
|
+
range > 0 && range < 1e-4
|
|
128
|
+
? 10 ** Math.floor(Math.log10(range)) / 100
|
|
129
|
+
: range <= 1
|
|
130
|
+
? 0.01
|
|
131
|
+
: range <= 10
|
|
132
|
+
? 0.1
|
|
133
|
+
: range <= 100
|
|
134
|
+
? 1
|
|
135
|
+
: 10;
|
|
82
136
|
const on = (n: number) => !Number.isFinite(n) || Math.abs(n - Math.round(n)) <= 1e-6;
|
|
83
137
|
const fits = (step: number, vs: number[]) => on(min / Math.min(step, 1)) && vs.every((v) => on((v - min) / step));
|
|
84
|
-
const finest = (vs: number[]) => {
|
|
138
|
+
const finest = (vs: number[]) => {
|
|
139
|
+
let step = base;
|
|
140
|
+
for (let i = 0; i < 2 && !fits(step, vs); i++) step /= 10;
|
|
141
|
+
return step;
|
|
142
|
+
};
|
|
85
143
|
const withMax = finest([...vals, max]);
|
|
86
144
|
return fits(withMax, [...vals, max]) ? withMax : finest(vals);
|
|
87
145
|
};
|
|
88
|
-
// The range a value implies when no bound is given, widened by each value (defaultRange), and its step. A range
|
|
89
|
-
// about zero steps like its positive half (-0.5 by 0.01, like 0.5), not by its doubled span, and its ends keep
|
|
146
|
+
// The range a value implies when no bound is given, widened by each value (defaultRange), and its step. A range
|
|
147
|
+
// mirrored about zero steps like its positive half (-0.5 by 0.01, like 0.5), not by its doubled span, and its ends keep
|
|
148
|
+
// 0 on the grid.
|
|
90
149
|
const derivedRange = (...vals: number[]) => {
|
|
91
150
|
const rs = (vals.length ? vals : [0]).map(defaultRange);
|
|
92
|
-
const min = Math.min(...rs.map((r) => r[0])),
|
|
93
|
-
|
|
151
|
+
const min = Math.min(...rs.map((r) => r[0])),
|
|
152
|
+
max = Math.max(...rs.map((r) => r[1]));
|
|
153
|
+
return {
|
|
154
|
+
min,
|
|
155
|
+
max,
|
|
156
|
+
step: min < 0 && min === -max ? inferStep(0, max, max, ...vals.map(Math.abs)) : inferStep(min, max, ...vals),
|
|
157
|
+
};
|
|
94
158
|
};
|
|
95
159
|
// A console notice for a mistake. Its words live in the lazy heavy chunk (heavy.ts NOTICES), so a panel that makes none
|
|
96
160
|
// never downloads them; it lands a moment later, which a console doesn't mind.
|
|
97
161
|
// Once the heavy chunk is in (always on the single-file build), a notice prints at once, so code that reads the console
|
|
98
162
|
// right after the set() that caused it sees it; before, it waits for the chunk.
|
|
99
163
|
let says: ((kind: string, args: unknown[]) => void) | null = null;
|
|
100
|
-
const sayNow = (f: typeof says) => {
|
|
101
|
-
|
|
164
|
+
const sayNow = (f: typeof says) => {
|
|
165
|
+
says = f;
|
|
166
|
+
};
|
|
167
|
+
const notice = (kind: string, ...args: unknown[]) => {
|
|
168
|
+
if (says) says(kind, args);
|
|
169
|
+
else
|
|
170
|
+
import("./heavy.js").then(
|
|
171
|
+
(m) => m.notice(kind, args),
|
|
172
|
+
() => {},
|
|
173
|
+
);
|
|
174
|
+
};
|
|
102
175
|
// Normalize a slider-style range: an absent or non-finite bound derives from the values, an inverted pair swaps (with a
|
|
103
|
-
// note, given the control's label), and only then does an absent or unusable step infer, so a NaN min can't hand it a
|
|
104
|
-
// span. Every range source funnels here.
|
|
176
|
+
// note, given the control's label), and only then does an absent or unusable step infer, so a NaN min can't hand it a
|
|
177
|
+
// NaN span. Every range source funnels here.
|
|
105
178
|
const normalizeRange = (rawMin: unknown, rawMax: unknown, rawStep: unknown, vals: number[] = [], label?: string) => {
|
|
106
179
|
const given = (n: unknown) => n != null && n !== "" && Number.isFinite(+n!);
|
|
107
180
|
vals = vals.filter(Number.isFinite);
|
|
108
181
|
const d = derivedRange(...vals);
|
|
109
|
-
let min = given(rawMin) ? +rawMin! : d.min,
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
182
|
+
let min = given(rawMin) ? +rawMin! : d.min,
|
|
183
|
+
max = given(rawMax) ? +rawMax! : d.max,
|
|
184
|
+
step = +rawStep!;
|
|
185
|
+
// One bound written at or past the values' own other end: that end derives from the written bound, as a bare number
|
|
186
|
+
// its size would, and steps a span past it when that's still no range, so [5, 20] slides 20–60 and [0.5, 1] 1–2, not
|
|
187
|
+
// 15–20 or 1–1.
|
|
188
|
+
if (given(rawMin) !== given(rawMax) && !(max > min)) {
|
|
189
|
+
const up = given(rawMin),
|
|
190
|
+
b = up ? min : max,
|
|
191
|
+
r = derivedRange(b);
|
|
192
|
+
if (up) max = r.max > b ? r.max : b + r.max - r.min;
|
|
193
|
+
else min = r.min < b ? r.min : b - r.max + r.min;
|
|
194
|
+
}
|
|
195
|
+
if (max < min) {
|
|
196
|
+
if (label != null) notice("swap", label, min, max);
|
|
197
|
+
const t = min;
|
|
198
|
+
min = max;
|
|
199
|
+
max = t;
|
|
200
|
+
}
|
|
114
201
|
if (!(step > 0) || step > max - min) step = given(rawMin) || given(rawMax) ? inferStep(min, max, ...vals) : d.step;
|
|
115
202
|
return { min, max, step };
|
|
116
203
|
};
|
|
@@ -119,26 +206,37 @@ const normalizeRange = (rawMin: unknown, rawMax: unknown, rawStep: unknown, vals
|
|
|
119
206
|
const rangeStep = (e: KeyboardEvent, cur: number, step: number, lo: number, hi: number, page: number | null = null) => {
|
|
120
207
|
const coarse = e.shiftKey ? 10 : 1;
|
|
121
208
|
switch (e.key) {
|
|
122
|
-
case "ArrowRight":
|
|
123
|
-
case "
|
|
124
|
-
|
|
125
|
-
case "
|
|
126
|
-
case "
|
|
127
|
-
|
|
209
|
+
case "ArrowRight":
|
|
210
|
+
case "ArrowUp":
|
|
211
|
+
return cur + step * coarse;
|
|
212
|
+
case "ArrowLeft":
|
|
213
|
+
case "ArrowDown":
|
|
214
|
+
return cur - step * coarse;
|
|
215
|
+
case "PageUp":
|
|
216
|
+
return page != null ? cur + page : null;
|
|
217
|
+
case "PageDown":
|
|
218
|
+
return page != null ? cur - page : null;
|
|
219
|
+
case "Home":
|
|
220
|
+
return lo;
|
|
221
|
+
case "End":
|
|
222
|
+
return hi;
|
|
128
223
|
}
|
|
129
224
|
return null;
|
|
130
225
|
};
|
|
131
226
|
// Does a handle's pixel span [hx, hx+hw] overlap the label or value text? `m` is a near-miss buffer.
|
|
132
227
|
const overlapsText = (labelEl: HTMLElement, valueEl: HTMLElement, hx: number, hw: number, m = 0) => {
|
|
133
|
-
const ll = labelEl.offsetLeft,
|
|
134
|
-
|
|
228
|
+
const ll = labelEl.offsetLeft,
|
|
229
|
+
lr = ll + labelEl.offsetWidth;
|
|
230
|
+
const vl = valueEl.offsetLeft,
|
|
231
|
+
vr = vl + valueEl.offsetWidth;
|
|
135
232
|
return (hx < lr && hx + hw > ll) || (hx < vr + m && hx + hw > vl - m);
|
|
136
233
|
};
|
|
137
234
|
// Default range for a number with no bounds: 0–1 keeps 0–1, a positive spans 0–3× (100 as a
|
|
138
235
|
// last resort), and a negative spans that much either side of zero, so it can still cross it
|
|
139
236
|
// (rotate: -45 → −135 to 135).
|
|
140
237
|
const defaultRange = (v: number): [number, number] => {
|
|
141
|
-
|
|
238
|
+
// NaN would span NaN–NaN; every entry point reads it as 0, as a verbose slider does
|
|
239
|
+
if (!Number.isFinite(v)) return [0, 1];
|
|
142
240
|
const x3 = Number((Math.abs(v) * 3).toPrecision(12)); // 2.3 × 3 is 6.8999…, which the grid trimmed to 6.8
|
|
143
241
|
if (v >= 0) return [0, v <= 1 ? 1 : x3 || 100];
|
|
144
242
|
const m = v >= -1 ? 1 : x3;
|
|
@@ -148,26 +246,62 @@ const defaultRange = (v: number): [number, number] => {
|
|
|
148
246
|
// An option is a string, a { value, label } object, or a bare primitive that is its own value.
|
|
149
247
|
const optValue = (o: Option) => (o == null ? undefined : typeof o === "object" ? o.value : o);
|
|
150
248
|
// An object's readable form: its JSON, or String() when it can't be stringified (a cycle).
|
|
151
|
-
const json = (v: unknown) => {
|
|
249
|
+
const json = (v: unknown) => {
|
|
250
|
+
try {
|
|
251
|
+
return JSON.stringify(v);
|
|
252
|
+
} catch {
|
|
253
|
+
return String(v);
|
|
254
|
+
}
|
|
255
|
+
};
|
|
152
256
|
// A string option shows as written ("px", "sRGB"): it's the author's text, not a key. A { value }
|
|
153
257
|
// option with no label reads as its value's string; an object with neither, as its JSON.
|
|
154
|
-
const optLabel = (o: Option) =>
|
|
258
|
+
const optLabel = (o: Option) =>
|
|
259
|
+
o == null
|
|
260
|
+
? ""
|
|
261
|
+
: typeof o === "string"
|
|
262
|
+
? o
|
|
263
|
+
: typeof o === "object"
|
|
264
|
+
? (o.label ?? (o.value !== undefined ? String(o.value) : json(o)))
|
|
265
|
+
: String(o);
|
|
155
266
|
|
|
156
267
|
// The DOM factory, typed by tag like createElement (svgEl in heavy.ts is its SVG twin). A field it
|
|
157
268
|
// makes is a setting, not a form: autofill and the password managers leave it alone.
|
|
158
|
-
const el = <K extends keyof HTMLElementTagNameMap>(
|
|
159
|
-
|
|
160
|
-
|
|
269
|
+
const el = <K extends keyof HTMLElementTagNameMap>(
|
|
270
|
+
tag: K,
|
|
271
|
+
cls?: string,
|
|
272
|
+
html?: string,
|
|
273
|
+
): Built<HTMLElementTagNameMap[K]> => {
|
|
274
|
+
const n = document.createElement(tag);
|
|
275
|
+
if (cls) n.className = cls;
|
|
276
|
+
if (html != null) n.innerHTML = html;
|
|
277
|
+
if (tag == "input" || tag == "textarea")
|
|
278
|
+
(n.setAttribute("autocomplete", "off"),
|
|
279
|
+
n.setAttribute("data-1p-ignore", ""),
|
|
280
|
+
n.setAttribute("data-lpignore", "true"));
|
|
161
281
|
return n;
|
|
162
282
|
};
|
|
163
283
|
// Every <button> is type="button" — inside a host's <form> the default would submit — and
|
|
164
284
|
// text goes through textContent, never innerHTML: labels are host data.
|
|
165
|
-
const btn = (cls: string, html?: string) => {
|
|
166
|
-
const
|
|
285
|
+
const btn = (cls: string, html?: string) => {
|
|
286
|
+
const b = el("button", cls, html);
|
|
287
|
+
b.type = "button";
|
|
288
|
+
return b;
|
|
289
|
+
};
|
|
290
|
+
const txt = <K extends keyof HTMLElementTagNameMap>(tag: K, cls: string, text: string) => {
|
|
291
|
+
const n = tag === "button" ? btn(cls) : el(tag, cls);
|
|
292
|
+
n.textContent = text;
|
|
293
|
+
return n as Built<HTMLElementTagNameMap[K]>;
|
|
294
|
+
};
|
|
167
295
|
// Stop a node's pointer events and clicks leaking to the page behind it.
|
|
168
|
-
const stopPointerLeak = (node: EventTarget) =>
|
|
296
|
+
const stopPointerLeak = (node: EventTarget) =>
|
|
297
|
+
["pointerdown", "pointermove", "pointerup", "click"].forEach((t) =>
|
|
298
|
+
node.addEventListener(t, (e) => e.stopPropagation()),
|
|
299
|
+
);
|
|
169
300
|
// Run fn next frame and again once web fonts load — font swaps change the metrics controls measure.
|
|
170
|
-
const onReady = (fn: () => void) => {
|
|
301
|
+
const onReady = (fn: () => void) => {
|
|
302
|
+
requestAnimationFrame(fn);
|
|
303
|
+
if (document.fonts && document.fonts.ready) document.fonts.ready.then(fn);
|
|
304
|
+
};
|
|
171
305
|
// Listen on global targets for a DOM-bound owner: the first event after the owner has left
|
|
172
306
|
// the document drops the listener set. "Left" means it was connected once, so an event
|
|
173
307
|
// between build and the host's append is skipped, not fatal. Returns the release fn.
|
|
@@ -175,18 +309,32 @@ const onLive = (owner: Element, targets: Array<[EventTarget, string]>, fn: (e?:
|
|
|
175
309
|
let mounted = owner.isConnected;
|
|
176
310
|
// A host appends right after building, before any event can arrive: note that mount next
|
|
177
311
|
// frame, so an unmount with no event in between still releases on the first event after.
|
|
178
|
-
if (!mounted)
|
|
179
|
-
|
|
312
|
+
if (!mounted)
|
|
313
|
+
requestAnimationFrame(() => {
|
|
314
|
+
if (owner.isConnected) mounted = true;
|
|
315
|
+
});
|
|
316
|
+
// destroy() marks the owner; release even if never mounted
|
|
317
|
+
const h = (e: Event) => {
|
|
318
|
+
if (!owner.isConnected) {
|
|
319
|
+
if (mounted || owner.closest("[data-twk-destroyed]")) off();
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
mounted = true;
|
|
323
|
+
fn(e);
|
|
324
|
+
};
|
|
180
325
|
const off = () => targets.forEach(([t, ev]) => t.removeEventListener(ev, h));
|
|
181
326
|
targets.forEach(([t, ev]) => t.addEventListener(ev, h));
|
|
182
327
|
return off;
|
|
183
328
|
};
|
|
184
329
|
// Ask every measured surface to re-measure next frame after a display:none → shown flip.
|
|
185
330
|
// Namespaced, not a real "resize": host pages listen to that.
|
|
186
|
-
const requestReflow = () => requestAnimationFrame(() => window.dispatchEvent(new Event("
|
|
331
|
+
const requestReflow = () => requestAnimationFrame(() => window.dispatchEvent(new Event("twk-reflow")));
|
|
187
332
|
// Toggle .is-hover while the pointer is over a node; onEnter runs on entry.
|
|
188
333
|
const wireHoverClass = (el: HTMLElement, onEnter: () => void) => {
|
|
189
|
-
el.addEventListener("pointerenter", () => {
|
|
334
|
+
el.addEventListener("pointerenter", () => {
|
|
335
|
+
el.classList.add("is-hover");
|
|
336
|
+
onEnter();
|
|
337
|
+
});
|
|
190
338
|
el.addEventListener("pointerleave", () => el.classList.remove("is-hover"));
|
|
191
339
|
};
|
|
192
340
|
// ── Quiet mouse focus — :focus-visible always matches a focused text field, so a plain
|
|
@@ -194,42 +342,78 @@ const wireHoverClass = (el: HTMLElement, onEnter: () => void) => {
|
|
|
194
342
|
// the next focus, any key restores the ring. Installs once per page. ──
|
|
195
343
|
let pointerModality = false;
|
|
196
344
|
if (typeof document !== "undefined") {
|
|
197
|
-
document.addEventListener(
|
|
198
|
-
|
|
345
|
+
document.addEventListener(
|
|
346
|
+
"pointerdown",
|
|
347
|
+
() => {
|
|
348
|
+
pointerModality = true;
|
|
349
|
+
},
|
|
350
|
+
true,
|
|
351
|
+
);
|
|
352
|
+
document.addEventListener(
|
|
353
|
+
"keydown",
|
|
354
|
+
() => {
|
|
355
|
+
pointerModality = false;
|
|
356
|
+
},
|
|
357
|
+
true,
|
|
358
|
+
);
|
|
199
359
|
}
|
|
200
360
|
const quietFocus = (input: HTMLElement) => {
|
|
201
|
-
input.addEventListener("focus", () => input.classList.toggle("
|
|
202
|
-
input.addEventListener("blur", () => input.classList.remove("
|
|
361
|
+
input.addEventListener("focus", () => input.classList.toggle("twk-focus-quiet", pointerModality));
|
|
362
|
+
input.addEventListener("blur", () => input.classList.remove("twk-focus-quiet"));
|
|
203
363
|
};
|
|
204
364
|
/** Place a portaled popover under its trigger, flipping above when it won't fit, clamped into
|
|
205
365
|
* the viewport. width:"match" sizes it to the trigger; align:"end" lines up the right edges. */
|
|
206
|
-
export const placeBelow = (
|
|
366
|
+
export const placeBelow = (
|
|
367
|
+
trigger: Element,
|
|
368
|
+
pop: HTMLElement,
|
|
369
|
+
{
|
|
370
|
+
width,
|
|
371
|
+
fallbackH = 300,
|
|
372
|
+
gap = 6,
|
|
373
|
+
align = "start",
|
|
374
|
+
prefer = "below",
|
|
375
|
+
}: {
|
|
376
|
+
width?: number | "match";
|
|
377
|
+
fallbackH?: number;
|
|
378
|
+
gap?: number;
|
|
379
|
+
align?: "start" | "end" | "center";
|
|
380
|
+
prefer?: "above" | "below";
|
|
381
|
+
} = {},
|
|
382
|
+
) => {
|
|
207
383
|
const r = trigger.getBoundingClientRect();
|
|
208
384
|
if (width === "match") pop.style.width = r.width + "px";
|
|
209
|
-
|
|
210
|
-
|
|
385
|
+
// drawn width, which min-width may hold above a match
|
|
386
|
+
const w = pop.offsetWidth || (width === "match" ? r.width : width || 0);
|
|
387
|
+
// A match held wider by min-width grows left from the trigger's right edge, so in a nested folder it spans the
|
|
388
|
+
// panel's row.
|
|
211
389
|
const end = align === "end" || (width === "match" && w > r.width + 0.5);
|
|
212
390
|
const x = end ? r.right - w : align === "center" ? r.left + r.width / 2 - w / 2 : r.left;
|
|
213
391
|
pop.style.left = Math.max(8, Math.min(x, window.innerWidth - w - 8)) + "px";
|
|
214
392
|
const h = pop.offsetHeight || fallbackH;
|
|
215
|
-
const below = window.innerHeight - r.bottom,
|
|
393
|
+
const below = window.innerHeight - r.bottom,
|
|
394
|
+
above = r.top,
|
|
395
|
+
need = h + 12;
|
|
216
396
|
// The preferred side if it fits, else the other if it fits, else whichever has more room.
|
|
217
|
-
const fitsBelow = below >= need,
|
|
218
|
-
|
|
397
|
+
const fitsBelow = below >= need,
|
|
398
|
+
fitsAbove = above >= need;
|
|
399
|
+
const goAbove =
|
|
400
|
+
prefer === "above" ? fitsAbove || (!fitsBelow && above >= below) : !fitsBelow && (fitsAbove || above > below);
|
|
219
401
|
const top = goAbove ? r.top - h - gap : r.bottom + gap;
|
|
220
402
|
pop.style.top = clamp(top, gap, window.innerHeight - h - gap) + "px"; // clamp Y into the viewport
|
|
221
403
|
// For the CSS entrance: the side it opened on, and its height.
|
|
222
|
-
pop.dataset.side = goAbove ? "above" : "below";
|
|
404
|
+
pop.dataset.side = goAbove ? "above" : "below";
|
|
405
|
+
pop.style.setProperty("--twk-pop-h", String(h));
|
|
223
406
|
};
|
|
224
407
|
|
|
225
|
-
// ── Theming — the panel runs on --
|
|
408
|
+
// ── Theming — the panel runs on --twk-* custom properties, so a theme is a set of those vars, which applyThemeVars
|
|
226
409
|
// sets inline. A key that isn't a token is skipped out loud.
|
|
227
410
|
const resolveTheme = (theme: Theme | null | undefined): ThemeVars | null => {
|
|
228
411
|
if (!theme || typeof theme !== "object") return null;
|
|
229
412
|
const out: ThemeVars = {};
|
|
230
413
|
for (const [k, v] of Object.entries(theme)) {
|
|
231
414
|
if (v == null) continue; // unset, as an optional spread leaves a key
|
|
232
|
-
if (k.startsWith("--")) out[k] = String(v);
|
|
415
|
+
if (k.startsWith("--twk-")) out[k] = String(v);
|
|
416
|
+
else notice("theme", k);
|
|
233
417
|
}
|
|
234
418
|
return Object.keys(out).length ? out : null;
|
|
235
419
|
};
|
|
@@ -243,115 +427,315 @@ const applyThemeVars = (node: ThemedEl | null | undefined, vars: ThemeVars | nul
|
|
|
243
427
|
// Carry a forced scheme onto a node portaled to <body>, where the cascade can't reach. Copy
|
|
244
428
|
// the WINNING scheme, not the nearest pin: pins resolve flat (light outranks dark; tweaks.css).
|
|
245
429
|
const carryScheme = (portal: Element, anchor: Element | null | undefined) => {
|
|
246
|
-
const s = anchor?.closest('[data-
|
|
247
|
-
|
|
430
|
+
const s = anchor?.closest('[data-twk-scheme="light"]')
|
|
431
|
+
? "light"
|
|
432
|
+
: anchor?.closest('[data-twk-scheme="dark"]')
|
|
433
|
+
? "dark"
|
|
434
|
+
: null;
|
|
435
|
+
if (s) portal.setAttribute("data-twk-scheme", s);
|
|
436
|
+
else portal.removeAttribute("data-twk-scheme");
|
|
248
437
|
};
|
|
249
438
|
// …and the anchor panel's live theme (read at carry time — setTheme() swaps the object).
|
|
250
|
-
|
|
439
|
+
// A shadow panel's portal host (shadow-root.ts) also takes the panel's tokens, which page CSS set on the panel's host.
|
|
440
|
+
const carrySkin = (portal: HTMLElement, anchor: Element | null | undefined) => {
|
|
441
|
+
applyThemeVars(portal, anchor?.closest<PanelEl>(".twk-panel")?._twTheme);
|
|
442
|
+
carryScheme(portal, anchor);
|
|
443
|
+
((anchor?.getRootNode() as ShadowRoot | undefined)?.host as any)?._twSync?.();
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
// ── Shadow roots: a panel built with opts.shadow lives in its host's shadow root, and its portals in a second host on
|
|
447
|
+
// <body> (shadow-root.ts), where a transformed ancestor can't capture them. home(): where a portal from `n` goes: that
|
|
448
|
+
// panel's portal root, the kit's own root `n` is already in (a portal's, or a shadow panel's before its dressing
|
|
449
|
+
// lands), else <body>, as for a light panel inside a page's own web component.
|
|
450
|
+
// activeIn(): focus as `n`'s tree sees it, since document.activeElement stops at a shadow host. holds(): `t` is in
|
|
451
|
+
// `box`,
|
|
452
|
+
// or is the host `box`'s tree shows as, the relatedTarget of focus crossing between a panel's root and its portals'. ──
|
|
453
|
+
const home = (n: Node): Node => {
|
|
454
|
+
const h = (n.getRootNode() as ShadowRoot).host as any;
|
|
455
|
+
return h ? h._twPortal || (h.matches(".twk-host, .twk-portal-host") ? h.shadowRoot : document.body) : document.body;
|
|
456
|
+
};
|
|
457
|
+
const activeIn = (n: Node) => (n.getRootNode() as Document | ShadowRoot).activeElement;
|
|
458
|
+
const holds = (box: Node, t: Node | null) => !!t && (box.contains(t) || t === (box.getRootNode() as ShadowRoot).host);
|
|
459
|
+
// opts.shadow (a panel, mountControl, createColorPicker): `root` goes in a shadow root on a host of its own, hidden
|
|
460
|
+
// until shadow-root.ts has styled it. Returns what goes in the page, the promise `ready` waits for (it holds the
|
|
461
|
+
// dressing's own teardown), and the teardown, which waits for the dressing if a destroy() comes first.
|
|
462
|
+
const isolate = (root: HTMLElement, shadow: boolean | { href?: string } | undefined) => {
|
|
463
|
+
if (!shadow) return { box: root, end: () => {} };
|
|
464
|
+
const box = el("twk-host" as "div", "twk-host");
|
|
465
|
+
box.attachShadow({ mode: "open" }).append(root);
|
|
466
|
+
root.classList.add("twk-in-shadow");
|
|
467
|
+
box.style.visibility = "hidden";
|
|
468
|
+
const dressed = import("./shadow-root.js").then(
|
|
469
|
+
(m) => m.dress(box, root, shadow),
|
|
470
|
+
(e) => {
|
|
471
|
+
box.style.visibility = "";
|
|
472
|
+
console.error("[tweaks] shadow:", e);
|
|
473
|
+
},
|
|
474
|
+
);
|
|
475
|
+
return {
|
|
476
|
+
box,
|
|
477
|
+
dressed,
|
|
478
|
+
end: () => {
|
|
479
|
+
box.remove();
|
|
480
|
+
dressed.then((stop) => stop && stop());
|
|
481
|
+
},
|
|
482
|
+
};
|
|
483
|
+
};
|
|
484
|
+
// Put a portal where `from`'s portals go; in a shadow root .twk-in-shadow leaves its tokens to that root's :host.
|
|
485
|
+
const portalTo = (n: HTMLElement, from: Node) => {
|
|
486
|
+
const h = home(from);
|
|
487
|
+
n.classList.toggle("twk-in-shadow", h !== document.body);
|
|
488
|
+
if (n.parentNode !== h) h.appendChild(n);
|
|
489
|
+
};
|
|
251
490
|
|
|
252
491
|
// ── Popover — the portal-to-<body> shell behind every transient surface. Opens under its
|
|
253
|
-
// trigger at its width (unless opts.width), carries the host theme, closes on outside-press / Esc / scroll-out.
|
|
492
|
+
// trigger at its width (unless opts.width), carries the host theme, closes on outside-press / Esc / scroll-out.
|
|
493
|
+
// Globally
|
|
254
494
|
// single-open. onOpen runs once it's placed at real size; onReflow on resize while open. ──
|
|
255
|
-
let activePopoverClose: null | (() => void) = null,
|
|
495
|
+
let activePopoverClose: null | (() => void) = null,
|
|
496
|
+
activePopoverTrigger: HTMLElement | null = null;
|
|
256
497
|
// Closes the open popover when its trigger lives inside `owner`.
|
|
257
|
-
const closeActivePopover = (owner: Element) => {
|
|
258
|
-
|
|
259
|
-
|
|
498
|
+
const closeActivePopover = (owner: Element) => {
|
|
499
|
+
if (activePopoverClose && owner.contains(activePopoverTrigger)) activePopoverClose();
|
|
500
|
+
};
|
|
501
|
+
function popover(
|
|
502
|
+
root: HTMLElement,
|
|
503
|
+
trigger: HTMLButtonElement,
|
|
504
|
+
pop: HTMLElement,
|
|
505
|
+
opts: {
|
|
506
|
+
width?: number | "match";
|
|
507
|
+
fallbackH?: number;
|
|
508
|
+
align?: "start" | "end";
|
|
509
|
+
movable?: boolean;
|
|
510
|
+
onOpen?: () => void;
|
|
511
|
+
onReflow?: () => void;
|
|
512
|
+
initialFocus?: () => HTMLElement | null | undefined;
|
|
513
|
+
} = {},
|
|
514
|
+
): Popover {
|
|
515
|
+
let open = false,
|
|
516
|
+
schemeObs: MutationObserver | null = null,
|
|
517
|
+
removeTimer: ReturnType<typeof setTimeout> | undefined,
|
|
518
|
+
byPointer = false,
|
|
519
|
+
sizeObs: ResizeObserver | null = null;
|
|
260
520
|
// Focus goes back to the row as the pane was opened: quietly after a click, even when an Escape closes it.
|
|
261
521
|
const refocus = () => trigger.focus({ focusVisible: !byPointer });
|
|
262
|
-
pop.classList.add("
|
|
522
|
+
pop.classList.add("twk-portal"); // portal-wide CSS rules key off this
|
|
263
523
|
// A closed pop stays in the DOM at opacity 0; inert keeps it out of the tab order and a11y tree.
|
|
264
524
|
pop.inert = true;
|
|
265
525
|
// Relay pointerdowns to the host panel's edit hook — on <body>, its own listener can't see them.
|
|
266
526
|
stopPointerLeak(pop); // on <body>, outside the panel's own pointer-stop
|
|
267
527
|
// A scroll carries the pop along at its offset from the trigger (follow) instead of
|
|
268
528
|
// re-placing it; once the row has left the viewport the pop closes.
|
|
269
|
-
let dx = 0,
|
|
529
|
+
let dx = 0,
|
|
530
|
+
dy = 0;
|
|
270
531
|
// A movable pane drags by its edge (heavy.ts dragPane, bound on the first open); `st` is the state the two share.
|
|
271
|
-
const st = { open: false, moved: false };
|
|
272
|
-
|
|
532
|
+
const st = { open: false, moved: false };
|
|
533
|
+
let dragBound = false;
|
|
534
|
+
const keepInView = () => {
|
|
535
|
+
pop.style.left = clamp(parseFloat(pop.style.left), 8, window.innerWidth - pop.offsetWidth - 8) + "px";
|
|
536
|
+
pop.style.top = clamp(parseFloat(pop.style.top), 8, window.innerHeight - pop.offsetHeight - 8) + "px";
|
|
537
|
+
};
|
|
273
538
|
const place = () => {
|
|
274
|
-
if (st.moved) keepInView();
|
|
275
|
-
|
|
539
|
+
if (st.moved) keepInView();
|
|
540
|
+
else placeBelow(trigger, pop, { width: opts.width ?? "match", fallbackH: opts.fallbackH, align: opts.align });
|
|
541
|
+
const r = trigger.getBoundingClientRect();
|
|
542
|
+
dx = parseFloat(pop.style.left) - r.left;
|
|
543
|
+
dy = parseFloat(pop.style.top) - r.top;
|
|
276
544
|
};
|
|
277
545
|
// A detached trigger has a zero rect: if the host has unmounted the panel, close instead.
|
|
278
|
-
const reflow = () => {
|
|
546
|
+
const reflow = () => {
|
|
547
|
+
if (open) {
|
|
548
|
+
if (!root.isConnected) return close();
|
|
549
|
+
place();
|
|
550
|
+
opts.onReflow && opts.onReflow();
|
|
551
|
+
}
|
|
552
|
+
};
|
|
279
553
|
const follow = () => {
|
|
280
|
-
if (!open) return;
|
|
554
|
+
if (!open) return;
|
|
555
|
+
if (!root.isConnected) return close();
|
|
281
556
|
const r = trigger.getBoundingClientRect();
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
557
|
+
// a floating panel scrolls, and clips its rows (an inline one holds them all)
|
|
558
|
+
const sr = trigger.closest(".twk-panel")?.getBoundingClientRect();
|
|
559
|
+
// hidden, or scrolled out
|
|
560
|
+
if (
|
|
561
|
+
!(r.width || r.height) ||
|
|
562
|
+
r.bottom < 0 ||
|
|
563
|
+
r.top > window.innerHeight ||
|
|
564
|
+
r.right < 0 ||
|
|
565
|
+
r.left > window.innerWidth ||
|
|
566
|
+
(sr?.height && (r.bottom < sr.top || r.top > sr.bottom))
|
|
567
|
+
)
|
|
568
|
+
return close();
|
|
569
|
+
pop.style.left = r.left + dx + "px";
|
|
570
|
+
pop.style.top = r.top + dy + "px";
|
|
571
|
+
};
|
|
572
|
+
// the path, as a shadow host retargets e.target
|
|
573
|
+
const onOutside = (e: PointerEvent) => {
|
|
574
|
+
const p = e.composedPath();
|
|
575
|
+
if (!p.includes(root) && !p.includes(pop)) close();
|
|
285
576
|
};
|
|
286
|
-
const onOutside = (e: PointerEvent) => { if (!root.contains(e.target as Node) && !pop.contains(e.target as Node)) close(); };
|
|
287
577
|
// Escape closes and stops propagation, so a host dialog doesn't close too. A field reverting an edit stops it first.
|
|
288
|
-
const onKey = (e: KeyboardEvent) => {
|
|
578
|
+
const onKey = (e: KeyboardEvent) => {
|
|
579
|
+
if (e.key === "Escape" && open && !e.defaultPrevented) {
|
|
580
|
+
e.preventDefault();
|
|
581
|
+
e.stopPropagation();
|
|
582
|
+
close();
|
|
583
|
+
refocus();
|
|
584
|
+
}
|
|
585
|
+
};
|
|
289
586
|
// Focus leaving both the pop and its trigger closes. A blur with no relatedTarget is
|
|
290
587
|
// ambiguous (a press on a drag surface inside the pop blurs to <body> too), so the modality
|
|
291
588
|
// note decides. A Tab that lands outside the panel comes back to the trigger a tick later.
|
|
292
589
|
const onFocusOut = (e: FocusEvent) => {
|
|
293
|
-
if (!open) return;
|
|
590
|
+
if (!open) return;
|
|
591
|
+
const t = e.relatedTarget as Node | null;
|
|
294
592
|
if (!t && !document.hasFocus()) return; // the window lost focus (a file dialog, another app): the pane waits for it
|
|
295
593
|
if (t ? root.contains(t) || pop.contains(t) : pointerModality) return;
|
|
594
|
+
// Focus crossing between a shadow panel's root and its portals' root reads as their host: look where it landed.
|
|
595
|
+
if (t && (holds(root, t) || holds(pop, t))) {
|
|
596
|
+
setTimeout(() => {
|
|
597
|
+
if (open && !root.contains(activeIn(root)) && !pop.contains(activeIn(pop))) close();
|
|
598
|
+
}, 0);
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
296
601
|
close();
|
|
297
|
-
if (t && !trigger.closest(".
|
|
602
|
+
if (t && !trigger.closest(".twk-panel")?.contains(t))
|
|
603
|
+
setTimeout(() => {
|
|
604
|
+
if (!open && activeIn(t) === t) trigger.focus();
|
|
605
|
+
}, 0);
|
|
298
606
|
};
|
|
299
|
-
root.addEventListener("focusout", onFocusOut);
|
|
300
|
-
|
|
607
|
+
root.addEventListener("focusout", onFocusOut);
|
|
608
|
+
pop.addEventListener("focusout", onFocusOut);
|
|
609
|
+
const firstFocusable = () =>
|
|
610
|
+
pop.querySelector<HTMLElement>(
|
|
611
|
+
'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href], [tabindex]:not([tabindex="-1"])',
|
|
612
|
+
);
|
|
301
613
|
// Re-carry the host scheme + theme while open: the cascade can't reach <body>, so a mid-open
|
|
302
614
|
// flip would strand a mismatched popover. The observer ignores its own write on `pop`.
|
|
303
615
|
const recarry = () => carrySkin(pop, root);
|
|
304
|
-
const onScheme = (recs: MutationRecord[]) => {
|
|
616
|
+
const onScheme = (recs: MutationRecord[]) => {
|
|
617
|
+
if (!recs.every((r) => r.target === pop)) recarry();
|
|
618
|
+
};
|
|
305
619
|
const openPop = () => {
|
|
620
|
+
if (open) return; // a second open would stack a second set of observers that close() can't reach
|
|
306
621
|
if (activePopoverClose && activePopoverClose !== close) activePopoverClose(); // close any other open popover first
|
|
307
|
-
activePopoverClose = close;
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
622
|
+
activePopoverClose = close;
|
|
623
|
+
activePopoverTrigger = trigger;
|
|
624
|
+
open = st.open = true;
|
|
625
|
+
root.classList.add("is-open");
|
|
626
|
+
trigger.setAttribute("aria-expanded", "true");
|
|
627
|
+
// Reopened mid-fade, it's still in the page; re-appending would kill the transition, so it reverses in place.
|
|
628
|
+
// a pop parked in its row isn't fading
|
|
629
|
+
clearTimeout(removeTimer);
|
|
630
|
+
const fading = pop.parentNode === home(root);
|
|
631
|
+
st.moved = false;
|
|
632
|
+
byPointer = pointerModality;
|
|
633
|
+
pop.inert = false;
|
|
634
|
+
if (!fading) portalTo(pop, root);
|
|
635
|
+
if (!dragBound && opts.movable !== false) {
|
|
636
|
+
dragBound = true;
|
|
637
|
+
import("./heavy.js").then(
|
|
638
|
+
(m) => m.dragPane(pop, st, place),
|
|
639
|
+
() => {},
|
|
640
|
+
);
|
|
641
|
+
}
|
|
313
642
|
carrySkin(pop, root);
|
|
314
|
-
window.addEventListener("
|
|
315
|
-
|
|
643
|
+
window.addEventListener("twk-retheme", recarry); // setTheme() while open
|
|
644
|
+
// host scheme flip while open
|
|
645
|
+
if (typeof MutationObserver === "function") {
|
|
646
|
+
schemeObs = new MutationObserver(onScheme);
|
|
647
|
+
schemeObs.observe(document.documentElement, {
|
|
648
|
+
subtree: true,
|
|
649
|
+
attributes: true,
|
|
650
|
+
attributeFilter: ["data-twk-scheme"],
|
|
651
|
+
});
|
|
652
|
+
}
|
|
316
653
|
// Place with transitions off: the recorded side and height set the closed state the entrance starts from.
|
|
317
|
-
if (fading) place();
|
|
654
|
+
if (fading) place();
|
|
655
|
+
else {
|
|
656
|
+
pop.style.transition = "none";
|
|
657
|
+
place();
|
|
658
|
+
void pop.offsetWidth;
|
|
659
|
+
pop.style.transition = "";
|
|
660
|
+
}
|
|
318
661
|
// A pane that changes size while open (a mode switch, a disclosed picker) stays on its row and inside the window.
|
|
319
|
-
if (typeof ResizeObserver === "function") {
|
|
662
|
+
if (typeof ResizeObserver === "function") {
|
|
663
|
+
sizeObs = new ResizeObserver(() => {
|
|
664
|
+
if (open) place();
|
|
665
|
+
});
|
|
666
|
+
sizeObs.observe(pop);
|
|
667
|
+
}
|
|
320
668
|
requestAnimationFrame(() => {
|
|
321
669
|
if (!open) return; // a same-tick close already ran
|
|
322
|
-
pop.classList.add("is-open");
|
|
670
|
+
pop.classList.add("is-open");
|
|
671
|
+
opts.onOpen && opts.onOpen();
|
|
672
|
+
place(); // render at real size, then re-place
|
|
323
673
|
// Focus moves into the pop, or Tab walks past the portaled node at the end of <body>.
|
|
324
674
|
// initialFocus wins over the first focusable; a pointer-opened pop takes focus quietly.
|
|
325
|
-
if (!pop.contains(
|
|
675
|
+
if (!pop.contains(activeIn(pop))) {
|
|
676
|
+
const f = (opts.initialFocus && opts.initialFocus()) || firstFocusable();
|
|
677
|
+
if (f) f.focus({ focusVisible: !pointerModality });
|
|
678
|
+
}
|
|
326
679
|
});
|
|
327
680
|
// Unmount watchdog: a host removing the panel while open (an SPA route change) would
|
|
328
681
|
// otherwise strand the portaled pop on screen.
|
|
329
|
-
requestAnimationFrame(function watch() {
|
|
682
|
+
requestAnimationFrame(function watch() {
|
|
683
|
+
if (!open) return;
|
|
684
|
+
if (!root.isConnected) return close();
|
|
685
|
+
requestAnimationFrame(watch);
|
|
686
|
+
});
|
|
330
687
|
// Capture phase: the panel's own stopPointerLeak would swallow the event before it bubbles.
|
|
331
|
-
|
|
688
|
+
// skip the opening click
|
|
689
|
+
setTimeout(() => {
|
|
690
|
+
if (open) document.addEventListener("pointerdown", onOutside, true);
|
|
691
|
+
}, 0);
|
|
332
692
|
document.addEventListener("keydown", onKey); // Esc closes from anywhere while open
|
|
333
|
-
|
|
693
|
+
// capture: inner scrollers move the row too
|
|
694
|
+
window.addEventListener("scroll", follow, true);
|
|
695
|
+
window.addEventListener("resize", reflow);
|
|
334
696
|
};
|
|
335
697
|
const close = () => {
|
|
336
|
-
if (activePopoverClose === close) {
|
|
698
|
+
if (activePopoverClose === close) {
|
|
699
|
+
activePopoverClose = null;
|
|
700
|
+
activePopoverTrigger = null;
|
|
701
|
+
}
|
|
337
702
|
// Focus stranded inside the pop returns to the trigger before the node is removed.
|
|
338
|
-
if (pop.contains(
|
|
339
|
-
open = st.open = false;
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
703
|
+
if (pop.contains(activeIn(pop))) refocus();
|
|
704
|
+
open = st.open = false;
|
|
705
|
+
root.classList.remove("is-open");
|
|
706
|
+
pop.classList.remove("is-open");
|
|
707
|
+
pop.inert = true;
|
|
708
|
+
trigger.setAttribute("aria-expanded", "false");
|
|
709
|
+
document.removeEventListener("pointerdown", onOutside, true);
|
|
710
|
+
document.removeEventListener("keydown", onKey);
|
|
711
|
+
window.removeEventListener("scroll", follow, true);
|
|
712
|
+
window.removeEventListener("resize", reflow);
|
|
713
|
+
window.removeEventListener("twk-retheme", recarry);
|
|
714
|
+
if (schemeObs) {
|
|
715
|
+
schemeObs.disconnect();
|
|
716
|
+
schemeObs = null;
|
|
717
|
+
}
|
|
718
|
+
sizeObs?.disconnect();
|
|
719
|
+
sizeObs = null;
|
|
720
|
+
removeTimer = setTimeout(() => {
|
|
721
|
+
if (!open) pop.remove();
|
|
722
|
+
}, 200); // remove the portaled node once it's faded out
|
|
344
723
|
};
|
|
345
|
-
|
|
724
|
+
// a disabled trigger stays inert to a synthetic click
|
|
725
|
+
trigger.addEventListener("click", () => {
|
|
726
|
+
if (trigger.disabled) return;
|
|
727
|
+
open ? close() : openPop();
|
|
728
|
+
});
|
|
346
729
|
return { open: openPop, close, isOpen: () => open, reflow };
|
|
347
730
|
}
|
|
348
731
|
|
|
349
732
|
// Fuzzy match for the filter: `q` is a substring of `text`, or within a small edit distance
|
|
350
733
|
// of a window of it ("blut" finds "blur"); the DP's zeroed first row lets a match start anywhere.
|
|
351
734
|
|
|
352
|
-
const REDUCE_MOTION: { matches: boolean } =
|
|
353
|
-
|
|
354
|
-
//
|
|
735
|
+
const REDUCE_MOTION: { matches: boolean } =
|
|
736
|
+
typeof matchMedia === "function" ? matchMedia("(prefers-reduced-motion: reduce)") : { matches: false };
|
|
737
|
+
// ── Motion tokens — EASE_SPRING is the JS literal of the CSS --twk-ease-spring: WAAPI and
|
|
738
|
+
// nodes outside a .twk-panel can't read the custom property. ──
|
|
355
739
|
const EASE_SPRING = "cubic-bezier(0.22, 1, 0.36, 1)";
|
|
356
740
|
const EASE_GLIDE = "cubic-bezier(0.34, 1.2, 0.64, 1)"; // press glide with a light overshoot
|
|
357
741
|
// Liquid pill stretch (segmented + tabs): a one-shot scaleX overshoot, origin pinned to the
|
|
@@ -359,124 +743,245 @@ const EASE_GLIDE = "cubic-bezier(0.34, 1.2, 0.64, 1)"; // press glide with a lig
|
|
|
359
743
|
const stretchPill = (pill: HTMLElement, dir: number) => {
|
|
360
744
|
if (REDUCE_MOTION.matches || typeof pill.animate !== "function") return;
|
|
361
745
|
pill.style.transformOrigin = dir > 0 ? "left center" : "right center";
|
|
362
|
-
pill.animate([{ transform: "scaleX(1)" }, { transform: "scaleX(1.18)" }, { transform: "scaleX(1)" }], {
|
|
746
|
+
pill.animate([{ transform: "scaleX(1)" }, { transform: "scaleX(1.18)" }, { transform: "scaleX(1)" }], {
|
|
747
|
+
duration: 300,
|
|
748
|
+
easing: EASE_SPRING,
|
|
749
|
+
});
|
|
363
750
|
};
|
|
364
751
|
// Slide a pill to the active child of `container`. The box reads come before the writes so a
|
|
365
752
|
// measure can't force an extra layout; the stretch runs on a real move.
|
|
366
753
|
const measurePill = (container: Element, pill: HTMLElement, animate?: boolean) => {
|
|
367
|
-
const a = container.querySelector<HTMLElement>('[data-active="true"]');
|
|
368
|
-
|
|
369
|
-
|
|
754
|
+
const a = container.querySelector<HTMLElement>('[data-active="true"]');
|
|
755
|
+
if (!a) return;
|
|
756
|
+
const left = a.offsetLeft,
|
|
757
|
+
width = a.offsetWidth,
|
|
758
|
+
prev = parseFloat(pill.style.left);
|
|
759
|
+
pill.style.left = left + "px";
|
|
760
|
+
pill.style.width = width + "px";
|
|
370
761
|
if (animate && Number.isFinite(prev) && prev !== left) stretchPill(pill, left > prev ? 1 : -1);
|
|
371
762
|
};
|
|
372
763
|
// Slider rubber-band (slider + interval): dragged past an end by more than a 32px dead zone, the
|
|
373
764
|
// track widens toward the cursor, up to 8px on a sqrt falloff over the next 200px. rect is the wrap's box at press.
|
|
374
|
-
const RUBBER_DEAD = 32,
|
|
765
|
+
const RUBBER_DEAD = 32,
|
|
766
|
+
RUBBER_RANGE = 200,
|
|
767
|
+
RUBBER_MAX = 8;
|
|
375
768
|
const rubberBand = (track: HTMLElement, rect: DOMRect, clientX: number) => {
|
|
376
769
|
const over = clientX < rect.left ? clientX - rect.left : clientX > rect.right ? clientX - rect.right : 0;
|
|
377
|
-
const s =
|
|
770
|
+
const s =
|
|
771
|
+
Math.sign(over) * RUBBER_MAX * Math.sqrt(Math.min(Math.max(0, Math.abs(over) - RUBBER_DEAD) / RUBBER_RANGE, 1));
|
|
378
772
|
track.style.width = s ? `calc(100% + ${Math.abs(s)}px)` : "";
|
|
379
773
|
track.style.transform = s < 0 ? `translateX(${s}px)` : "";
|
|
380
774
|
};
|
|
381
775
|
// Release: spring the track home, then drop the transition so the next drag tracks 1:1.
|
|
382
776
|
const rubberRelease = (track: HTMLElement) => {
|
|
383
777
|
track.style.transition = `width 0.35s ${EASE_SPRING}, transform 0.35s ${EASE_SPRING}`;
|
|
384
|
-
track.style.width = "";
|
|
385
|
-
|
|
778
|
+
track.style.width = "";
|
|
779
|
+
track.style.transform = "";
|
|
780
|
+
setTimeout(() => {
|
|
781
|
+
track.style.transition = "";
|
|
782
|
+
}, 360);
|
|
386
783
|
};
|
|
387
784
|
|
|
388
785
|
// Disable a row: `inert` goes on the children, not the row — an inert element hit-tests like
|
|
389
786
|
// pointer-events:none, so an inert row would never show its not-allowed cursor.
|
|
390
787
|
const setDisabled = (node: HTMLElement, d: boolean) => {
|
|
391
|
-
|
|
392
|
-
|
|
788
|
+
// a closed popover child keeps its own inert
|
|
789
|
+
node.classList.toggle("is-disabled", d);
|
|
790
|
+
for (const c of node.children) if (!c.classList.contains("twk-portal")) (c as HTMLElement).inert = d;
|
|
791
|
+
// an open pane is portaled out of reach of inert: a control disabled mid-edit closes it
|
|
792
|
+
if (d) closeActivePopover(node);
|
|
393
793
|
};
|
|
394
794
|
// Collapse a section: the fold class, aria-expanded, and `inert` on the body so hidden controls leave the tab order.
|
|
395
|
-
const setCollapsed = (root: Element, toggle: Element, body: HTMLElement, c: boolean) => {
|
|
795
|
+
const setCollapsed = (root: Element, toggle: Element, body: HTMLElement, c: boolean) => {
|
|
796
|
+
root.classList.toggle("is-collapsed", c);
|
|
797
|
+
toggle.setAttribute("aria-expanded", String(!c));
|
|
798
|
+
body.inert = c;
|
|
799
|
+
};
|
|
396
800
|
// …and animate the fold over only the visible height: content past the height cap or the window would otherwise stall,
|
|
397
801
|
// then snap. Without the Animations API, or with reduced motion, the CSS transition runs; `done` runs once settled.
|
|
398
802
|
type Folding = HTMLElement & { _twFold?: Animation | null };
|
|
399
803
|
const foldBody = (root: Element, toggle: Element, body: Folding, c: boolean, done?: () => void) => {
|
|
400
|
-
if (REDUCE_MOTION.matches || typeof body.animate !== "function") {
|
|
804
|
+
if (REDUCE_MOTION.matches || typeof body.animate !== "function") {
|
|
805
|
+
setCollapsed(root, toggle, body, c);
|
|
806
|
+
if (done) setTimeout(done, 370);
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
401
809
|
// Everything read before the class flips, so a collapse forces no layout after it.
|
|
402
|
-
const r = body.getBoundingClientRect(),
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
const
|
|
406
|
-
|
|
407
|
-
|
|
810
|
+
const r = body.getBoundingClientRect(),
|
|
811
|
+
from = r.height; // mid-fold, the height it's drawn at
|
|
812
|
+
// border-box, so the cap includes the padding
|
|
813
|
+
const panel = root.closest<HTMLElement>(".twk-panel"),
|
|
814
|
+
cs = panel && getComputedStyle(panel),
|
|
815
|
+
maxH = cs ? parseFloat(cs.maxHeight) : NaN;
|
|
816
|
+
const capBottom =
|
|
817
|
+
cs && panel.dataset.mode === "floating" && maxH >= 0
|
|
818
|
+
? panel.getBoundingClientRect().top + maxH - parseFloat(cs.paddingBottom) - parseFloat(cs.borderBottomWidth)
|
|
819
|
+
: Infinity;
|
|
820
|
+
const room = Math.max(0, Math.min(window.innerHeight, capBottom) - r.top),
|
|
821
|
+
easing = getComputedStyle(body).getPropertyValue("--twk-ease-fold").trim();
|
|
822
|
+
body._twFold?.cancel();
|
|
823
|
+
body._twFold = null;
|
|
824
|
+
body.style.transition = "none";
|
|
825
|
+
body.style.gridTemplateRows = "";
|
|
408
826
|
setCollapsed(root, toggle, body, c);
|
|
409
827
|
let to = 0; // collapsed is always 0
|
|
410
828
|
if (!c) {
|
|
411
829
|
// Finish the folder's padding transition first: it's hidden under the fold, and the end height must include it.
|
|
412
|
-
for (const a of body.firstElementChild?.getAnimations?.() ?? [])
|
|
830
|
+
for (const a of body.firstElementChild?.getAnimations?.() ?? [])
|
|
831
|
+
if ((a as CSSTransition).transitionProperty !== "opacity") a.finish();
|
|
413
832
|
to = body.getBoundingClientRect().height;
|
|
414
833
|
}
|
|
415
|
-
const f = Math.min(from, room),
|
|
834
|
+
const f = Math.min(from, room),
|
|
835
|
+
t = Math.min(to, room);
|
|
416
836
|
// fill: forwards holds the last frame until the 1fr comes off; otherwise one frame would draw the whole body.
|
|
417
|
-
const finish = () => {
|
|
837
|
+
const finish = () => {
|
|
838
|
+
body.style.gridTemplateRows = "";
|
|
839
|
+
void body.offsetHeight;
|
|
840
|
+
body._twFold?.cancel();
|
|
841
|
+
body._twFold = null;
|
|
842
|
+
body.style.transition = "";
|
|
843
|
+
done?.();
|
|
844
|
+
};
|
|
418
845
|
if (Math.abs(f - t) < 1) return finish();
|
|
419
846
|
body.style.gridTemplateRows = "1fr"; // the rows stay laid out under the fold, clipped from the bottom
|
|
420
|
-
const a = body._twFold = body.animate([{ height: f + "px" }, { height: t + "px" }], {
|
|
421
|
-
|
|
847
|
+
const a = (body._twFold = body.animate([{ height: f + "px" }, { height: t + "px" }], {
|
|
848
|
+
duration: 350,
|
|
849
|
+
easing,
|
|
850
|
+
fill: "forwards",
|
|
851
|
+
}));
|
|
852
|
+
a.onfinish = () => {
|
|
853
|
+
if (body._twFold === a) finish();
|
|
854
|
+
};
|
|
422
855
|
};
|
|
423
856
|
// The button whose (stringified) value is active — or, when none matches, the first: the
|
|
424
857
|
// group's roving tab stop. Only an empty group gives −1.
|
|
425
|
-
const activeIndex = (btns: RadioBtn[], value: unknown) => {
|
|
858
|
+
const activeIndex = (btns: RadioBtn[], value: unknown) => {
|
|
859
|
+
const i = btns.findIndex((b) => b.dataset.value === String(value));
|
|
860
|
+
return i < 0 && btns.length ? 0 : i;
|
|
861
|
+
};
|
|
426
862
|
// Reflect a single-select value onto a radio group: data-active, aria-checked, and a roving
|
|
427
863
|
// tabindex. Paint follows the real match; the tab stop follows activeIndex's fallback.
|
|
428
|
-
const setRadioActive = (btns: RadioBtn[], value: unknown) => {
|
|
864
|
+
const setRadioActive = (btns: RadioBtn[], value: unknown) => {
|
|
865
|
+
const stop = activeIndex(btns, value);
|
|
866
|
+
btns.forEach((b, k) => {
|
|
867
|
+
const on = b.dataset.value === String(value);
|
|
868
|
+
b.dataset.active = String(on);
|
|
869
|
+
b.setAttribute("aria-checked", String(on));
|
|
870
|
+
b.tabIndex = k === stop ? 0 : -1;
|
|
871
|
+
});
|
|
872
|
+
};
|
|
429
873
|
// A single-select radio button. _twVal carries the option's real value — dataset stringifies,
|
|
430
874
|
// so a keyboard pick off dataset.value turned a numeric option into a string.
|
|
431
|
-
const radioButton = (cls: string, o: Option, onPick: (v: string | number | undefined) => void) => {
|
|
875
|
+
const radioButton = (cls: string, o: Option, onPick: (v: string | number | undefined) => void) => {
|
|
876
|
+
const b: RadioBtn = txt("button", cls, optLabel(o));
|
|
877
|
+
b.setAttribute("role", "radio");
|
|
878
|
+
b._twVal = optValue(o);
|
|
879
|
+
b.dataset.value = String(b._twVal);
|
|
880
|
+
b.addEventListener("click", () => onPick(b._twVal));
|
|
881
|
+
return b;
|
|
882
|
+
};
|
|
432
883
|
// Arrow-key navigation → the next index, or −1 when the key isn't the group's. cols > 0 jumps
|
|
433
884
|
// ↑/↓ by a row (the radio grid); 0 treats ↑/↓ as ←/→; < 0 leaves ↑/↓ to the page (the tab bar).
|
|
434
885
|
const navIndex = (key: string, i: number, n: number, cols = 0) => {
|
|
435
886
|
switch (key) {
|
|
436
|
-
case "ArrowRight":
|
|
437
|
-
|
|
438
|
-
case "
|
|
439
|
-
|
|
440
|
-
case "
|
|
441
|
-
|
|
887
|
+
case "ArrowRight":
|
|
888
|
+
return (i + 1) % n;
|
|
889
|
+
case "ArrowLeft":
|
|
890
|
+
return (i - 1 + n) % n;
|
|
891
|
+
case "ArrowDown":
|
|
892
|
+
return cols < 0 ? -1 : cols ? (i + cols < n ? i + cols : i) : (i + 1) % n;
|
|
893
|
+
case "ArrowUp":
|
|
894
|
+
return cols < 0 ? -1 : cols ? (i - cols >= 0 ? i - cols : i) : (i - 1 + n) % n;
|
|
895
|
+
case "Home":
|
|
896
|
+
return 0;
|
|
897
|
+
case "End":
|
|
898
|
+
return n - 1;
|
|
442
899
|
}
|
|
443
900
|
return -1;
|
|
444
901
|
};
|
|
445
902
|
// ── Segmented control — a single-select pill row (the boolean toggle, the spring's mode switch). ──
|
|
446
|
-
// `onIs`: the boolean toggle, a two-segment row that is one switch to a screen reader (role="switch", aria-checked, one
|
|
447
|
-
// stop, Space or Enter flips it) rather than "radio button, On, 2 of 2"; its Off/On segments stay as the pointer's
|
|
903
|
+
// `onIs`: the boolean toggle, a two-segment row that is one switch to a screen reader (role="switch", aria-checked, one
|
|
904
|
+
// tab stop, Space or Enter flips it) rather than "radio button, On, 2 of 2"; its Off/On segments stay as the pointer's
|
|
905
|
+
// targets.
|
|
448
906
|
function createSegmented(options: Option[], value: unknown, onChange: OnChange, ariaLabel?: string, onIs?: unknown) {
|
|
449
|
-
const sw = onIs !== undefined,
|
|
907
|
+
const sw = onIs !== undefined,
|
|
908
|
+
seg = el("div", "twk-seg");
|
|
909
|
+
seg.setAttribute("role", sw ? "switch" : "radiogroup");
|
|
450
910
|
if (ariaLabel) seg.setAttribute("aria-label", ariaLabel);
|
|
451
911
|
if (sw) seg.tabIndex = 0;
|
|
452
|
-
const pill = el("div", "
|
|
912
|
+
const pill = el("div", "twk-seg-pill");
|
|
453
913
|
seg.append(pill);
|
|
454
|
-
|
|
914
|
+
// lazy `set` — it's declared below; a clicked segment hands focus to the switch, never parks it hidden
|
|
915
|
+
const btns = options.map((o) => {
|
|
916
|
+
const b = radioButton("twk-seg-btn", o, (v) => set(v));
|
|
917
|
+
if (sw) {
|
|
918
|
+
b.setAttribute("aria-hidden", "true");
|
|
919
|
+
b.addEventListener("focus", () => seg.focus());
|
|
920
|
+
}
|
|
921
|
+
seg.append(b);
|
|
922
|
+
return b;
|
|
923
|
+
});
|
|
455
924
|
const measure = (animate?: boolean) => measurePill(seg, pill, animate);
|
|
456
|
-
const reflect = () => {
|
|
457
|
-
|
|
925
|
+
const reflect = () => {
|
|
926
|
+
setRadioActive(btns, value);
|
|
927
|
+
if (sw) {
|
|
928
|
+
seg.setAttribute("aria-checked", String(value === onIs));
|
|
929
|
+
btns.forEach((b) => (b.tabIndex = -1));
|
|
930
|
+
}
|
|
931
|
+
measure(true);
|
|
932
|
+
};
|
|
933
|
+
// null clears; a value matching no segment is ignored
|
|
934
|
+
const set = (v: unknown, fire = true) => {
|
|
935
|
+
if (v != null && !btns.some((b) => b._twVal === v)) return;
|
|
936
|
+
value = v;
|
|
937
|
+
reflect();
|
|
938
|
+
if (fire) onChange(v);
|
|
939
|
+
};
|
|
458
940
|
seg.addEventListener("keydown", (e) => {
|
|
459
|
-
const i = activeIndex(btns, value);
|
|
460
|
-
if (
|
|
941
|
+
const i = activeIndex(btns, value);
|
|
942
|
+
if (i < 0) return;
|
|
943
|
+
if (sw && (e.key === " " || e.key === "Enter")) {
|
|
944
|
+
e.preventDefault();
|
|
945
|
+
set(btns[1 - i]._twVal);
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
461
948
|
// A switch's arrows point at a side, Off or On, and stop there: wrapping, → on On turned it off.
|
|
462
|
-
const j = sw
|
|
463
|
-
|
|
949
|
+
const j = sw
|
|
950
|
+
? (({ ArrowRight: 1, ArrowUp: 1, End: 1, ArrowLeft: 0, ArrowDown: 0, Home: 0 } as Record<string, number>)[
|
|
951
|
+
e.key
|
|
952
|
+
] ?? -1)
|
|
953
|
+
: navIndex(e.key, i, btns.length);
|
|
954
|
+
if (j < 0) return;
|
|
955
|
+
// _twVal: the real, possibly non-string value
|
|
956
|
+
e.preventDefault();
|
|
957
|
+
set(btns[j]._twVal);
|
|
958
|
+
(sw ? seg : btns[j]).focus();
|
|
464
959
|
});
|
|
465
960
|
reflect();
|
|
466
|
-
|
|
961
|
+
// once laid out, again when fonts land
|
|
962
|
+
onReady(() => {
|
|
963
|
+
measure();
|
|
964
|
+
seg.classList.add("is-ready");
|
|
965
|
+
});
|
|
467
966
|
// A tab page revealing this control re-measures the pill, built at 0×0 while display:none.
|
|
468
|
-
onLive(seg, [[window, "
|
|
967
|
+
onLive(seg, [[window, "twk-reflow"]], () => measure());
|
|
469
968
|
return { el: seg, set: (v: unknown) => set(v, false), get: () => value };
|
|
470
969
|
}
|
|
471
970
|
|
|
472
971
|
// One stroke-icon shell for every inline SVG. It lives here, not in icons.ts, because a lazy
|
|
473
972
|
// control imports it: anything both core and a lazy chunk import must sit in the shared chunk,
|
|
474
973
|
// or esbuild hoists the module into a third chunk every basic panel has to fetch.
|
|
475
|
-
const icon = (body: string, cls = "", width = 2, box = 24) =>
|
|
974
|
+
const icon = (body: string, cls = "", width = 2, box = 24) =>
|
|
975
|
+
`<svg${cls ? ` class="${cls}"` : ""} viewBox="0 0 ${box} ${box}" fill="none" stroke="currentColor" stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
|
|
476
976
|
|
|
477
977
|
// The handle a display/action control returns: no value, so build skips entry/reset/persist
|
|
478
978
|
// wiring. `destroy` lets panel.destroy() stop a control's loop even if the panel never connected.
|
|
479
|
-
const blade = (el: HTMLElement, destroy?: () => void): Control => ({
|
|
979
|
+
const blade = (el: HTMLElement, destroy?: () => void): Control => ({
|
|
980
|
+
el,
|
|
981
|
+
set: () => {},
|
|
982
|
+
get: () => undefined,
|
|
983
|
+
destroy,
|
|
984
|
+
});
|
|
480
985
|
|
|
481
986
|
// Own-key lookup for objects used as maps: "toString" must miss, not hit Object.prototype.
|
|
482
987
|
const hasOwn = <T extends object>(o: T, k: PropertyKey): k is keyof T => Object.prototype.hasOwnProperty.call(o, k);
|
|
@@ -484,42 +989,149 @@ const hasOwn = <T extends object>(o: T, k: PropertyKey): k is keyof T => Object.
|
|
|
484
989
|
const isReservedKey = (k: string) => k === "__proto__" || k === "constructor" || k === "prototype";
|
|
485
990
|
// Identity for primitives, structural for the object-valued controls (a fresh object each
|
|
486
991
|
// get()). Gates notify(): a same-value echo from an on() listener recursed to stack exhaustion.
|
|
487
|
-
const valueChanged = (a: unknown, b: unknown) =>
|
|
992
|
+
const valueChanged = (a: unknown, b: unknown) =>
|
|
993
|
+
a !== b && !(isObj(a) && isObj(b) && JSON.stringify(a) === JSON.stringify(b));
|
|
488
994
|
// Reset: the form the control OPENED on first (it covers a value set() can't take as-is),
|
|
489
995
|
// then the authored value on top, so whatever the control CAN take lands exactly.
|
|
490
|
-
|
|
491
|
-
const
|
|
996
|
+
// a control, or the panel's entry for one
|
|
997
|
+
const restoreDefault = (ctrl: Pick<Control, "set">, raw: unknown, def: unknown) => {
|
|
998
|
+
ctrl.set(def);
|
|
999
|
+
ctrl.set(raw);
|
|
1000
|
+
};
|
|
1001
|
+
// read by the spring and motion controls, and their markup
|
|
1002
|
+
const SPRING_KEYS = ["stiffness", "damping", "mass", "visualDuration", "bounce"];
|
|
492
1003
|
// ── Control registry — type → constructor. Core controls register on load; a lazy one when
|
|
493
1004
|
// its module is imported, so the loaded set drives what can be built. ──
|
|
494
|
-
|
|
495
|
-
|
|
1005
|
+
// heterogeneous by design — see ControlCtor; null-prototype, so a type named "toString" finds nothing
|
|
1006
|
+
const REGISTRY: Record<string, ControlCtor<any, any>> = Object.create(null);
|
|
1007
|
+
export const registerControl = (type: string, ctor: ControlCtor<any, any>) => {
|
|
1008
|
+
REGISTRY[type] = ctor;
|
|
1009
|
+
};
|
|
496
1010
|
export const getControl = <C = Control, M = Meta>(type: string): ControlCtor<M, C> | undefined => REGISTRY[type];
|
|
497
1011
|
export const controlTypes = () => Object.keys(REGISTRY);
|
|
498
1012
|
// The markup chunk's enhance(), registered when markup.ts loads (enhance.ts fetches it, or single.ts imports it).
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
export const
|
|
1013
|
+
// any: the kit's type lives in markup.ts, which imports this
|
|
1014
|
+
let MARKUP: ((root: Document | Element, kit: any, strings?: Words) => Promise<void>) | null = null;
|
|
1015
|
+
export const setMarkup = (fn: typeof MARKUP) => {
|
|
1016
|
+
MARKUP = fn;
|
|
1017
|
+
};
|
|
1018
|
+
export const getMarkup = <K>() =>
|
|
1019
|
+
MARKUP as ((root: Document | Element, kit: K, strings?: Words) => Promise<void>) | null;
|
|
1020
|
+
|
|
1021
|
+
// ── Words: opts.strings over the English defaults, for the chrome and every control (types.ts Strings). A panel builds
|
|
1022
|
+
// under its strings (using()), and each control takes the set in force as it's made (words()), so a pane, note or
|
|
1023
|
+
// announcement it makes later speaks the same. The words function fills a key's {placeholders}, or calls a language's
|
|
1024
|
+
// own function with them, for plurals that need more than one form. ──
|
|
1025
|
+
type Word = string | ((v: any) => string);
|
|
1026
|
+
type Words = Strings;
|
|
1027
|
+
let inForce: Words = {};
|
|
1028
|
+
const using = <T>(s: Words | null | undefined, fn: () => T): T => {
|
|
1029
|
+
const was = inForce;
|
|
1030
|
+
inForce = s || was;
|
|
1031
|
+
try {
|
|
1032
|
+
return fn();
|
|
1033
|
+
} finally {
|
|
1034
|
+
inForce = was;
|
|
1035
|
+
}
|
|
1036
|
+
};
|
|
1037
|
+
// `.s` is the set it reads, for a control that rebuilds a part after its build to do so under it again (using(tr.s,
|
|
1038
|
+
// …)).
|
|
1039
|
+
const words = (s: Words | null | undefined = inForce) =>
|
|
1040
|
+
Object.assign(
|
|
1041
|
+
(k: string, d: Word, v?: Record<string, unknown>): string => {
|
|
1042
|
+
const w = (s as Record<string, Word | undefined> | null | undefined)?.[k] ?? d;
|
|
1043
|
+
return typeof w === "function"
|
|
1044
|
+
? w(v)
|
|
1045
|
+
: v
|
|
1046
|
+
? w.replace(/\{(\w+)\}/g, (m, n) => (hasOwn(v, n) ? String(v[n]) : m))
|
|
1047
|
+
: w;
|
|
1048
|
+
},
|
|
1049
|
+
{ s },
|
|
1050
|
+
);
|
|
1051
|
+
type Tr = ReturnType<typeof words>;
|
|
502
1052
|
|
|
503
|
-
// What a click or a run of keys did, in words, for a screen reader: one visually hidden status region for the page
|
|
504
|
-
//
|
|
505
|
-
let srEl: HTMLDivElement | null = null,
|
|
1053
|
+
// What a click or a run of keys did, in words, for a screen reader: one visually hidden status region for the page.
|
|
1054
|
+
// It's cleared and written a beat later, so the same words twice are read twice.
|
|
1055
|
+
let srEl: HTMLDivElement | null = null,
|
|
1056
|
+
srT = 0;
|
|
506
1057
|
const announce = (msg: string) => {
|
|
507
|
-
if (!srEl) {
|
|
1058
|
+
if (!srEl) {
|
|
1059
|
+
srEl = el("div", "twk-sr");
|
|
1060
|
+
srEl.setAttribute("role", "status");
|
|
1061
|
+
}
|
|
508
1062
|
if (!srEl.isConnected) document.body.appendChild(srEl); // re-home after a host replaced <body>'s content
|
|
509
|
-
srEl.textContent = "";
|
|
1063
|
+
srEl.textContent = "";
|
|
1064
|
+
clearTimeout(srT);
|
|
1065
|
+
srT = setTimeout(() => {
|
|
1066
|
+
srEl!.textContent = msg;
|
|
1067
|
+
}, 100);
|
|
510
1068
|
};
|
|
511
1069
|
|
|
512
1070
|
export {
|
|
513
|
-
titleCase,
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
1071
|
+
titleCase,
|
|
1072
|
+
clamp,
|
|
1073
|
+
isObj,
|
|
1074
|
+
isColorStr,
|
|
1075
|
+
stepPrecision,
|
|
1076
|
+
gridEnds,
|
|
1077
|
+
roundToStep,
|
|
1078
|
+
inferStep,
|
|
1079
|
+
defaultRange,
|
|
1080
|
+
normalizeRange,
|
|
1081
|
+
notice,
|
|
1082
|
+
sayNow,
|
|
1083
|
+
rangeStep,
|
|
1084
|
+
overlapsText,
|
|
1085
|
+
optValue,
|
|
1086
|
+
optLabel,
|
|
1087
|
+
json,
|
|
1088
|
+
el,
|
|
1089
|
+
btn,
|
|
1090
|
+
txt,
|
|
1091
|
+
stopPointerLeak,
|
|
1092
|
+
onReady,
|
|
1093
|
+
onLive,
|
|
1094
|
+
requestReflow,
|
|
1095
|
+
wireHoverClass,
|
|
1096
|
+
popover,
|
|
1097
|
+
closeActivePopover,
|
|
1098
|
+
resolveTheme,
|
|
1099
|
+
applyThemeVars,
|
|
1100
|
+
carryScheme,
|
|
1101
|
+
carrySkin,
|
|
1102
|
+
home,
|
|
1103
|
+
activeIn,
|
|
1104
|
+
holds,
|
|
1105
|
+
portalTo,
|
|
1106
|
+
isolate,
|
|
1107
|
+
setCollapsed,
|
|
1108
|
+
foldBody,
|
|
1109
|
+
setDisabled,
|
|
1110
|
+
activeIndex,
|
|
1111
|
+
setRadioActive,
|
|
1112
|
+
radioButton,
|
|
1113
|
+
navIndex,
|
|
1114
|
+
createSegmented,
|
|
1115
|
+
using,
|
|
1116
|
+
words,
|
|
1117
|
+
blade,
|
|
1118
|
+
quietFocus,
|
|
1119
|
+
measurePill,
|
|
1120
|
+
rubberBand,
|
|
1121
|
+
rubberRelease,
|
|
1122
|
+
REDUCE_MOTION,
|
|
1123
|
+
EASE_SPRING,
|
|
1124
|
+
EASE_GLIDE,
|
|
1125
|
+
icon,
|
|
1126
|
+
announce,
|
|
1127
|
+
hasOwn,
|
|
1128
|
+
isReservedKey,
|
|
1129
|
+
valueChanged,
|
|
1130
|
+
restoreDefault,
|
|
1131
|
+
SPRING_KEYS,
|
|
1132
|
+
};
|
|
1133
|
+
export type { OnChange, ThemeVars, PanelEl, RadioBtn, Built, NumSpec, NumField, Popover, ControlCtor, Words, Tr };
|
|
521
1134
|
// Re-exported for the chunking: core and the gradient control both import easing.ts, and a
|
|
522
1135
|
// module only those two reach would become a chunk of its own (test/chunks.test.mjs holds it
|
|
523
1136
|
// to three). Core exports from easing.ts directly so the .d.ts prune skips shared.ts internals.
|
|
524
1137
|
export { gradientCss, gradientStops } from "./easing.js";
|
|
525
|
-
|