tweakit 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +31 -29
- package/README.md +55 -29
- package/THIRD-PARTY-NOTICES.md +0 -1
- package/dist/tweaks/auto.js +2 -0
- package/dist/tweaks/auto.js.map +6 -0
- package/dist/tweaks/bezier-JFEZMCLP.js +2 -0
- package/dist/tweaks/bezier-JFEZMCLP.js.map +6 -0
- package/dist/tweaks/chunk-6UO4DHBN.js +2 -0
- package/dist/tweaks/chunk-6UO4DHBN.js.map +6 -0
- package/dist/tweaks/chunk-DIQMLBAS.js +2 -0
- package/dist/tweaks/chunk-DIQMLBAS.js.map +6 -0
- package/dist/tweaks/chunk-XE6OUJBG.js +2 -0
- package/dist/tweaks/chunk-XE6OUJBG.js.map +6 -0
- package/dist/tweaks/clip-WW7JYO4X.js +2 -0
- package/dist/tweaks/clip-WW7JYO4X.js.map +6 -0
- package/dist/tweaks/color-YMMFURP6.js +2 -0
- package/dist/tweaks/color-YMMFURP6.js.map +6 -0
- package/dist/tweaks/core.js +2 -1
- package/dist/tweaks/core.js.map +6 -0
- package/dist/tweaks/{fold-UGN7TXGX.js → fold-U6YRZLCJ.js} +2 -1
- package/dist/tweaks/fold-U6YRZLCJ.js.map +6 -0
- package/dist/tweaks/gradient-JER7PW4D.js +2 -0
- package/dist/tweaks/gradient-JER7PW4D.js.map +6 -0
- package/dist/tweaks/heavy-CLD7UAVI.js +2 -0
- package/dist/tweaks/heavy-CLD7UAVI.js.map +6 -0
- package/dist/tweaks/image-H6QSLGO3.js +4 -0
- package/dist/tweaks/image-H6QSLGO3.js.map +6 -0
- package/dist/tweaks/image-store-WRKY2NPU.js +2 -0
- package/dist/tweaks/image-store-WRKY2NPU.js.map +6 -0
- package/dist/tweaks/interval-TEUHQGZN.js +2 -0
- package/dist/tweaks/interval-TEUHQGZN.js.map +6 -0
- package/dist/tweaks/markup-NIBJD6E2.js +2 -0
- package/dist/tweaks/markup-NIBJD6E2.js.map +6 -0
- package/dist/tweaks/monitor-3BY7YCGX.js +3 -0
- package/dist/tweaks/monitor-3BY7YCGX.js.map +6 -0
- package/dist/tweaks/motion-FQPUBKKM.js +2 -0
- package/dist/tweaks/motion-FQPUBKKM.js.map +6 -0
- package/dist/tweaks/plot-2HBNBZSG.js +3 -0
- package/dist/tweaks/plot-2HBNBZSG.js.map +6 -0
- package/dist/tweaks/point-VV6XV3I6.js +2 -0
- package/dist/tweaks/point-VV6XV3I6.js.map +6 -0
- package/dist/tweaks/shadow-5E4E55I2.js +2 -0
- package/dist/tweaks/shadow-5E4E55I2.js.map +6 -0
- package/dist/tweaks/sides-P3STEVYY.js +2 -0
- package/dist/tweaks/sides-P3STEVYY.js.map +6 -0
- package/dist/tweaks/spring-HUVSXNVN.js +2 -0
- package/dist/tweaks/spring-HUVSXNVN.js.map +6 -0
- package/dist/tweaks/tabs-76FW7LQ7.js +2 -0
- package/dist/tweaks/tabs-76FW7LQ7.js.map +6 -0
- package/dist/tweaks.css +1 -1
- package/dist/tweaks.js +6 -3
- package/dist/tweaks.js.map +6 -0
- package/dist/types/tweaks/easing.d.ts +8 -11
- package/dist/types/tweaks/panel.d.ts +6 -4
- package/dist/types/tweaks/schema.d.ts +4 -8
- package/dist/types/tweaks/standalone.d.ts +3 -3
- package/dist/types/tweaks/types.d.ts +278 -101
- package/package.json +27 -5
- package/src/tweaks/auto.ts +12 -0
- package/src/tweaks/clip.ts +94 -0
- package/src/tweaks/controls/basic.ts +403 -0
- package/src/tweaks/controls/bezier.ts +99 -0
- package/src/tweaks/controls/color.ts +412 -0
- package/src/tweaks/controls/gradient.ts +240 -0
- package/src/tweaks/controls/image.ts +299 -0
- package/src/tweaks/controls/interval.ts +104 -0
- package/src/tweaks/controls/monitor.ts +105 -0
- package/src/tweaks/controls/motion.ts +141 -0
- package/src/tweaks/controls/plot.ts +220 -0
- package/src/tweaks/controls/point.ts +119 -0
- package/src/tweaks/controls/shadow.ts +83 -0
- package/src/tweaks/controls/sides.ts +86 -0
- package/src/tweaks/controls/spring.ts +148 -0
- package/src/tweaks/controls/tabs.ts +47 -0
- package/src/tweaks/core.ts +9 -0
- package/src/tweaks/easing.ts +93 -0
- package/src/tweaks/enhance.ts +20 -0
- package/src/tweaks/feedback.ts +119 -0
- package/src/tweaks/fold.ts +21 -0
- package/src/tweaks/heavy.ts +302 -0
- package/src/tweaks/icons.ts +19 -0
- package/src/tweaks/image-store.ts +101 -0
- package/src/tweaks/lazy.ts +46 -0
- package/src/tweaks/markup.ts +154 -0
- package/src/tweaks/panel.ts +869 -0
- package/src/tweaks/schema.ts +189 -0
- package/src/tweaks/shared.ts +525 -0
- package/src/tweaks/single.ts +22 -0
- package/src/tweaks/standalone.ts +89 -0
- package/src/tweaks/types.ts +526 -0
- package/src/tweaks.css +959 -0
- package/src/wide-gamut.ts +310 -0
- package/dist/tweaks/bezier-2RABC6LD.js +0 -1
- package/dist/tweaks/chunk-4ATI34PB.js +0 -1
- package/dist/tweaks/chunk-7OSCMZ2J.js +0 -1
- package/dist/tweaks/chunk-W3JNIJ2F.js +0 -1
- package/dist/tweaks/clip-ECGCECEC.js +0 -1
- package/dist/tweaks/colour-2RE3FQ5E.js +0 -1
- package/dist/tweaks/gradient-DB6XTDHO.js +0 -1
- package/dist/tweaks/heavy-PKOMHB3C.js +0 -1
- package/dist/tweaks/image-7MCLBOZ3.js +0 -1
- package/dist/tweaks/interval-L2E4L3AZ.js +0 -1
- package/dist/tweaks/markup-ZJJ6CCWM.js +0 -1
- package/dist/tweaks/monitor-IEK742PG.js +0 -2
- package/dist/tweaks/motion-N5UNVR55.js +0 -1
- package/dist/tweaks/plot-VVTOY3RA.js +0 -2
- package/dist/tweaks/point-YMMHSLO7.js +0 -1
- package/dist/tweaks/shadow-IKYOV3VN.js +0 -1
- package/dist/tweaks/sides-FH227Z54.js +0 -1
- package/dist/tweaks/spring-3PKAFIH7.js +0 -1
- package/dist/tweaks/tabs-BKK5NIIY.js +0 -1
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
/* Tweaks — shared building blocks: DOM + math helpers, theming, the popover shell and
|
|
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";
|
|
4
|
+
import type { Meta } from "./schema.js";
|
|
5
|
+
|
|
6
|
+
// ── Internal types ──
|
|
7
|
+
/** A control's change callback. */
|
|
8
|
+
type OnChange = (value: unknown) => void;
|
|
9
|
+
/** The resolved `--tw-*` custom-property map a theme turns into (resolveTheme). */
|
|
10
|
+
type ThemeVars = Record<string, string>;
|
|
11
|
+
/** A panel root; panel.ts hangs its live theme, edit hook and ready promise on the node. */
|
|
12
|
+
interface PanelEl extends HTMLElement { _twTheme?: ThemeVars | null; ready?: Promise<Panel> }
|
|
13
|
+
/** A node applyThemeVars has written to; remembers which properties, to clear them next time. */
|
|
14
|
+
interface ThemedEl extends HTMLElement { _twAppliedVars?: string[] | null }
|
|
15
|
+
/** A single-select radio button carrying its option's real value (dataset stringifies). */
|
|
16
|
+
interface RadioBtn extends HTMLButtonElement { _twVal?: string | number }
|
|
17
|
+
/** setAttribute() / style.setProperty() taking a number as well as a string (the DOM
|
|
18
|
+
* stringifies either). Type-level only. */
|
|
19
|
+
interface Stringifying { setAttribute(name: string, value: string | number): void; style: { setProperty(name: string, value: string | number | null, priority?: string): void } }
|
|
20
|
+
type Built<E extends Element> = E & Stringifying;
|
|
21
|
+
/** The labeled numeric field's spec (numField). */
|
|
22
|
+
interface NumSpec { label: string; value?: unknown; min?: unknown; max?: unknown; step?: unknown; soft?: boolean; row?: boolean; unit?: string } // unit: muted suffix, also in the accessible name
|
|
23
|
+
/** The numeric field's handle: set() coerces and fits, get() reads the fitted number. */
|
|
24
|
+
interface NumField { el: HTMLDivElement; set(v: number | string): void; get(): number; range: [number, number] }
|
|
25
|
+
/** The popover shell's handle (popover). */
|
|
26
|
+
interface Popover { open(): void; close(): void; isOpen(): boolean; reflow(): void }
|
|
27
|
+
/** A control constructor: normalized meta in, handle out. The registry holds every one under
|
|
28
|
+
* the loose form; a lookup names the shape it expects — `getControl<TabsControl>("tabs")`. */
|
|
29
|
+
type ControlCtor<M = Meta, C = Control> = (meta: M, onChange?: OnChange) => C;
|
|
30
|
+
|
|
31
|
+
// ── helpers ──
|
|
32
|
+
// A word starts at a camel hump or where a capital opens a lowercase word ("myURLParser" → "My URL Parser").
|
|
33
|
+
// A key opening on one lowercase letter and capitals, or on an Apple OS, is a name written as meant ("sRGB", "iOS",
|
|
34
|
+
// "macOS", not "S RGB"), kept whole and the rest split after it ("sRGBGamut" → "sRGB Gamut"); a word ending in an
|
|
35
|
+
// acronym splits as any other ("showFPS" → "Show FPS").
|
|
36
|
+
const humanize = (s: string) => s.replace(/([a-z])(?=[A-Z])|([A-Z\d])(?=[A-Z][a-z])/g, "$1$2 ").replace(/^./, (c) => c.toUpperCase()).trim();
|
|
37
|
+
const titleCase = (s: string) => { const m = /^(?:[a-z][A-Z]{2,}?|(?:mac|tv|watch|iPad|vision)OS)(?=[A-Z][a-z]|[^A-Za-z]|$)/.exec(s); return m ? `${m[0]} ${humanize(s.slice(m[0].length))}`.trim() : humanize(s); };
|
|
38
|
+
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
|
|
39
|
+
const isObj = (v: unknown): v is Record<string, any> => !!v && typeof v === "object";
|
|
40
|
+
// Hex or any CSS color function: routes a schema string to the color control.
|
|
41
|
+
const isColorStr = (v: unknown) => typeof v === "string" && /^(#([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
|
+
// toFixed() throws past 100 digits, and a readout past 20 decimals is noise.
|
|
43
|
+
const MAX_FIXED = 20;
|
|
44
|
+
const stepPrecision = (step: number) => {
|
|
45
|
+
const t = String(step);
|
|
46
|
+
// String(1e-7) has no "." — read the exponent, or fine steps round to whole numbers.
|
|
47
|
+
const e = /e-(\d+)$/i.exec(t);
|
|
48
|
+
if (e) return Math.min(MAX_FIXED, Number(e[1]) + (t.split("e")[0].split(".")[1] || "").length);
|
|
49
|
+
const i = t.indexOf(".");
|
|
50
|
+
return i === -1 ? 0 : Math.min(MAX_FIXED, t.length - i - 1);
|
|
51
|
+
};
|
|
52
|
+
// Round to the min-anchored step grid. The anchor is rounded to the step's decimals too: a
|
|
53
|
+
// finer min put every grid point half a decimal off, and each re-round walked the value up.
|
|
54
|
+
const roundToStep = (v: number, min: number, step: number) => {
|
|
55
|
+
if (!(step > 0)) return v;
|
|
56
|
+
const p = stepPrecision(step), a = Number((+min).toFixed(p));
|
|
57
|
+
const n = Math.round((v - a) / step); if (!Number.isFinite(n)) return v; // a huge value over a tiny step overflows; keep the raw
|
|
58
|
+
return Number((a + n * step).toFixed(p));
|
|
59
|
+
};
|
|
60
|
+
// The reachable ends of a [min, max] step grid, so a clamp never lands off-grid; a range too
|
|
61
|
+
// narrow to hold a grid point pins to min. ±Infinity = open.
|
|
62
|
+
const gridEnds = (min: number, max: number, step: number, anchor = min): [number, number] => {
|
|
63
|
+
const r = (v: number) => roundToStep(v, anchor, step);
|
|
64
|
+
let lo = Number.isFinite(min) ? r(min) : -Infinity, hi = Number.isFinite(max) ? r(max) : Infinity;
|
|
65
|
+
// 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 0.31.
|
|
66
|
+
const eps = step * 1e-6;
|
|
67
|
+
if (lo < min - eps) lo = r(lo + step);
|
|
68
|
+
if (hi > max + eps) hi = r(hi - step);
|
|
69
|
+
return lo > hi ? [min, min] : [lo, hi];
|
|
70
|
+
};
|
|
71
|
+
// 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 snapped the default off what was written (37 over 0–110 became 40). A value finer still
|
|
73
|
+
// (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 stepped over 0. roundToStep anchors the grid at min rounded to the step's decimals,
|
|
75
|
+
// 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
|
+
// step within reach fits it, else the grid trims it as before.
|
|
77
|
+
const inferStep = (min: number, max: number, ...vals: number[]) => {
|
|
78
|
+
const range = max - min;
|
|
79
|
+
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] was 0–0.
|
|
81
|
+
const base = range > 0 && range < 1e-4 ? 10 ** Math.floor(Math.log10(range)) / 100 : range <= 1 ? 0.01 : range <= 10 ? 0.1 : range <= 100 ? 1 : 10;
|
|
82
|
+
const on = (n: number) => !Number.isFinite(n) || Math.abs(n - Math.round(n)) <= 1e-6;
|
|
83
|
+
const fits = (step: number, vs: number[]) => on(min / Math.min(step, 1)) && vs.every((v) => on((v - min) / step));
|
|
84
|
+
const finest = (vs: number[]) => { let step = base; for (let i = 0; i < 2 && !fits(step, vs); i++) step /= 10; return step; };
|
|
85
|
+
const withMax = finest([...vals, max]);
|
|
86
|
+
return fits(withMax, [...vals, max]) ? withMax : finest(vals);
|
|
87
|
+
};
|
|
88
|
+
// The range a value implies when no bound is given, widened by each value (defaultRange), and its step. A range mirrored
|
|
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 0 on the grid.
|
|
90
|
+
const derivedRange = (...vals: number[]) => {
|
|
91
|
+
const rs = (vals.length ? vals : [0]).map(defaultRange);
|
|
92
|
+
const min = Math.min(...rs.map((r) => r[0])), max = Math.max(...rs.map((r) => r[1]));
|
|
93
|
+
return { min, max, step: min < 0 && min === -max ? inferStep(0, max, max, ...vals.map(Math.abs)) : inferStep(min, max, ...vals) };
|
|
94
|
+
};
|
|
95
|
+
// A console notice for a mistake. Its words live in the lazy heavy chunk (heavy.ts NOTICES), so a panel that makes none
|
|
96
|
+
// never downloads them; it lands a moment later, which a console doesn't mind.
|
|
97
|
+
// Once the heavy chunk is in (always on the single-file build), a notice prints at once, so code that reads the console
|
|
98
|
+
// right after the set() that caused it sees it; before, it waits for the chunk.
|
|
99
|
+
let says: ((kind: string, args: unknown[]) => void) | null = null;
|
|
100
|
+
const sayNow = (f: typeof says) => { says = f; };
|
|
101
|
+
const notice = (kind: string, ...args: unknown[]) => { if (says) says(kind, args); else import("./heavy.js").then((m) => m.notice(kind, args), () => {}); };
|
|
102
|
+
// 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 NaN
|
|
104
|
+
// span. Every range source funnels here.
|
|
105
|
+
const normalizeRange = (rawMin: unknown, rawMax: unknown, rawStep: unknown, vals: number[] = [], label?: string) => {
|
|
106
|
+
const given = (n: unknown) => n != null && n !== "" && Number.isFinite(+n!);
|
|
107
|
+
vals = vals.filter(Number.isFinite);
|
|
108
|
+
const d = derivedRange(...vals);
|
|
109
|
+
let min = given(rawMin) ? +rawMin! : d.min, max = given(rawMax) ? +rawMax! : d.max, step = +rawStep!;
|
|
110
|
+
// One bound written at or past the values' own other end: that end derives from the written bound, as a bare number its
|
|
111
|
+
// 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 15–20 or 1–1.
|
|
112
|
+
if (given(rawMin) !== given(rawMax) && !(max > min)) { const up = given(rawMin), b = up ? min : max, r = derivedRange(b); if (up) max = r.max > b ? r.max : b + r.max - r.min; else min = r.min < b ? r.min : b - r.max + r.min; }
|
|
113
|
+
if (max < min) { if (label != null) notice("swap", label, min, max); const t = min; min = max; max = t; }
|
|
114
|
+
if (!(step > 0) || step > max - min) step = given(rawMin) || given(rawMax) ? inferStep(min, max, ...vals) : d.step;
|
|
115
|
+
return { min, max, step };
|
|
116
|
+
};
|
|
117
|
+
// One keyboard model for every 1-D range surface: arrows step (⇧ = ×10), Home/End snap to
|
|
118
|
+
// the ends, PageUp/Down jump by `page`. Null when the key isn't the range's to handle.
|
|
119
|
+
const rangeStep = (e: KeyboardEvent, cur: number, step: number, lo: number, hi: number, page: number | null = null) => {
|
|
120
|
+
const coarse = e.shiftKey ? 10 : 1;
|
|
121
|
+
switch (e.key) {
|
|
122
|
+
case "ArrowRight": case "ArrowUp": return cur + step * coarse;
|
|
123
|
+
case "ArrowLeft": case "ArrowDown": return cur - step * coarse;
|
|
124
|
+
case "PageUp": return page != null ? cur + page : null;
|
|
125
|
+
case "PageDown": return page != null ? cur - page : null;
|
|
126
|
+
case "Home": return lo;
|
|
127
|
+
case "End": return hi;
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
};
|
|
131
|
+
// Does a handle's pixel span [hx, hx+hw] overlap the label or value text? `m` is a near-miss buffer.
|
|
132
|
+
const overlapsText = (labelEl: HTMLElement, valueEl: HTMLElement, hx: number, hw: number, m = 0) => {
|
|
133
|
+
const ll = labelEl.offsetLeft, lr = ll + labelEl.offsetWidth;
|
|
134
|
+
const vl = valueEl.offsetLeft, vr = vl + valueEl.offsetWidth;
|
|
135
|
+
return (hx < lr && hx + hw > ll) || (hx < vr + m && hx + hw > vl - m);
|
|
136
|
+
};
|
|
137
|
+
// Default range for a number with no bounds: 0–1 keeps 0–1, a positive spans 0–3× (100 as a
|
|
138
|
+
// last resort), and a negative spans that much either side of zero, so it can still cross it
|
|
139
|
+
// (rotate: -45 → −135 to 135).
|
|
140
|
+
const defaultRange = (v: number): [number, number] => {
|
|
141
|
+
if (!Number.isFinite(v)) return [0, 1]; // NaN spanned NaN–NaN; every entry point now reads it as 0, as a verbose slider did
|
|
142
|
+
const x3 = Number((Math.abs(v) * 3).toPrecision(12)); // 2.3 × 3 is 6.8999…, which the grid trimmed to 6.8
|
|
143
|
+
if (v >= 0) return [0, v <= 1 ? 1 : x3 || 100];
|
|
144
|
+
const m = v >= -1 ? 1 : x3;
|
|
145
|
+
return [-m, m];
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// An option is a string, a { value, label } object, or a bare primitive that is its own value.
|
|
149
|
+
const optValue = (o: Option) => (o == null ? undefined : typeof o === "object" ? o.value : o);
|
|
150
|
+
// An object's readable form: its JSON, or String() when it can't be stringified (a cycle).
|
|
151
|
+
const json = (v: unknown) => { try { return JSON.stringify(v); } catch { return String(v); } };
|
|
152
|
+
// A string option shows as written ("px", "sRGB"): it's the author's text, not a key. A { value }
|
|
153
|
+
// option with no label reads as its value's string; an object with neither, as its JSON.
|
|
154
|
+
const optLabel = (o: Option) => (o == null ? "" : typeof o === "string" ? o : typeof o === "object" ? o.label ?? (o.value !== undefined ? String(o.value) : json(o)) : String(o));
|
|
155
|
+
|
|
156
|
+
// The DOM factory, typed by tag like createElement (svgEl in heavy.ts is its SVG twin). A field it
|
|
157
|
+
// makes is a setting, not a form: autofill and the password managers leave it alone.
|
|
158
|
+
const el = <K extends keyof HTMLElementTagNameMap>(tag: K, cls?: string, html?: string): Built<HTMLElementTagNameMap[K]> => {
|
|
159
|
+
const n = document.createElement(tag); if (cls) n.className = cls; if (html != null) n.innerHTML = html;
|
|
160
|
+
if (tag == "input" || tag == "textarea") n.setAttribute("autocomplete", "off"), n.setAttribute("data-1p-ignore", ""), n.setAttribute("data-lpignore", "true");
|
|
161
|
+
return n;
|
|
162
|
+
};
|
|
163
|
+
// Every <button> is type="button" — inside a host's <form> the default would submit — and
|
|
164
|
+
// text goes through textContent, never innerHTML: labels are host data.
|
|
165
|
+
const btn = (cls: string, html?: string) => { const b = el("button", cls, html); b.type = "button"; return b; };
|
|
166
|
+
const txt = <K extends keyof HTMLElementTagNameMap>(tag: K, cls: string, text: string) => { const n = tag === "button" ? btn(cls) : el(tag, cls); n.textContent = text; return n as Built<HTMLElementTagNameMap[K]>; };
|
|
167
|
+
// Stop a node's pointer events and clicks leaking to the page behind it.
|
|
168
|
+
const stopPointerLeak = (node: EventTarget) => ["pointerdown", "pointermove", "pointerup", "click"].forEach((t) => node.addEventListener(t, (e) => e.stopPropagation()));
|
|
169
|
+
// Run fn next frame and again once web fonts load — font swaps change the metrics controls measure.
|
|
170
|
+
const onReady = (fn: () => void) => { requestAnimationFrame(fn); if (document.fonts && document.fonts.ready) document.fonts.ready.then(fn); };
|
|
171
|
+
// Listen on global targets for a DOM-bound owner: the first event after the owner has left
|
|
172
|
+
// the document drops the listener set. "Left" means it was connected once, so an event
|
|
173
|
+
// between build and the host's append is skipped, not fatal. Returns the release fn.
|
|
174
|
+
const onLive = (owner: Element, targets: Array<[EventTarget, string]>, fn: (e?: Event) => void) => {
|
|
175
|
+
let mounted = owner.isConnected;
|
|
176
|
+
// A host appends right after building, before any event can arrive: note that mount next
|
|
177
|
+
// frame, so an unmount with no event in between still releases on the first event after.
|
|
178
|
+
if (!mounted) requestAnimationFrame(() => { if (owner.isConnected) mounted = true; });
|
|
179
|
+
const h = (e: Event) => { if (!owner.isConnected) { if (mounted || owner.closest("[data-tw-destroyed]")) off(); return; } mounted = true; fn(e); }; // destroy() marks the owner; release even if never mounted
|
|
180
|
+
const off = () => targets.forEach(([t, ev]) => t.removeEventListener(ev, h));
|
|
181
|
+
targets.forEach(([t, ev]) => t.addEventListener(ev, h));
|
|
182
|
+
return off;
|
|
183
|
+
};
|
|
184
|
+
// Ask every measured surface to re-measure next frame after a display:none → shown flip.
|
|
185
|
+
// Namespaced, not a real "resize": host pages listen to that.
|
|
186
|
+
const requestReflow = () => requestAnimationFrame(() => window.dispatchEvent(new Event("tw-reflow")));
|
|
187
|
+
// Toggle .is-hover while the pointer is over a node; onEnter runs on entry.
|
|
188
|
+
const wireHoverClass = (el: HTMLElement, onEnter: () => void) => {
|
|
189
|
+
el.addEventListener("pointerenter", () => { el.classList.add("is-hover"); onEnter(); });
|
|
190
|
+
el.addEventListener("pointerleave", () => el.classList.remove("is-hover"));
|
|
191
|
+
};
|
|
192
|
+
// ── Quiet mouse focus — :focus-visible always matches a focused text field, so a plain
|
|
193
|
+
// click drew the keyboard ring. One document-level modality note: a pointer press quiets
|
|
194
|
+
// the next focus, any key restores the ring. Installs once per page. ──
|
|
195
|
+
let pointerModality = false;
|
|
196
|
+
if (typeof document !== "undefined") {
|
|
197
|
+
document.addEventListener("pointerdown", () => { pointerModality = true; }, true);
|
|
198
|
+
document.addEventListener("keydown", () => { pointerModality = false; }, true);
|
|
199
|
+
}
|
|
200
|
+
const quietFocus = (input: HTMLElement) => {
|
|
201
|
+
input.addEventListener("focus", () => input.classList.toggle("tw-focus-quiet", pointerModality));
|
|
202
|
+
input.addEventListener("blur", () => input.classList.remove("tw-focus-quiet"));
|
|
203
|
+
};
|
|
204
|
+
/** Place a portaled popover under its trigger, flipping above when it won't fit, clamped into
|
|
205
|
+
* the viewport. width:"match" sizes it to the trigger; align:"end" lines up the right edges. */
|
|
206
|
+
export const placeBelow = (trigger: Element, pop: HTMLElement, { width, fallbackH = 300, gap = 6, align = "start", prefer = "below" }: { width?: number | "match"; fallbackH?: number; gap?: number; align?: "start" | "end" | "center"; prefer?: "above" | "below" } = {}) => {
|
|
207
|
+
const r = trigger.getBoundingClientRect();
|
|
208
|
+
if (width === "match") pop.style.width = r.width + "px";
|
|
209
|
+
const w = pop.offsetWidth || (width === "match" ? r.width : width || 0); // drawn width, which min-width may hold above a match
|
|
210
|
+
// A match held wider by min-width grows left from the trigger's right edge, so in a nested folder it spans the panel's row.
|
|
211
|
+
const end = align === "end" || (width === "match" && w > r.width + 0.5);
|
|
212
|
+
const x = end ? r.right - w : align === "center" ? r.left + r.width / 2 - w / 2 : r.left;
|
|
213
|
+
pop.style.left = Math.max(8, Math.min(x, window.innerWidth - w - 8)) + "px";
|
|
214
|
+
const h = pop.offsetHeight || fallbackH;
|
|
215
|
+
const below = window.innerHeight - r.bottom, above = r.top, need = h + 12;
|
|
216
|
+
// The preferred side if it fits, else the other if it fits, else whichever has more room.
|
|
217
|
+
const fitsBelow = below >= need, fitsAbove = above >= need;
|
|
218
|
+
const goAbove = prefer === "above" ? (fitsAbove || (!fitsBelow && above >= below)) : (!fitsBelow && (fitsAbove || above > below));
|
|
219
|
+
const top = goAbove ? r.top - h - gap : r.bottom + gap;
|
|
220
|
+
pop.style.top = clamp(top, gap, window.innerHeight - h - gap) + "px"; // clamp Y into the viewport
|
|
221
|
+
// For the CSS entrance: the side it opened on, and its height.
|
|
222
|
+
pop.dataset.side = goAbove ? "above" : "below"; pop.style.setProperty("--tw-pop-h", String(h));
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// ── Theming — the panel runs on --tw-* custom properties, so a theme is a set of those vars, which applyThemeVars
|
|
226
|
+
// sets inline. A key that isn't a token is skipped out loud.
|
|
227
|
+
const resolveTheme = (theme: Theme | null | undefined): ThemeVars | null => {
|
|
228
|
+
if (!theme || typeof theme !== "object") return null;
|
|
229
|
+
const out: ThemeVars = {};
|
|
230
|
+
for (const [k, v] of Object.entries(theme)) {
|
|
231
|
+
if (v == null) continue; // unset, as an optional spread leaves a key
|
|
232
|
+
if (k.startsWith("--")) out[k] = String(v); else notice("theme", k);
|
|
233
|
+
}
|
|
234
|
+
return Object.keys(out).length ? out : null;
|
|
235
|
+
};
|
|
236
|
+
// Clear the previous application first, so a partial theme reverts what it no longer names.
|
|
237
|
+
const applyThemeVars = (node: ThemedEl | null | undefined, vars: ThemeVars | null | undefined) => {
|
|
238
|
+
if (!node) return;
|
|
239
|
+
if (node._twAppliedVars) for (const k of node._twAppliedVars) node.style.removeProperty(k);
|
|
240
|
+
node._twAppliedVars = vars ? Object.keys(vars) : null;
|
|
241
|
+
if (vars) for (const k in vars) node.style.setProperty(k, vars[k]);
|
|
242
|
+
};
|
|
243
|
+
// Carry a forced scheme onto a node portaled to <body>, where the cascade can't reach. Copy
|
|
244
|
+
// the WINNING scheme, not the nearest pin: pins resolve flat (light outranks dark; tweaks.css).
|
|
245
|
+
const carryScheme = (portal: Element, anchor: Element | null | undefined) => {
|
|
246
|
+
const s = anchor?.closest('[data-tw-scheme="light"]') ? "light" : anchor?.closest('[data-tw-scheme="dark"]') ? "dark" : null;
|
|
247
|
+
if (s) portal.setAttribute("data-tw-scheme", s); else portal.removeAttribute("data-tw-scheme");
|
|
248
|
+
};
|
|
249
|
+
// …and the anchor panel's live theme (read at carry time — setTheme() swaps the object).
|
|
250
|
+
const carrySkin = (portal: HTMLElement, anchor: Element | null | undefined) => { applyThemeVars(portal, anchor?.closest<PanelEl>(".tw-panel")?._twTheme); carryScheme(portal, anchor); };
|
|
251
|
+
|
|
252
|
+
// ── 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. Globally
|
|
254
|
+
// single-open. onOpen runs once it's placed at real size; onReflow on resize while open. ──
|
|
255
|
+
let activePopoverClose: null | (() => void) = null, activePopoverTrigger: HTMLElement | null = null;
|
|
256
|
+
// Closes the open popover when its trigger lives inside `owner`.
|
|
257
|
+
const closeActivePopover = (owner: Element) => { if (activePopoverClose && owner.contains(activePopoverTrigger)) activePopoverClose(); };
|
|
258
|
+
function popover(root: HTMLElement, trigger: HTMLButtonElement, pop: HTMLElement, opts: { width?: number | "match"; fallbackH?: number; align?: "start" | "end"; movable?: boolean; onOpen?: () => void; onReflow?: () => void; initialFocus?: () => HTMLElement | null | undefined } = {}): Popover {
|
|
259
|
+
let open = false, schemeObs: MutationObserver | null = null, removeTimer: ReturnType<typeof setTimeout> | undefined, byPointer = false, sizeObs: ResizeObserver | null = null;
|
|
260
|
+
// Focus goes back to the row as the pane was opened: quietly after a click, even when an Escape closes it.
|
|
261
|
+
const refocus = () => trigger.focus({ focusVisible: !byPointer });
|
|
262
|
+
pop.classList.add("tw-portal"); // portal-wide CSS rules key off this
|
|
263
|
+
// A closed pop stays in the DOM at opacity 0; inert keeps it out of the tab order and a11y tree.
|
|
264
|
+
pop.inert = true;
|
|
265
|
+
// Relay pointerdowns to the host panel's edit hook — on <body>, its own listener can't see them.
|
|
266
|
+
stopPointerLeak(pop); // on <body>, outside the panel's own pointer-stop
|
|
267
|
+
// A scroll carries the pop along at its offset from the trigger (follow) instead of
|
|
268
|
+
// re-placing it; once the row has left the viewport the pop closes.
|
|
269
|
+
let dx = 0, dy = 0;
|
|
270
|
+
// 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 }; let dragBound = false;
|
|
272
|
+
const keepInView = () => { pop.style.left = clamp(parseFloat(pop.style.left), 8, window.innerWidth - pop.offsetWidth - 8) + "px"; pop.style.top = clamp(parseFloat(pop.style.top), 8, window.innerHeight - pop.offsetHeight - 8) + "px"; };
|
|
273
|
+
const place = () => {
|
|
274
|
+
if (st.moved) keepInView(); else placeBelow(trigger, pop, { width: opts.width ?? "match", fallbackH: opts.fallbackH, align: opts.align });
|
|
275
|
+
const r = trigger.getBoundingClientRect(); dx = parseFloat(pop.style.left) - r.left; dy = parseFloat(pop.style.top) - r.top;
|
|
276
|
+
};
|
|
277
|
+
// A detached trigger has a zero rect: if the host has unmounted the panel, close instead.
|
|
278
|
+
const reflow = () => { if (open) { if (!root.isConnected) return close(); place(); opts.onReflow && opts.onReflow(); } };
|
|
279
|
+
const follow = () => {
|
|
280
|
+
if (!open) return; if (!root.isConnected) return close();
|
|
281
|
+
const r = trigger.getBoundingClientRect();
|
|
282
|
+
const sr = trigger.closest(".tw-panel")?.getBoundingClientRect(); // a floating panel scrolls, and clips its rows (an inline one holds them all)
|
|
283
|
+
if (!(r.width || r.height) || r.bottom < 0 || r.top > window.innerHeight || r.right < 0 || r.left > window.innerWidth || (sr?.height && (r.bottom < sr.top || r.top > sr.bottom))) return close(); // hidden, or scrolled out
|
|
284
|
+
pop.style.left = r.left + dx + "px"; pop.style.top = r.top + dy + "px";
|
|
285
|
+
};
|
|
286
|
+
const onOutside = (e: PointerEvent) => { if (!root.contains(e.target as Node) && !pop.contains(e.target as Node)) close(); };
|
|
287
|
+
// 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) => { if (e.key === "Escape" && open && !e.defaultPrevented) { e.preventDefault(); e.stopPropagation(); close(); refocus(); } };
|
|
289
|
+
// Focus leaving both the pop and its trigger closes. A blur with no relatedTarget is
|
|
290
|
+
// ambiguous (a press on a drag surface inside the pop blurs to <body> too), so the modality
|
|
291
|
+
// note decides. A Tab that lands outside the panel comes back to the trigger a tick later.
|
|
292
|
+
const onFocusOut = (e: FocusEvent) => {
|
|
293
|
+
if (!open) return; const t = e.relatedTarget as Node | null;
|
|
294
|
+
if (!t && !document.hasFocus()) return; // the window lost focus (a file dialog, another app): the pane waits for it
|
|
295
|
+
if (t ? root.contains(t) || pop.contains(t) : pointerModality) return;
|
|
296
|
+
close();
|
|
297
|
+
if (t && !trigger.closest(".tw-panel")?.contains(t)) setTimeout(() => { if (!open && document.activeElement === t) trigger.focus(); }, 0);
|
|
298
|
+
};
|
|
299
|
+
root.addEventListener("focusout", onFocusOut); pop.addEventListener("focusout", onFocusOut);
|
|
300
|
+
const firstFocusable = () => pop.querySelector<HTMLElement>('button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href], [tabindex]:not([tabindex="-1"])');
|
|
301
|
+
// Re-carry the host scheme + theme while open: the cascade can't reach <body>, so a mid-open
|
|
302
|
+
// flip would strand a mismatched popover. The observer ignores its own write on `pop`.
|
|
303
|
+
const recarry = () => carrySkin(pop, root);
|
|
304
|
+
const onScheme = (recs: MutationRecord[]) => { if (!recs.every((r) => r.target === pop)) recarry(); };
|
|
305
|
+
const openPop = () => {
|
|
306
|
+
if (activePopoverClose && activePopoverClose !== close) activePopoverClose(); // close any other open popover first
|
|
307
|
+
activePopoverClose = close; activePopoverTrigger = trigger;
|
|
308
|
+
open = st.open = true; root.classList.add("is-open"); trigger.setAttribute("aria-expanded", "true");
|
|
309
|
+
// Reopened mid-fade, it's still on <body>; re-appending would kill the transition, so it reverses in place.
|
|
310
|
+
clearTimeout(removeTimer); const fading = pop.parentNode === document.body; st.moved = false; byPointer = pointerModality;
|
|
311
|
+
pop.inert = false; if (!fading) document.body.appendChild(pop);
|
|
312
|
+
if (!dragBound && opts.movable !== false) { dragBound = true; import("./heavy.js").then((m) => m.dragPane(pop, st, place), () => {}); }
|
|
313
|
+
carrySkin(pop, root);
|
|
314
|
+
window.addEventListener("tw-retheme", recarry); // setTheme() while open
|
|
315
|
+
if (typeof MutationObserver === "function") { schemeObs = new MutationObserver(onScheme); schemeObs.observe(document.documentElement, { subtree: true, attributes: true, attributeFilter: ["data-tw-scheme"] }); } // host scheme flip while open
|
|
316
|
+
// Place with transitions off: the recorded side and height set the closed state the entrance starts from.
|
|
317
|
+
if (fading) place(); else { pop.style.transition = "none"; place(); void pop.offsetWidth; pop.style.transition = ""; }
|
|
318
|
+
// 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") { sizeObs = new ResizeObserver(() => { if (open) place(); }); sizeObs.observe(pop); }
|
|
320
|
+
requestAnimationFrame(() => {
|
|
321
|
+
if (!open) return; // a same-tick close already ran
|
|
322
|
+
pop.classList.add("is-open"); opts.onOpen && opts.onOpen(); place(); // render at real size, then re-place
|
|
323
|
+
// Focus moves into the pop, or Tab walks past the portaled node at the end of <body>.
|
|
324
|
+
// initialFocus wins over the first focusable; a pointer-opened pop takes focus quietly.
|
|
325
|
+
if (!pop.contains(document.activeElement)) { const f = (opts.initialFocus && opts.initialFocus()) || firstFocusable(); if (f) f.focus({ focusVisible: !pointerModality }); }
|
|
326
|
+
});
|
|
327
|
+
// Unmount watchdog: a host removing the panel while open (an SPA route change) would
|
|
328
|
+
// otherwise strand the portaled pop on screen.
|
|
329
|
+
requestAnimationFrame(function watch() { if (!open) return; if (!root.isConnected) return close(); requestAnimationFrame(watch); });
|
|
330
|
+
// Capture phase: the panel's own stopPointerLeak would swallow the event before it bubbles.
|
|
331
|
+
setTimeout(() => { if (open) document.addEventListener("pointerdown", onOutside, true); }, 0); // skip the opening click
|
|
332
|
+
document.addEventListener("keydown", onKey); // Esc closes from anywhere while open
|
|
333
|
+
window.addEventListener("scroll", follow, true); window.addEventListener("resize", reflow); // capture: inner scrollers move the row too
|
|
334
|
+
};
|
|
335
|
+
const close = () => {
|
|
336
|
+
if (activePopoverClose === close) { activePopoverClose = null; activePopoverTrigger = null; }
|
|
337
|
+
// Focus stranded inside the pop returns to the trigger before the node is removed.
|
|
338
|
+
if (pop.contains(document.activeElement)) refocus();
|
|
339
|
+
open = st.open = false; root.classList.remove("is-open"); pop.classList.remove("is-open"); pop.inert = true; trigger.setAttribute("aria-expanded", "false");
|
|
340
|
+
document.removeEventListener("pointerdown", onOutside, true); document.removeEventListener("keydown", onKey);
|
|
341
|
+
window.removeEventListener("scroll", follow, true); window.removeEventListener("resize", reflow);
|
|
342
|
+
window.removeEventListener("tw-retheme", recarry); if (schemeObs) { schemeObs.disconnect(); schemeObs = null; } sizeObs?.disconnect(); sizeObs = null;
|
|
343
|
+
removeTimer = setTimeout(() => { if (!open) pop.remove(); }, 200); // remove the portaled node once it's faded out
|
|
344
|
+
};
|
|
345
|
+
trigger.addEventListener("click", () => { if (trigger.disabled) return; open ? close() : openPop(); }); // a disabled trigger stays inert to a synthetic click
|
|
346
|
+
return { open: openPop, close, isOpen: () => open, reflow };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Fuzzy match for the filter: `q` is a substring of `text`, or within a small edit distance
|
|
350
|
+
// of a window of it ("blut" finds "blur"); the DP's zeroed first row lets a match start anywhere.
|
|
351
|
+
|
|
352
|
+
const REDUCE_MOTION: { matches: boolean } = typeof matchMedia === "function" ? matchMedia("(prefers-reduced-motion: reduce)") : { matches: false };
|
|
353
|
+
// ── Motion tokens — EASE_SPRING is the JS literal of the CSS --tw-ease-spring: WAAPI and
|
|
354
|
+
// nodes outside a .tw-panel can't read the custom property. ──
|
|
355
|
+
const EASE_SPRING = "cubic-bezier(0.22, 1, 0.36, 1)";
|
|
356
|
+
const EASE_GLIDE = "cubic-bezier(0.34, 1.2, 0.64, 1)"; // press glide with a light overshoot
|
|
357
|
+
// Liquid pill stretch (segmented + tabs): a one-shot scaleX overshoot, origin pinned to the
|
|
358
|
+
// trailing edge so it reads directional. dir>0 = moving right.
|
|
359
|
+
const stretchPill = (pill: HTMLElement, dir: number) => {
|
|
360
|
+
if (REDUCE_MOTION.matches || typeof pill.animate !== "function") return;
|
|
361
|
+
pill.style.transformOrigin = dir > 0 ? "left center" : "right center";
|
|
362
|
+
pill.animate([{ transform: "scaleX(1)" }, { transform: "scaleX(1.18)" }, { transform: "scaleX(1)" }], { duration: 300, easing: EASE_SPRING });
|
|
363
|
+
};
|
|
364
|
+
// Slide a pill to the active child of `container`. The box reads come before the writes so a
|
|
365
|
+
// measure can't force an extra layout; the stretch runs on a real move.
|
|
366
|
+
const measurePill = (container: Element, pill: HTMLElement, animate?: boolean) => {
|
|
367
|
+
const a = container.querySelector<HTMLElement>('[data-active="true"]'); if (!a) return;
|
|
368
|
+
const left = a.offsetLeft, width = a.offsetWidth, prev = parseFloat(pill.style.left);
|
|
369
|
+
pill.style.left = left + "px"; pill.style.width = width + "px";
|
|
370
|
+
if (animate && Number.isFinite(prev) && prev !== left) stretchPill(pill, left > prev ? 1 : -1);
|
|
371
|
+
};
|
|
372
|
+
// Slider rubber-band (slider + interval): dragged past an end by more than a 32px dead zone, the
|
|
373
|
+
// 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, RUBBER_RANGE = 200, RUBBER_MAX = 8;
|
|
375
|
+
const rubberBand = (track: HTMLElement, rect: DOMRect, clientX: number) => {
|
|
376
|
+
const over = clientX < rect.left ? clientX - rect.left : clientX > rect.right ? clientX - rect.right : 0;
|
|
377
|
+
const s = Math.sign(over) * RUBBER_MAX * Math.sqrt(Math.min(Math.max(0, Math.abs(over) - RUBBER_DEAD) / RUBBER_RANGE, 1));
|
|
378
|
+
track.style.width = s ? `calc(100% + ${Math.abs(s)}px)` : "";
|
|
379
|
+
track.style.transform = s < 0 ? `translateX(${s}px)` : "";
|
|
380
|
+
};
|
|
381
|
+
// Release: spring the track home, then drop the transition so the next drag tracks 1:1.
|
|
382
|
+
const rubberRelease = (track: HTMLElement) => {
|
|
383
|
+
track.style.transition = `width 0.35s ${EASE_SPRING}, transform 0.35s ${EASE_SPRING}`;
|
|
384
|
+
track.style.width = ""; track.style.transform = "";
|
|
385
|
+
setTimeout(() => { track.style.transition = ""; }, 360);
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
// Disable a row: `inert` goes on the children, not the row — an inert element hit-tests like
|
|
389
|
+
// pointer-events:none, so an inert row would never show its not-allowed cursor.
|
|
390
|
+
const setDisabled = (node: HTMLElement, d: boolean) => {
|
|
391
|
+
node.classList.toggle("is-disabled", d); for (const c of node.children) if (!c.classList.contains("tw-portal")) (c as HTMLElement).inert = d; // a closed popover child keeps its own inert
|
|
392
|
+
if (d) closeActivePopover(node); // an open pane is portaled out of reach of inert: a control disabled mid-edit closes it
|
|
393
|
+
};
|
|
394
|
+
// 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) => { root.classList.toggle("is-collapsed", c); toggle.setAttribute("aria-expanded", String(!c)); body.inert = c; };
|
|
396
|
+
// …and animate the fold over only the visible height: content past the height cap or the window would otherwise stall,
|
|
397
|
+
// then snap. Without the Animations API, or with reduced motion, the CSS transition runs; `done` runs once settled.
|
|
398
|
+
type Folding = HTMLElement & { _twFold?: Animation | null };
|
|
399
|
+
const foldBody = (root: Element, toggle: Element, body: Folding, c: boolean, done?: () => void) => {
|
|
400
|
+
if (REDUCE_MOTION.matches || typeof body.animate !== "function") { setCollapsed(root, toggle, body, c); if (done) setTimeout(done, 370); return; }
|
|
401
|
+
// Everything read before the class flips, so a collapse forces no layout after it.
|
|
402
|
+
const r = body.getBoundingClientRect(), from = r.height; // mid-fold, the height it's drawn at
|
|
403
|
+
const panel = root.closest<HTMLElement>(".tw-panel"), cs = panel && getComputedStyle(panel), maxH = cs ? parseFloat(cs.maxHeight) : NaN; // border-box, so the cap includes the padding
|
|
404
|
+
const capBottom = cs && panel.dataset.mode === "floating" && maxH >= 0 ? panel.getBoundingClientRect().top + maxH - parseFloat(cs.paddingBottom) - parseFloat(cs.borderBottomWidth) : Infinity;
|
|
405
|
+
const room = Math.max(0, Math.min(window.innerHeight, capBottom) - r.top), easing = getComputedStyle(body).getPropertyValue("--tw-ease-fold").trim();
|
|
406
|
+
body._twFold?.cancel(); body._twFold = null;
|
|
407
|
+
body.style.transition = "none"; body.style.gridTemplateRows = "";
|
|
408
|
+
setCollapsed(root, toggle, body, c);
|
|
409
|
+
let to = 0; // collapsed is always 0
|
|
410
|
+
if (!c) {
|
|
411
|
+
// 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?.() ?? []) if ((a as CSSTransition).transitionProperty !== "opacity") a.finish();
|
|
413
|
+
to = body.getBoundingClientRect().height;
|
|
414
|
+
}
|
|
415
|
+
const f = Math.min(from, room), t = Math.min(to, room);
|
|
416
|
+
// fill: forwards holds the last frame until the 1fr comes off; otherwise one frame would draw the whole body.
|
|
417
|
+
const finish = () => { body.style.gridTemplateRows = ""; void body.offsetHeight; body._twFold?.cancel(); body._twFold = null; body.style.transition = ""; done?.(); };
|
|
418
|
+
if (Math.abs(f - t) < 1) return finish();
|
|
419
|
+
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" }], { duration: 350, easing, fill: "forwards" });
|
|
421
|
+
a.onfinish = () => { if (body._twFold === a) finish(); };
|
|
422
|
+
};
|
|
423
|
+
// The button whose (stringified) value is active — or, when none matches, the first: the
|
|
424
|
+
// group's roving tab stop. Only an empty group gives −1.
|
|
425
|
+
const activeIndex = (btns: RadioBtn[], value: unknown) => { const i = btns.findIndex((b) => b.dataset.value === String(value)); return i < 0 && btns.length ? 0 : i; };
|
|
426
|
+
// Reflect a single-select value onto a radio group: data-active, aria-checked, and a roving
|
|
427
|
+
// tabindex. Paint follows the real match; the tab stop follows activeIndex's fallback.
|
|
428
|
+
const setRadioActive = (btns: RadioBtn[], value: unknown) => { const stop = activeIndex(btns, value); btns.forEach((b, k) => { const on = b.dataset.value === String(value); b.dataset.active = String(on); b.setAttribute("aria-checked", String(on)); b.tabIndex = k === stop ? 0 : -1; }); };
|
|
429
|
+
// A single-select radio button. _twVal carries the option's real value — dataset stringifies,
|
|
430
|
+
// 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) => { const b: RadioBtn = txt("button", cls, optLabel(o)); b.setAttribute("role", "radio"); b._twVal = optValue(o); b.dataset.value = String(b._twVal); b.addEventListener("click", () => onPick(b._twVal)); return b; };
|
|
432
|
+
// Arrow-key navigation → the next index, or −1 when the key isn't the group's. cols > 0 jumps
|
|
433
|
+
// ↑/↓ by a row (the radio grid); 0 treats ↑/↓ as ←/→; < 0 leaves ↑/↓ to the page (the tab bar).
|
|
434
|
+
const navIndex = (key: string, i: number, n: number, cols = 0) => {
|
|
435
|
+
switch (key) {
|
|
436
|
+
case "ArrowRight": return (i + 1) % n;
|
|
437
|
+
case "ArrowLeft": return (i - 1 + n) % n;
|
|
438
|
+
case "ArrowDown": return cols < 0 ? -1 : cols ? (i + cols < n ? i + cols : i) : (i + 1) % n;
|
|
439
|
+
case "ArrowUp": return cols < 0 ? -1 : cols ? (i - cols >= 0 ? i - cols : i) : (i - 1 + n) % n;
|
|
440
|
+
case "Home": return 0;
|
|
441
|
+
case "End": return n - 1;
|
|
442
|
+
}
|
|
443
|
+
return -1;
|
|
444
|
+
};
|
|
445
|
+
// ── 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 tab
|
|
447
|
+
// stop, Space or Enter flips it) rather than "radio button, On, 2 of 2"; its Off/On segments stay as the pointer's targets.
|
|
448
|
+
function createSegmented(options: Option[], value: unknown, onChange: OnChange, ariaLabel?: string, onIs?: unknown) {
|
|
449
|
+
const sw = onIs !== undefined, seg = el("div", "tw-seg"); seg.setAttribute("role", sw ? "switch" : "radiogroup");
|
|
450
|
+
if (ariaLabel) seg.setAttribute("aria-label", ariaLabel);
|
|
451
|
+
if (sw) seg.tabIndex = 0;
|
|
452
|
+
const pill = el("div", "tw-seg-pill");
|
|
453
|
+
seg.append(pill);
|
|
454
|
+
const btns = options.map((o) => { const b = radioButton("tw-seg-btn", o, (v) => set(v)); if (sw) { b.setAttribute("aria-hidden", "true"); b.addEventListener("focus", () => seg.focus()); } seg.append(b); return b; }); // lazy `set` — it's declared below; a clicked segment hands focus to the switch, never parks it hidden
|
|
455
|
+
const measure = (animate?: boolean) => measurePill(seg, pill, animate);
|
|
456
|
+
const reflect = () => { setRadioActive(btns, value); if (sw) { seg.setAttribute("aria-checked", String(value === onIs)); btns.forEach((b) => (b.tabIndex = -1)); } measure(true); };
|
|
457
|
+
const set = (v: unknown, fire = true) => { if (v != null && !btns.some((b) => b._twVal === v)) return; value = v; reflect(); if (fire) onChange(v); }; // null clears; a value matching no segment is ignored
|
|
458
|
+
seg.addEventListener("keydown", (e) => {
|
|
459
|
+
const i = activeIndex(btns, value); if (i < 0) return;
|
|
460
|
+
if (sw && (e.key === " " || e.key === "Enter")) { e.preventDefault(); set(btns[1 - i]._twVal); return; }
|
|
461
|
+
// A switch's arrows point at a side, Off or On, and stop there: wrapping, → on On turned it off.
|
|
462
|
+
const j = sw ? ({ ArrowRight: 1, ArrowUp: 1, End: 1, ArrowLeft: 0, ArrowDown: 0, Home: 0 } as Record<string, number>)[e.key] ?? -1 : navIndex(e.key, i, btns.length); if (j < 0) return;
|
|
463
|
+
e.preventDefault(); set(btns[j]._twVal); (sw ? seg : btns[j]).focus(); // _twVal: the real, possibly non-string value
|
|
464
|
+
});
|
|
465
|
+
reflect();
|
|
466
|
+
onReady(() => { measure(); seg.classList.add("is-ready"); }); // once laid out, again when fonts land
|
|
467
|
+
// A tab page revealing this control re-measures the pill, built at 0×0 while display:none.
|
|
468
|
+
onLive(seg, [[window, "tw-reflow"]], () => measure());
|
|
469
|
+
return { el: seg, set: (v: unknown) => set(v, false), get: () => value };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// One stroke-icon shell for every inline SVG. It lives here, not in icons.ts, because a lazy
|
|
473
|
+
// control imports it: anything both core and a lazy chunk import must sit in the shared chunk,
|
|
474
|
+
// 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) => `<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
|
+
|
|
477
|
+
// The handle a display/action control returns: no value, so build skips entry/reset/persist
|
|
478
|
+
// wiring. `destroy` lets panel.destroy() stop a control's loop even if the panel never connected.
|
|
479
|
+
const blade = (el: HTMLElement, destroy?: () => void): Control => ({ el, set: () => {}, get: () => undefined, destroy });
|
|
480
|
+
|
|
481
|
+
// Own-key lookup for objects used as maps: "toString" must miss, not hit Object.prototype.
|
|
482
|
+
const hasOwn = <T extends object>(o: T, k: PropertyKey): k is keyof T => Object.prototype.hasOwnProperty.call(o, k);
|
|
483
|
+
// A key by one of these names would write through to Object.prototype on the params bag.
|
|
484
|
+
const isReservedKey = (k: string) => k === "__proto__" || k === "constructor" || k === "prototype";
|
|
485
|
+
// Identity for primitives, structural for the object-valued controls (a fresh object each
|
|
486
|
+
// get()). Gates notify(): a same-value echo from an on() listener recursed to stack exhaustion.
|
|
487
|
+
const valueChanged = (a: unknown, b: unknown) => a !== b && !(isObj(a) && isObj(b) && JSON.stringify(a) === JSON.stringify(b));
|
|
488
|
+
// Reset: the form the control OPENED on first (it covers a value set() can't take as-is),
|
|
489
|
+
// then the authored value on top, so whatever the control CAN take lands exactly.
|
|
490
|
+
const restoreDefault = (ctrl: Pick<Control, "set">, raw: unknown, def: unknown) => { ctrl.set(def); ctrl.set(raw); }; // a control, or the panel's entry for one
|
|
491
|
+
const SPRING_KEYS = ["stiffness", "damping", "mass", "visualDuration", "bounce"]; // read by the spring and motion controls, and their markup
|
|
492
|
+
// ── Control registry — type → constructor. Core controls register on load; a lazy one when
|
|
493
|
+
// its module is imported, so the loaded set drives what can be built. ──
|
|
494
|
+
const REGISTRY: Record<string, ControlCtor<any, any>> = {}; // heterogeneous by design — see ControlCtor
|
|
495
|
+
export const registerControl = (type: string, ctor: ControlCtor<any, any>) => { REGISTRY[type] = ctor; };
|
|
496
|
+
export const getControl = <C = Control, M = Meta>(type: string): ControlCtor<M, C> | undefined => REGISTRY[type];
|
|
497
|
+
export const controlTypes = () => Object.keys(REGISTRY);
|
|
498
|
+
// The markup chunk's enhance(), registered when markup.ts loads (enhance.ts fetches it, or single.ts imports it).
|
|
499
|
+
let MARKUP: ((root: Document | Element, kit: any) => Promise<void>) | null = null; // any: the kit's type lives in markup.ts, which imports this
|
|
500
|
+
export const setMarkup = (fn: typeof MARKUP) => { MARKUP = fn; };
|
|
501
|
+
export const getMarkup = <K>() => MARKUP as ((root: Document | Element, kit: K) => Promise<void>) | null;
|
|
502
|
+
|
|
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
|
+
// where the toast used to speak. It's cleared and written a beat later, so the same words twice are read twice.
|
|
505
|
+
let srEl: HTMLDivElement | null = null, srT = 0;
|
|
506
|
+
const announce = (msg: string) => {
|
|
507
|
+
if (!srEl) { srEl = el("div", "tw-sr"); srEl.setAttribute("role", "status"); }
|
|
508
|
+
if (!srEl.isConnected) document.body.appendChild(srEl); // re-home after a host replaced <body>'s content
|
|
509
|
+
srEl.textContent = ""; clearTimeout(srT); srT = setTimeout(() => { srEl!.textContent = msg; }, 100);
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
export {
|
|
513
|
+
titleCase, clamp, isObj, isColorStr, stepPrecision, gridEnds, roundToStep, inferStep, defaultRange,
|
|
514
|
+
normalizeRange, notice, sayNow, rangeStep, overlapsText,
|
|
515
|
+
optValue, optLabel, json, el, btn, txt, stopPointerLeak, onReady, onLive, requestReflow,
|
|
516
|
+
wireHoverClass, popover, closeActivePopover,
|
|
517
|
+
resolveTheme, applyThemeVars, carryScheme, carrySkin, setCollapsed, foldBody, setDisabled, activeIndex, setRadioActive, radioButton, navIndex, createSegmented,
|
|
518
|
+
blade, quietFocus, measurePill, rubberBand, rubberRelease, REDUCE_MOTION, EASE_SPRING, EASE_GLIDE, icon, announce, hasOwn, isReservedKey, valueChanged, restoreDefault, SPRING_KEYS,
|
|
519
|
+
};
|
|
520
|
+
export type { OnChange, ThemeVars, PanelEl, RadioBtn, Built, NumSpec, NumField, Popover, ControlCtor };
|
|
521
|
+
// Re-exported for the chunking: core and the gradient control both import easing.ts, and a
|
|
522
|
+
// module only those two reach would become a chunk of its own (test/chunks.test.mjs holds it
|
|
523
|
+
// to three). Core exports from easing.ts directly so the .d.ts prune skips shared.ts internals.
|
|
524
|
+
export { gradientCss, gradientStops } from "./easing.js";
|
|
525
|
+
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/* Single-file build entry. Statically imports every control so each self-registers
|
|
2
|
+
* into the shared registry on load; with the esbuild define TW_SPLIT=false, core's
|
|
3
|
+
* dynamic-import map is empty, so esbuild inlines the whole kit into one self-contained,
|
|
4
|
+
* synchronous file — a drop-in that needs no bundler. The code-split build uses core.ts
|
|
5
|
+
* directly (TW_SPLIT=true) and loads these controls on demand instead. */
|
|
6
|
+
import "./markup.js"; // registers enhance()'s work, so enhance() stays synchronous here
|
|
7
|
+
import "./heavy.js"; // registers the Number control
|
|
8
|
+
import "./controls/color.js";
|
|
9
|
+
import "./controls/gradient.js";
|
|
10
|
+
import "./controls/shadow.js";
|
|
11
|
+
import "./controls/sides.js";
|
|
12
|
+
import "./controls/interval.js";
|
|
13
|
+
import "./controls/tabs.js";
|
|
14
|
+
import "./controls/image.js";
|
|
15
|
+
import "./controls/monitor.js";
|
|
16
|
+
import "./controls/spring.js";
|
|
17
|
+
import "./controls/bezier.js";
|
|
18
|
+
import "./controls/motion.js";
|
|
19
|
+
import "./controls/point.js";
|
|
20
|
+
import "./controls/plot.js";
|
|
21
|
+
|
|
22
|
+
export * from "./core.js";
|