@cosmicdrift/kumiko-renderer-web 0.185.0 → 0.186.1

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-renderer-web",
3
- "version": "0.185.0",
3
+ "version": "0.186.1",
4
4
  "description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -16,9 +16,9 @@
16
16
  "./styles.css": "./src/styles.css"
17
17
  },
18
18
  "dependencies": {
19
- "@cosmicdrift/kumiko-dispatcher-live": "0.185.0",
20
- "@cosmicdrift/kumiko-headless": "0.185.0",
21
- "@cosmicdrift/kumiko-renderer": "0.185.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.186.1",
20
+ "@cosmicdrift/kumiko-headless": "0.186.1",
21
+ "@cosmicdrift/kumiko-renderer": "0.186.1",
22
22
  "@radix-ui/react-dialog": "^1.1.15",
23
23
  "@radix-ui/react-dropdown-menu": "^2.1.16",
24
24
  "@radix-ui/react-label": "^2.1.8",
@@ -183,6 +183,13 @@ describe("defaultCellRender", () => {
183
183
  expect(result).not.toMatch(/[.,]\d\d$/);
184
184
  });
185
185
 
186
+ test("money → rehydrated { amount major, amountMinor } uses amountMinor (not major×100 wrong)", () => {
187
+ // rehydrateMoney (fw#1830): amount is major units, amountMinor is cents.
188
+ // formatMoney expects cents — using amount would show 100× too small.
189
+ const result = defaultCellRender({ amount: 119, currency: "EUR", amountMinor: 11900 }, "money");
190
+ expect(result.replace(/[^0-9]/g, "")).toBe("11900");
191
+ });
192
+
186
193
  test("money → unerwarteter Value-Shape fällt auf String zurück", () => {
187
194
  expect(defaultCellRender(45000, "money")).toBe("45000");
188
195
  });
@@ -104,6 +104,23 @@ describe("MoneyInput — Render (Tier 2)", () => {
104
104
  expect(value).not.toContain("€");
105
105
  });
106
106
 
107
+ test("Focus selektiert den gesamten Draft (bewusstes Select-all-on-focus) — sonst hängt ein danach eingefügter Wert an statt sie zu ersetzen (#1856)", () => {
108
+ render(
109
+ <MoneyInput
110
+ id="amt"
111
+ name="amt"
112
+ value={1000}
113
+ onChange={() => {}}
114
+ currency="EUR"
115
+ locale="de-DE"
116
+ />,
117
+ );
118
+ fireEvent.focus(inputEl());
119
+ const input = inputEl();
120
+ expect(input.selectionStart).toBe(0);
121
+ expect(input.selectionEnd).toBe(input.value.length);
122
+ });
123
+
107
124
  test("Blur mit neuem Wert ruft onChange mit Minor-Units (Cents)", () => {
108
125
  const onChange = mock((_v: number | undefined) => {});
109
126
  render(
@@ -180,10 +180,12 @@ function DefaultBanner({
180
180
  actions,
181
181
  padded,
182
182
  testId,
183
+ id,
183
184
  }: BannerProps): ReactNode {
184
185
  const isError = variant === "error";
185
186
  const banner = (
186
187
  <div
188
+ id={id}
187
189
  data-testid={testId}
188
190
  role={isError ? "alert" : undefined}
189
191
  data-variant={variant}
@@ -1348,7 +1350,14 @@ export function isComponentRendererRef(renderer: unknown): { readonly name: stri
1348
1350
  // applyFormatSpec re-exported from headless (platform-agnostic).
1349
1351
  export { applyFormatSpec };
1350
1352
 
1351
- function isMoneyValue(value: unknown): value is { amount: number; currency: string } {
1353
+ type MoneyCellValue = {
1354
+ amount: number;
1355
+ currency: string;
1356
+ /** Exact integer cents from rehydrateMoney (fw#1830); preferred for display. */
1357
+ amountMinor?: number;
1358
+ };
1359
+
1360
+ function isMoneyValue(value: unknown): value is MoneyCellValue {
1352
1361
  return (
1353
1362
  typeof value === "object" &&
1354
1363
  value !== null &&
@@ -1383,7 +1392,12 @@ export function defaultCellRender(
1383
1392
  // falls back to guessLocale() (navigator.language), so the same amount
1384
1393
  // can render differently in a table vs. a form on the same screen.
1385
1394
  // Mid-term: pass the app locale down here too, analogous to render-field.
1386
- return formatMoney(value.amount, value.currency);
1395
+ //
1396
+ // formatMoney expects minor units. rehydrateMoney returns amount in MAJOR
1397
+ // units plus amountMinor (cents). Prefer amountMinor when present; legacy
1398
+ // shapes without it still carry cents in `amount`.
1399
+ const minor = typeof value.amountMinor === "number" ? value.amountMinor : value.amount;
1400
+ return formatMoney(minor, value.currency);
1387
1401
  }
1388
1402
  if (type === "select") {
1389
1403
  const raw = String(value);
@@ -15,7 +15,7 @@
15
15
  // Cent-genaue Steps will tippt halt im Focus-Modus.
16
16
 
17
17
  import { Minus, Plus } from "lucide-react";
18
- import { type FocusEvent, type ReactNode, useState } from "react";
18
+ import { type ReactNode, useEffect, useRef, useState } from "react";
19
19
  import { cn } from "../lib/cn";
20
20
 
21
21
  export type MoneyInputProps = {
@@ -62,6 +62,7 @@ export function MoneyInput({
62
62
  // Raw-Edit-Buffer während Focus. Sonst würde jeder Tipp-Step durch
63
63
  // Math.round → format-Roundtrip jagen und der Cursor würde springen.
64
64
  const [draft, setDraft] = useState<string>("");
65
+ const inputRef = useRef<HTMLInputElement>(null);
65
66
 
66
67
  const major = value === "" ? null : value / factor;
67
68
  const formatted = value === "" ? "" : formatMoney(value, currency, resolvedLocale);
@@ -79,11 +80,27 @@ export function MoneyInput({
79
80
 
80
81
  const editable = focused ? draft : toEditable(major);
81
82
 
82
- const handleFocus = (_e: FocusEvent<HTMLInputElement>): void => {
83
+ const handleFocus = (): void => {
83
84
  setDraft(toEditable(major));
84
85
  setFocused(true);
85
86
  };
86
87
 
88
+ // Select-all-on-focus: deliberate, not just a Playwright accommodation.
89
+ // Focus always swaps the displayed value from the formatted string
90
+ // ("1.234,56 €") to the raw editable one ("1234,56"). Measured in a real
91
+ // browser: on this kind of value swap, the browser collapses the cursor
92
+ // to the *end* of the new value rather than preserving position — so
93
+ // without an explicit re-select, typing right after focus appends
94
+ // instead of replacing. That's what silently corrupted values set via
95
+ // Playwright's `.fill()` (framework#1856). It also matches standard
96
+ // money-input UX (immediate overtype on click), and sidesteps mapping a
97
+ // click position from the formatted view to the editable one, which has
98
+ // no well-defined equivalent once separators and the currency symbol
99
+ // are stripped.
100
+ useEffect(() => {
101
+ if (focused) inputRef.current?.select();
102
+ }, [focused]);
103
+
87
104
  const handleBlur = (): void => {
88
105
  setFocused(false);
89
106
  if (draft.trim() === "") {
@@ -103,6 +120,7 @@ export function MoneyInput({
103
120
  return (
104
121
  <div className="relative w-full">
105
122
  <input
123
+ ref={inputRef}
106
124
  id={id}
107
125
  name={name}
108
126
  type="text"