type-tester-tdf 2.0.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/dist/react.cjs ADDED
@@ -0,0 +1,566 @@
1
+ 'use strict';
2
+
3
+ var react = require('react');
4
+ var jsxRuntime = require('react/jsx-runtime');
5
+
6
+ // src/react/index.tsx
7
+
8
+ // src/core/dom.ts
9
+ function el(tag, props = {}) {
10
+ const node = document.createElement(tag);
11
+ if (props.class) node.className = props.class;
12
+ if (props.text != null) node.textContent = props.text;
13
+ if (props.attrs) {
14
+ for (const [name, value] of Object.entries(props.attrs)) {
15
+ if (value == null || value === false) continue;
16
+ node.setAttribute(name, value === true ? "" : String(value));
17
+ }
18
+ }
19
+ if (props.children) {
20
+ for (const child of props.children) node.appendChild(child);
21
+ }
22
+ return node;
23
+ }
24
+ function clamp(value, min, max) {
25
+ return Math.min(max, Math.max(min, value));
26
+ }
27
+ function toNumber(value, fallback) {
28
+ if (value == null || value === "") return fallback;
29
+ const n = typeof value === "number" ? value : Number(value);
30
+ return Number.isFinite(n) ? n : fallback;
31
+ }
32
+
33
+ // src/core/fit.ts
34
+ var REFERENCE_SIZE = 100;
35
+ var MIRROR_PROPS = [
36
+ "fontFamily",
37
+ "fontWeight",
38
+ "fontStyle",
39
+ "fontStretch",
40
+ "letterSpacing",
41
+ "fontFeatureSettings",
42
+ "fontVariationSettings",
43
+ "textTransform"
44
+ ];
45
+ var Fitter = class {
46
+ /**
47
+ * @param target Element whose text is being fitted.
48
+ * @param min Minimum font-size in px.
49
+ * @param max Maximum font-size in px.
50
+ */
51
+ constructor(target, min, max) {
52
+ this.mirror = null;
53
+ this.observer = null;
54
+ this.frame = 0;
55
+ this.onFit = null;
56
+ this.destroyed = false;
57
+ // Last container width fitted against. Used to ignore ResizeObserver
58
+ // notifications caused purely by height changes (a fit grows the line
59
+ // height, which would otherwise re-trigger the observer in a loop).
60
+ this.lastWidth = -1;
61
+ this.target = target;
62
+ this.min = min;
63
+ this.max = max;
64
+ }
65
+ /**
66
+ * Starts observing the target's container and reports the fitted size via
67
+ * `onFit` whenever it should change. Re-fits once fonts have loaded.
68
+ */
69
+ start(onFit) {
70
+ this.onFit = onFit;
71
+ if (typeof ResizeObserver !== "undefined") {
72
+ const container = this.target.parentElement ?? this.target;
73
+ this.observer = new ResizeObserver(() => {
74
+ if (container.clientWidth !== this.lastWidth) this.schedule();
75
+ });
76
+ this.observer.observe(container);
77
+ }
78
+ const fonts = document.fonts;
79
+ if (fonts?.ready) {
80
+ fonts.ready.then(() => this.schedule()).catch(() => {
81
+ });
82
+ }
83
+ this.schedule();
84
+ }
85
+ /** Requests a fit on the next animation frame, coalescing rapid calls. */
86
+ schedule() {
87
+ if (this.destroyed) return;
88
+ if (typeof requestAnimationFrame === "undefined") {
89
+ this.fit();
90
+ return;
91
+ }
92
+ if (this.frame) return;
93
+ this.frame = requestAnimationFrame(() => {
94
+ this.frame = 0;
95
+ this.fit();
96
+ });
97
+ }
98
+ /** Measures the text and reports the fitted size. */
99
+ fit() {
100
+ if (this.destroyed || !this.onFit) return;
101
+ const container = this.target.parentElement ?? this.target;
102
+ const width = container.clientWidth;
103
+ if (width <= 0) return;
104
+ const mirror = this.ensureMirror();
105
+ const computed = getComputedStyle(this.target);
106
+ for (const prop of MIRROR_PROPS) {
107
+ mirror.style[prop] = computed[prop];
108
+ }
109
+ mirror.style.fontSize = `${REFERENCE_SIZE}px`;
110
+ mirror.textContent = this.target.textContent ?? "";
111
+ const measured = mirror.getBoundingClientRect().width;
112
+ if (measured <= 0) return;
113
+ this.lastWidth = width;
114
+ const size = clamp(REFERENCE_SIZE * width / measured, this.min, this.max);
115
+ this.onFit(Math.round(size * 100) / 100);
116
+ }
117
+ /** Lazily creates the shared offscreen measurement mirror. */
118
+ ensureMirror() {
119
+ if (this.mirror) return this.mirror;
120
+ const mirror = document.createElement("span");
121
+ mirror.setAttribute("aria-hidden", "true");
122
+ Object.assign(mirror.style, {
123
+ position: "absolute",
124
+ left: "-9999px",
125
+ top: "0",
126
+ visibility: "hidden",
127
+ whiteSpace: "nowrap",
128
+ pointerEvents: "none"
129
+ });
130
+ document.body.appendChild(mirror);
131
+ this.mirror = mirror;
132
+ return mirror;
133
+ }
134
+ /** Stops observing and removes the mirror. */
135
+ destroy() {
136
+ this.destroyed = true;
137
+ if (this.frame) cancelAnimationFrame(this.frame);
138
+ this.observer?.disconnect();
139
+ this.observer = null;
140
+ this.mirror?.remove();
141
+ this.mirror = null;
142
+ this.onFit = null;
143
+ }
144
+ };
145
+
146
+ // src/core/opentype.ts
147
+ var FEATURES = [
148
+ // Ligatures
149
+ { tag: "liga", label: "Standard Ligatures", group: "Ligatures" },
150
+ { tag: "dlig", label: "Discretionary Ligatures", group: "Ligatures" },
151
+ { tag: "hlig", label: "Historical Ligatures", group: "Ligatures" },
152
+ { tag: "clig", label: "Contextual Ligatures", group: "Ligatures" },
153
+ // Letter Case
154
+ { tag: "smcp", label: "Small Capitals", group: "Letter Case" },
155
+ { tag: "c2sc", label: "Capitals to Small Capitals", group: "Letter Case" },
156
+ { tag: "case", label: "Case-Sensitive Forms", group: "Letter Case" },
157
+ { tag: "cpsp", label: "Capital Spacing", group: "Letter Case" },
158
+ // Figures
159
+ { tag: "lnum", label: "Lining Figures", group: "Figures" },
160
+ { tag: "onum", label: "Oldstyle Figures", group: "Figures" },
161
+ { tag: "pnum", label: "Proportional Figures", group: "Figures" },
162
+ { tag: "tnum", label: "Tabular Figures", group: "Figures" },
163
+ { tag: "zero", label: "Slashed Zero", group: "Figures" },
164
+ { tag: "ordn", label: "Ordinals", group: "Figures" },
165
+ // Fractions
166
+ { tag: "frac", label: "Fractions", group: "Fractions" },
167
+ { tag: "afrc", label: "Alternative Fractions", group: "Fractions" },
168
+ // Alternates
169
+ { tag: "swsh", label: "Swash", group: "Alternates" },
170
+ { tag: "calt", label: "Contextual Alternates", group: "Alternates" },
171
+ { tag: "salt", label: "Stylistic Alternates", group: "Alternates" },
172
+ { tag: "hist", label: "Historical Forms", group: "Alternates" },
173
+ { tag: "nalt", label: "Alternate Annotation Forms", group: "Alternates" },
174
+ // Position
175
+ { tag: "sups", label: "Superscript", group: "Position" },
176
+ { tag: "subs", label: "Subscript", group: "Position" },
177
+ // Stylistic Sets ss01–ss20
178
+ ...Array.from({ length: 20 }, (_, i) => {
179
+ const n = i + 1;
180
+ const tag = `ss${String(n).padStart(2, "0")}`;
181
+ return { tag, label: `Stylistic Set ${n}`, group: "Stylistic Sets" };
182
+ })
183
+ ];
184
+ var FEATURE_BY_TAG = new Map(
185
+ FEATURES.map((f) => [f.tag, f])
186
+ );
187
+ function featureLabel(tag) {
188
+ return FEATURE_BY_TAG.get(tag)?.label ?? tag.toUpperCase();
189
+ }
190
+ function isKnownFeature(tag) {
191
+ return FEATURE_BY_TAG.has(tag);
192
+ }
193
+ function featureSettings(active) {
194
+ const tags = Array.from(active).filter(isKnownFeature);
195
+ if (tags.length === 0) return "normal";
196
+ return tags.map((tag) => `"${tag}" 1`).join(", ");
197
+ }
198
+
199
+ // src/core/typeTester.ts
200
+ var DEFAULT_RANGES = {
201
+ size: { min: 8, max: 300, step: 1 },
202
+ tracking: { min: -0.1, max: 0.5, step: 5e-3 },
203
+ weight: { min: 100, max: 900, step: 100 }
204
+ };
205
+ var ALIGNS = ["left", "center", "right"];
206
+ var idCounter = 0;
207
+ function nextId() {
208
+ return ++idCounter;
209
+ }
210
+ function resolveRange(value, fallback) {
211
+ if (!value) return null;
212
+ if (value === true) return { ...fallback };
213
+ return { min: value.min, max: value.max, step: value.step ?? fallback.step };
214
+ }
215
+ var TypeTester = class {
216
+ /**
217
+ * @param host Element to render the tester into.
218
+ * @param options Configuration; all fields optional.
219
+ */
220
+ constructor(host, options = {}) {
221
+ this.activeFeatures = /* @__PURE__ */ new Set();
222
+ this.cleanups = [];
223
+ this.sizeOutput = null;
224
+ this.fitter = null;
225
+ this.host = host;
226
+ this.options = options;
227
+ this.controls = options.controls ?? {};
228
+ const fit = options.size === "fit";
229
+ for (const tag of options.features ?? []) {
230
+ if (isKnownFeature(tag)) this.activeFeatures.add(tag);
231
+ }
232
+ this.state = {
233
+ text: options.text ?? "",
234
+ size: fit ? 0 : toNumber(options.size, 80),
235
+ fit,
236
+ tracking: toNumber(options.tracking, 0),
237
+ weight: toNumber(options.weight, 400),
238
+ italic: options.italic ?? false,
239
+ align: options.align ?? "left",
240
+ wrap: options.wrap ?? true,
241
+ features: Array.from(this.activeFeatures)
242
+ };
243
+ this.render();
244
+ }
245
+ /** Returns a snapshot of the current state. */
246
+ getState() {
247
+ return { ...this.state, features: Array.from(this.activeFeatures) };
248
+ }
249
+ /** Removes all DOM, listeners and observers created by this instance. */
250
+ destroy() {
251
+ this.fitter?.destroy();
252
+ this.fitter = null;
253
+ for (const off of this.cleanups.splice(0)) off();
254
+ this.host.replaceChildren();
255
+ }
256
+ // ---- rendering -------------------------------------------------------
257
+ render() {
258
+ this.host.classList.add("tt");
259
+ this.textEl = el("div", {
260
+ class: "tt__text",
261
+ text: this.state.text,
262
+ attrs: {
263
+ role: "textbox",
264
+ "aria-label": this.options.ariaLabel ?? "Sample text",
265
+ "aria-multiline": String(this.state.wrap),
266
+ contenteditable: this.options.editable !== false ? "true" : null,
267
+ spellcheck: "true",
268
+ "data-placeholder": this.options.placeholder ?? "Type to test\u2026"
269
+ }
270
+ });
271
+ this.typeEl = el("div", { class: "tt__type", children: [this.textEl] });
272
+ const stage = el("div", { class: "tt__stage", children: [this.typeEl] });
273
+ this.liveEl = el("div", {
274
+ class: "tt__sr-only",
275
+ attrs: { "aria-live": "polite", "aria-atomic": "true" }
276
+ });
277
+ const controls = this.buildControls();
278
+ const children = [stage];
279
+ if (controls) children.push(controls);
280
+ children.push(this.liveEl);
281
+ this.host.replaceChildren(...children);
282
+ if (this.options.editable !== false) this.wireEditable();
283
+ this.applyStyles();
284
+ this.setupFit();
285
+ }
286
+ buildControls() {
287
+ const items = [];
288
+ const sizeRange = resolveRange(this.controls.size, DEFAULT_RANGES.size);
289
+ if (sizeRange && !this.state.fit) items.push(this.buildSize(sizeRange));
290
+ const trackingRange = resolveRange(this.controls.tracking, DEFAULT_RANGES.tracking);
291
+ if (trackingRange) items.push(this.buildTracking(trackingRange));
292
+ const weightRange = resolveRange(
293
+ this.controls.weight,
294
+ this.options.variable?.wght ?? DEFAULT_RANGES.weight
295
+ );
296
+ if (weightRange) items.push(this.buildWeight(weightRange));
297
+ if (this.controls.italic) items.push(this.buildItalic());
298
+ if (this.controls.align) items.push(this.buildAlign());
299
+ if (this.controls.wrap) items.push(this.buildWrap());
300
+ if (this.controls.features) items.push(this.buildFeatures());
301
+ if (items.length === 0) return null;
302
+ return el("div", {
303
+ class: "tt__controls",
304
+ attrs: { role: "group", "aria-label": "Typography controls" },
305
+ children: items
306
+ });
307
+ }
308
+ // ---- generic control builders (no per-control copy-paste) -------------
309
+ buildSlider(key, label, range, value, unit, onInput) {
310
+ const output = el("output", { class: "tt__value", text: `${value}${unit}` });
311
+ const input = el("input", {
312
+ class: `tt__slider tt__slider--${key}`,
313
+ attrs: {
314
+ type: "range",
315
+ min: range.min,
316
+ max: range.max,
317
+ step: range.step ?? 1,
318
+ value,
319
+ "aria-label": label
320
+ }
321
+ });
322
+ const handler = () => {
323
+ const v = Number(input.value);
324
+ output.textContent = `${v}${unit}`;
325
+ onInput(v);
326
+ this.announce(`${label} ${v}${unit}`);
327
+ };
328
+ input.addEventListener("input", handler);
329
+ this.cleanups.push(() => input.removeEventListener("input", handler));
330
+ if (key === "size") this.sizeOutput = output;
331
+ return el("label", {
332
+ class: `tt__control tt__control--${key}`,
333
+ children: [el("span", { class: "tt__label", text: label }), input, output]
334
+ });
335
+ }
336
+ buildSize(range) {
337
+ this.state.size = clamp(this.state.size, range.min, range.max);
338
+ return this.buildSlider("size", "Size", range, this.state.size, "px", (v) => {
339
+ this.state.size = v;
340
+ this.applyStyles();
341
+ this.emitChange();
342
+ });
343
+ }
344
+ buildTracking(range) {
345
+ this.state.tracking = clamp(this.state.tracking, range.min, range.max);
346
+ return this.buildSlider(
347
+ "tracking",
348
+ "Tracking",
349
+ range,
350
+ this.state.tracking,
351
+ "em",
352
+ (v) => {
353
+ this.state.tracking = v;
354
+ this.applyStyles();
355
+ this.emitChange();
356
+ }
357
+ );
358
+ }
359
+ buildWeight(range) {
360
+ this.state.weight = clamp(this.state.weight, range.min, range.max);
361
+ return this.buildSlider("weight", "Weight", range, this.state.weight, "", (v) => {
362
+ this.state.weight = v;
363
+ this.applyStyles();
364
+ this.emitChange();
365
+ });
366
+ }
367
+ buildToggle(key, label, pressed, onToggle) {
368
+ const button = el("button", {
369
+ class: `tt__toggle tt__toggle--${key}`,
370
+ text: label,
371
+ // aria-pressed is a string-valued ARIA state and must always be present
372
+ // ("false"), unlike boolean HTML attributes which are omitted when off.
373
+ attrs: { type: "button", "aria-pressed": String(pressed) }
374
+ });
375
+ const handler = () => {
376
+ const next = button.getAttribute("aria-pressed") !== "true";
377
+ button.setAttribute("aria-pressed", String(next));
378
+ onToggle(next);
379
+ this.announce(`${label} ${next ? "on" : "off"}`);
380
+ };
381
+ button.addEventListener("click", handler);
382
+ this.cleanups.push(() => button.removeEventListener("click", handler));
383
+ return button;
384
+ }
385
+ buildItalic() {
386
+ const button = this.buildToggle("italic", "Italic", this.state.italic, (on) => {
387
+ this.state.italic = on;
388
+ this.applyStyles();
389
+ this.emitChange();
390
+ });
391
+ return el("div", { class: "tt__control", children: [button] });
392
+ }
393
+ buildWrap() {
394
+ const button = this.buildToggle("wrap", "Wrap", this.state.wrap, (on) => {
395
+ this.state.wrap = on;
396
+ this.textEl.setAttribute("aria-multiline", String(on));
397
+ this.applyStyles();
398
+ this.emitChange();
399
+ });
400
+ return el("div", { class: "tt__control", children: [button] });
401
+ }
402
+ buildAlign() {
403
+ const select = el("select", {
404
+ class: "tt__select tt__select--align",
405
+ attrs: { "aria-label": "Alignment" },
406
+ children: ALIGNS.map(
407
+ (a) => el("option", {
408
+ text: a.charAt(0).toUpperCase() + a.slice(1),
409
+ attrs: { value: a, selected: a === this.state.align }
410
+ })
411
+ )
412
+ });
413
+ const handler = () => {
414
+ const value = select.value;
415
+ if (ALIGNS.includes(value)) {
416
+ this.state.align = value;
417
+ this.applyStyles();
418
+ this.emitChange();
419
+ this.announce(`Alignment ${value}`);
420
+ }
421
+ };
422
+ select.addEventListener("change", handler);
423
+ this.cleanups.push(() => select.removeEventListener("change", handler));
424
+ return el("label", {
425
+ class: "tt__control tt__control--align",
426
+ children: [el("span", { class: "tt__label", text: "Align" }), select]
427
+ });
428
+ }
429
+ buildFeatures() {
430
+ const offered = Array.isArray(this.controls.features) ? this.controls.features.filter(isKnownFeature).map((tag) => FEATURE_BY_TAG.get(tag) ?? { tag, label: featureLabel(tag), group: "Alternates" }) : FEATURES;
431
+ const panelId = `tt-feat-${nextId()}`;
432
+ const toggle = el("button", {
433
+ class: "tt__toggle tt__toggle--features",
434
+ text: "Features",
435
+ attrs: {
436
+ type: "button",
437
+ "aria-haspopup": "true",
438
+ "aria-expanded": "false",
439
+ "aria-controls": panelId
440
+ }
441
+ });
442
+ const checks = offered.map((f) => {
443
+ const input = el("input", {
444
+ attrs: { type: "checkbox", value: f.tag, checked: this.activeFeatures.has(f.tag) }
445
+ });
446
+ const onChange = () => this.toggleFeature(f.tag, input.checked);
447
+ input.addEventListener("change", onChange);
448
+ this.cleanups.push(() => input.removeEventListener("change", onChange));
449
+ return el("label", {
450
+ class: "tt__feature",
451
+ children: [input, el("span", { text: f.label })]
452
+ });
453
+ });
454
+ const panel = el("div", {
455
+ class: "tt__panel",
456
+ attrs: { id: panelId, role: "group", "aria-label": "OpenType features", hidden: true },
457
+ children: checks
458
+ });
459
+ const wrapper = el("div", {
460
+ class: "tt__control tt__control--features",
461
+ children: [toggle, panel]
462
+ });
463
+ const setOpen = (open) => {
464
+ toggle.setAttribute("aria-expanded", String(open));
465
+ panel.toggleAttribute("hidden", !open);
466
+ if (open) {
467
+ const first = panel.querySelector("input");
468
+ first?.focus();
469
+ }
470
+ };
471
+ const onToggleClick = () => setOpen(toggle.getAttribute("aria-expanded") !== "true");
472
+ const onKeydown = (e) => {
473
+ if (e.key === "Escape" && toggle.getAttribute("aria-expanded") === "true") {
474
+ setOpen(false);
475
+ toggle.focus();
476
+ }
477
+ };
478
+ const onOutside = (e) => {
479
+ if (toggle.getAttribute("aria-expanded") !== "true") return;
480
+ if (!wrapper.contains(e.target)) setOpen(false);
481
+ };
482
+ toggle.addEventListener("click", onToggleClick);
483
+ document.addEventListener("keydown", onKeydown);
484
+ document.addEventListener("click", onOutside);
485
+ this.cleanups.push(() => {
486
+ toggle.removeEventListener("click", onToggleClick);
487
+ document.removeEventListener("keydown", onKeydown);
488
+ document.removeEventListener("click", onOutside);
489
+ });
490
+ return wrapper;
491
+ }
492
+ toggleFeature(tag, on) {
493
+ if (on) this.activeFeatures.add(tag);
494
+ else this.activeFeatures.delete(tag);
495
+ this.state.features = Array.from(this.activeFeatures);
496
+ this.applyStyles();
497
+ this.emitChange();
498
+ this.announce(`${featureLabel(tag)} ${on ? "on" : "off"}`);
499
+ }
500
+ // ---- behaviour -------------------------------------------------------
501
+ wireEditable() {
502
+ const handler = () => {
503
+ this.state.text = this.textEl.textContent ?? "";
504
+ if (this.state.fit) this.fitter?.schedule();
505
+ this.emitChange();
506
+ };
507
+ this.textEl.addEventListener("input", handler);
508
+ this.cleanups.push(() => this.textEl.removeEventListener("input", handler));
509
+ }
510
+ setupFit() {
511
+ if (!this.state.fit) return;
512
+ const range = resolveRange(this.controls.size, DEFAULT_RANGES.size) ?? DEFAULT_RANGES.size;
513
+ this.fitter = new Fitter(this.textEl, range.min, range.max);
514
+ this.fitter.start((size) => {
515
+ this.state.size = size;
516
+ this.typeEl.style.fontSize = `${size}px`;
517
+ if (this.sizeOutput) this.sizeOutput.textContent = `${size}px`;
518
+ });
519
+ }
520
+ /** Applies the full typographic state to the type element via element.style. */
521
+ applyStyles() {
522
+ const s = this.typeEl.style;
523
+ const family = this.options.fontFamily ? `"${this.options.fontFamily}"${this.options.fallback ? `, ${this.options.fallback}` : ", sans-serif"}` : this.options.fallback ?? "sans-serif";
524
+ s.fontFamily = family;
525
+ if (!this.state.fit) s.fontSize = `${this.state.size}px`;
526
+ s.letterSpacing = `${this.state.tracking}em`;
527
+ s.fontStyle = this.state.italic ? "italic" : "normal";
528
+ s.textAlign = this.state.align;
529
+ this.textEl.style.whiteSpace = this.state.wrap ? "normal" : "nowrap";
530
+ if (this.options.variable?.wght) {
531
+ s.fontVariationSettings = `"wght" ${this.state.weight}`;
532
+ }
533
+ s.fontWeight = String(this.state.weight);
534
+ const settings = featureSettings(this.activeFeatures);
535
+ s.fontFeatureSettings = settings;
536
+ s.setProperty("-webkit-font-feature-settings", settings);
537
+ }
538
+ announce(message) {
539
+ this.liveEl.textContent = message;
540
+ }
541
+ emitChange() {
542
+ this.options.onChange?.(this.getState());
543
+ }
544
+ };
545
+ function TypeTesterComponent(props) {
546
+ const { className, onChange, ...options } = props;
547
+ const hostRef = react.useRef(null);
548
+ const onChangeRef = react.useRef(onChange);
549
+ onChangeRef.current = onChange;
550
+ const key = JSON.stringify(options);
551
+ react.useEffect(() => {
552
+ const host = hostRef.current;
553
+ if (!host) return;
554
+ const instance = new TypeTester(host, {
555
+ ...options,
556
+ onChange: (state) => onChangeRef.current?.(state)
557
+ });
558
+ return () => instance.destroy();
559
+ }, [key]);
560
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { ref: hostRef, className });
561
+ }
562
+
563
+ exports.TypeTester = TypeTester;
564
+ exports.TypeTesterComponent = TypeTesterComponent;
565
+ //# sourceMappingURL=react.cjs.map
566
+ //# sourceMappingURL=react.cjs.map