@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.
|
|
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.
|
|
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
|
+
});
|
package/src/format/index.ts
CHANGED
|
@@ -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) ?? "";
|