@cosmicdrift/kumiko-headless 0.211.0 → 0.213.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-headless",
3
- "version": "0.211.0",
3
+ "version": "0.213.0",
4
4
  "description": "Headless UI logic for Kumiko — Dispatcher contract, Form-Controller, View-Model, Nav-Resolver. Plattform- und React-frei; jeder Renderer (renderer, renderer-web, renderer-native, …) komponiert darauf.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -36,7 +36,7 @@
36
36
  }
37
37
  },
38
38
  "dependencies": {
39
- "@cosmicdrift/kumiko-framework": "0.211.0",
39
+ "@cosmicdrift/kumiko-framework": "0.213.0",
40
40
  "temporal-polyfill": "^0.3.2",
41
41
  "zod": "^4.4.3"
42
42
  },
@@ -90,3 +90,67 @@ describe("applyFormatSpec — timestamp/date (formatDateCell-Pfad)", () => {
90
90
  expect(out).toContain("2026");
91
91
  });
92
92
  });
93
+
94
+ describe("applyFormatSpec — unit (fw#2187)", () => {
95
+ test("km/m/kg/percent gehen über Intl.NumberFormat(style:'unit'), locale-korrekt", () => {
96
+ expect(applyFormatSpec({ format: "unit", unit: "km" }, 58)).toBe(
97
+ new Intl.NumberFormat(undefined, {
98
+ style: "unit",
99
+ unit: "kilometer",
100
+ unitDisplay: "short",
101
+ }).format(58),
102
+ );
103
+ expect(applyFormatSpec({ format: "unit", unit: "km", locale: "de-DE" }, 58)).toBe("58 km");
104
+ expect(applyFormatSpec({ format: "unit", unit: "kg", locale: "de-DE" }, 2.5)).toBe("2,5 kg");
105
+ // de-DE percent uses a narrow no-break space (U+202F) before "%", not a
106
+ // plain space — assert against Intl's own output rather than guessing.
107
+ expect(applyFormatSpec({ format: "unit", unit: "percent", locale: "de-DE" }, 12)).toBe(
108
+ new Intl.NumberFormat("de-DE", {
109
+ style: "unit",
110
+ unit: "percent",
111
+ unitDisplay: "short",
112
+ }).format(12),
113
+ );
114
+ });
115
+
116
+ test("m2 hat kein ECMA-402-sanktioniertes Intl-Unit (RangeError für 'square-meter') — Zahl + literales Suffix", () => {
117
+ expect(applyFormatSpec({ format: "unit", unit: "m2", locale: "de-DE" }, 58)).toBe("58 m²");
118
+ expect(applyFormatSpec({ format: "unit", unit: "m2", locale: "de-DE" }, 1234.5)).toBe(
119
+ "1.234,5 m²",
120
+ );
121
+ });
122
+
123
+ test("unitDisplay steuert long", () => {
124
+ expect(
125
+ applyFormatSpec({ format: "unit", unit: "km", locale: "en-US", unitDisplay: "long" }, 3),
126
+ ).toBe("3 kilometers");
127
+ expect(
128
+ applyFormatSpec({ format: "unit", unit: "km", locale: "en-US", unitDisplay: "narrow" }, 3),
129
+ ).toBe(
130
+ new Intl.NumberFormat("en-US", {
131
+ style: "unit",
132
+ unit: "kilometer",
133
+ unitDisplay: "narrow",
134
+ }).format(3),
135
+ );
136
+ });
137
+
138
+ test("nicht-numerischer Wert fällt auf String zurück", () => {
139
+ expect(applyFormatSpec({ format: "unit", unit: "km" }, "not-a-number")).toBe("not-a-number");
140
+ });
141
+
142
+ test("fehlende/unbekannte unit fällt auf String zurück, statt zu werfen", () => {
143
+ expect(applyFormatSpec({ format: "unit" }, 58)).toBe("58");
144
+ expect(applyFormatSpec({ format: "unit", unit: "does-not-exist" }, 58)).toBe("58");
145
+ });
146
+
147
+ test("Prototype-Chain-Keys (toString, constructor) zählen NICHT als unit — Object.hasOwn statt `in`", () => {
148
+ expect(applyFormatSpec({ format: "unit", unit: "toString" }, 58)).toBe("58");
149
+ expect(applyFormatSpec({ format: "unit", unit: "constructor" }, 58)).toBe("58");
150
+ });
151
+
152
+ test("leerer Wert collapst zu '' wie jedes andere Nicht-priority-Format", () => {
153
+ expect(applyFormatSpec({ format: "unit", unit: "km" }, null)).toBe("");
154
+ expect(applyFormatSpec({ format: "unit", unit: "km" }, "")).toBe("");
155
+ });
156
+ });
@@ -80,6 +80,54 @@ function formatNumberCell(value: unknown, locale?: string): string {
80
80
  }
81
81
  }
82
82
 
83
+ // Two-tier unit table for `{ format: "unit" }`. Keys mirror
84
+ // @cosmicdrift/kumiko-types' UnitKey (kept as a local literal union instead
85
+ // of an import — headless has no build-time dependency on kumiko-types
86
+ // today, and applyFormatSpec's spec param is already an untyped
87
+ // Record<string, unknown>, cast per-case like every other format below).
88
+ const UNIT_INTL_IDS = {
89
+ km: "kilometer",
90
+ m: "meter",
91
+ kg: "kilogram",
92
+ percent: "percent",
93
+ } as const;
94
+
95
+ // "m2" (square meter) has no ECMA-402 sanctioned unit — Intl.NumberFormat
96
+ // throws RangeError for "square-meter". Locale-format the number and append
97
+ // the literal suffix instead of going through `style: "unit"`.
98
+ const UNIT_SUFFIXES = { m2: "m²" } as const;
99
+
100
+ type UnitKey = keyof typeof UNIT_INTL_IDS | keyof typeof UNIT_SUFFIXES;
101
+
102
+ function isUnitKey(unit: string): unit is UnitKey {
103
+ // Object.hasOwn, not `in` — `in` walks the prototype chain, so
104
+ // "toString" or "constructor" would pass as a valid unit key.
105
+ return Object.hasOwn(UNIT_INTL_IDS, unit) || Object.hasOwn(UNIT_SUFFIXES, unit);
106
+ }
107
+
108
+ function formatUnitCell(
109
+ value: unknown,
110
+ unit: string | undefined,
111
+ locale: string | undefined,
112
+ unitDisplay: Intl.NumberFormatOptions["unitDisplay"],
113
+ ): string {
114
+ const n = typeof value === "number" ? value : Number(value);
115
+ if (unit === undefined || !isUnitKey(unit) || !Number.isFinite(n)) return String(value);
116
+ if (Object.hasOwn(UNIT_INTL_IDS, unit)) {
117
+ try {
118
+ return new Intl.NumberFormat(locale, {
119
+ style: "unit",
120
+ unit: UNIT_INTL_IDS[unit as keyof typeof UNIT_INTL_IDS],
121
+ unitDisplay,
122
+ }).format(n);
123
+ } catch {
124
+ // Malformed BCP-47 locale tag — mirrors formatNumberCell's fallback below.
125
+ return formatNumberCell(n, locale);
126
+ }
127
+ }
128
+ return `${formatNumberCell(n, locale)} ${UNIT_SUFFIXES[unit as keyof typeof UNIT_SUFFIXES]}`;
129
+ }
130
+
83
131
  export { escapeHtml, escapeHtmlAttr, escapeXml, isSafeHref, stripControlChars } from "./escape";
84
132
  export { type HtmlValue, html, RawHtml, raw } from "./html-template";
85
133
  export { currencyDecimals } from "./money";
@@ -115,6 +163,13 @@ export function applyFormatSpec(
115
163
  case "decimal":
116
164
  case "bigInt":
117
165
  return formatNumberCell(value, spec["locale"] as string | undefined);
166
+ case "unit":
167
+ return formatUnitCell(
168
+ value,
169
+ spec["unit"] as string | undefined,
170
+ spec["locale"] as string | undefined,
171
+ (spec["unitDisplay"] as Intl.NumberFormatOptions["unitDisplay"]) ?? "short",
172
+ );
118
173
  case "priority": {
119
174
  const emptyLabel = (spec["emptyLabel"] as string | undefined) ?? "—";
120
175
  const prefix = (spec["prefix"] as string | undefined) ?? "";