@cosmicdrift/kumiko-renderer-web 0.186.3 → 0.188.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 +4 -4
- package/src/__tests__/config-edit.test.tsx +66 -0
- package/src/__tests__/primitives.test.tsx +121 -0
- package/src/__tests__/render-edit.test.tsx +1368 -1
- package/src/__tests__/test-utils.tsx +24 -0
- package/src/__tests__/wizard-form-validation.test.tsx +73 -0
- package/src/app/create-app.tsx +33 -20
- package/src/app/draft-storage.ts +59 -0
- package/src/index.ts +4 -0
- package/src/primitives/__tests__/date-parse.test.ts +26 -1
- package/src/primitives/__tests__/embedded-list-input.test.tsx +29 -0
- package/src/primitives/date-field.tsx +13 -5
- package/src/primitives/date-parse.ts +30 -7
- package/src/primitives/embedded-list-input.tsx +5 -3
- package/src/primitives/index.tsx +49 -7
- package/src/primitives/money-input.tsx +5 -9
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
} from "@cosmicdrift/kumiko-headless";
|
|
22
22
|
import {
|
|
23
23
|
createStaticLocaleResolver,
|
|
24
|
+
type DraftStorage,
|
|
24
25
|
kumikoDefaultTranslations,
|
|
25
26
|
type LiveEventSubscriber,
|
|
26
27
|
LiveEventsProvider,
|
|
@@ -163,4 +164,27 @@ export function createMockDispatcher(options: MockDispatcherOptions = {}): Dispa
|
|
|
163
164
|
};
|
|
164
165
|
}
|
|
165
166
|
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// Fake-DraftStorage-Helper
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
/** In-memory DraftStorage for tests that need RenderEdit's create-mode
|
|
172
|
+
* draftId to survive a same-tab remount (issue #1913) — the same role
|
|
173
|
+
* `sessionStorage` plays for `createBrowserDraftStorage()` in production.
|
|
174
|
+
* Fresh Map per call, so tests stay isolated from one another; wrap the
|
|
175
|
+
* SAME instance around both renders of a "simulated reload" test to prove
|
|
176
|
+
* persistence across the remount. */
|
|
177
|
+
export function createFakeDraftStorage(): DraftStorage {
|
|
178
|
+
const store = new Map<string, string>();
|
|
179
|
+
return {
|
|
180
|
+
getDraftId: (screenId) => store.get(screenId) ?? null,
|
|
181
|
+
setDraftId: (screenId, draftId) => {
|
|
182
|
+
store.set(screenId, draftId);
|
|
183
|
+
},
|
|
184
|
+
clearDraftId: (screenId) => {
|
|
185
|
+
store.delete(screenId);
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
166
190
|
export * from "@testing-library/react";
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// fw#1910: the auto-wired entityEdit path never set RenderEdit's `schema`
|
|
2
|
+
// prop, so the wizard's per-step "Next" validation was a no-op — a
|
|
3
|
+
// required field left empty on step 1 still advanced to step 2. This test
|
|
4
|
+
// covers the fix end to end (KumikoScreen → EntityEditCreateBody →
|
|
5
|
+
// RenderEdit) with real DOM primitives, not a hand-rolled unit call.
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import type {
|
|
8
|
+
EntityDefinition,
|
|
9
|
+
EntityEditScreenDefinition,
|
|
10
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
11
|
+
import type { FeatureSchema } from "@cosmicdrift/kumiko-renderer";
|
|
12
|
+
import { DispatcherProvider, KumikoScreen } from "@cosmicdrift/kumiko-renderer";
|
|
13
|
+
import userEvent from "@testing-library/user-event";
|
|
14
|
+
import { createMockDispatcher, render, screen } from "./test-utils";
|
|
15
|
+
|
|
16
|
+
const profileEntity = {
|
|
17
|
+
fields: {
|
|
18
|
+
fullName: { type: "text", required: true },
|
|
19
|
+
email: { type: "text", required: false },
|
|
20
|
+
},
|
|
21
|
+
} as unknown as EntityDefinition;
|
|
22
|
+
|
|
23
|
+
const wizardScreen: EntityEditScreenDefinition = {
|
|
24
|
+
id: "profile-edit",
|
|
25
|
+
type: "entityEdit",
|
|
26
|
+
entity: "profile",
|
|
27
|
+
layout: {
|
|
28
|
+
mode: "wizard",
|
|
29
|
+
sections: [
|
|
30
|
+
{ title: "Step 1", fields: ["fullName"] },
|
|
31
|
+
{ title: "Step 2", fields: ["email"] },
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const schema: FeatureSchema = {
|
|
37
|
+
featureName: "demo",
|
|
38
|
+
entities: { profile: profileEntity },
|
|
39
|
+
screens: [wizardScreen],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function renderWizard() {
|
|
43
|
+
return render(
|
|
44
|
+
<DispatcherProvider dispatcher={createMockDispatcher()}>
|
|
45
|
+
<KumikoScreen schema={schema} qn="demo:screen:profile-edit" />
|
|
46
|
+
</DispatcherProvider>,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe("entityEdit wizard — presence validation on Next (fw#1910)", () => {
|
|
51
|
+
test("empty required field blocks the step transition and shows a field error", async () => {
|
|
52
|
+
renderWizard();
|
|
53
|
+
|
|
54
|
+
await userEvent.click(screen.getByTestId("render-edit-wizard-next"));
|
|
55
|
+
|
|
56
|
+
expect(screen.getByTestId("render-edit-wizard-step-label").textContent).toContain("1");
|
|
57
|
+
expect(screen.getByTestId("field-fullName-errors")).toBeTruthy();
|
|
58
|
+
// Step 2's field never mounts — the transition was blocked.
|
|
59
|
+
expect(document.querySelector("#kumiko-edit-email")).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("filling the required field allows Next to advance to step 2", async () => {
|
|
63
|
+
const { container } = renderWizard();
|
|
64
|
+
|
|
65
|
+
const fullNameInput = container.querySelector("#kumiko-edit-fullName");
|
|
66
|
+
expect(fullNameInput).toBeTruthy();
|
|
67
|
+
await userEvent.type(fullNameInput as Element, "Ada Lovelace");
|
|
68
|
+
await userEvent.click(screen.getByTestId("render-edit-wizard-next"));
|
|
69
|
+
|
|
70
|
+
expect(screen.getByTestId("render-edit-wizard-step-label").textContent).toContain("2");
|
|
71
|
+
expect(screen.queryByTestId("field-fullName-errors")).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
});
|
package/src/app/create-app.tsx
CHANGED
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
CustomScreensProvider,
|
|
17
17
|
DashboardBodyProvider,
|
|
18
18
|
DispatcherProvider,
|
|
19
|
+
type DraftStorage,
|
|
20
|
+
DraftStorageProvider,
|
|
19
21
|
type ExtensionSectionComponent,
|
|
20
22
|
ExtensionSectionsProvider,
|
|
21
23
|
type FeatureSchema,
|
|
@@ -47,6 +49,7 @@ import { UpdateChecker } from "../version/update-checker";
|
|
|
47
49
|
import { createBrowserLocaleResolver } from "./browser-locale";
|
|
48
50
|
import { type ClientFeatureDefinition, stackWrappers } from "./client-plugin";
|
|
49
51
|
import { WebDashboardBody } from "./dashboard-body";
|
|
52
|
+
import { createBrowserDraftStorage } from "./draft-storage";
|
|
50
53
|
import { useBrowserNavApi } from "./nav";
|
|
51
54
|
import { NavProvidersProvider } from "./nav-providers-context";
|
|
52
55
|
import { type ResolverComponent, ResolversProvider } from "./resolvers-context";
|
|
@@ -133,6 +136,13 @@ export type CreateKumikoAppOptions = {
|
|
|
133
136
|
readonly schema?: AppSchema | FeatureSchema;
|
|
134
137
|
readonly rootId?: string;
|
|
135
138
|
readonly dispatcher?: Dispatcher;
|
|
139
|
+
/** RenderEdit's create-mode draftId storage (issue #1913) — where a
|
|
140
|
+
* same-tab reload finds which of several parallel create-sessions on a
|
|
141
|
+
* screen to resume. Default: `createBrowserDraftStorage()`
|
|
142
|
+
* (sessionStorage-backed). Apps that don't want any client-side draftId
|
|
143
|
+
* persistence can pass a no-op impl; RenderEdit still resolves via its
|
|
144
|
+
* `form-draft:query:list` mount-time fallback either way. */
|
|
145
|
+
readonly draftStorage?: DraftStorage;
|
|
136
146
|
readonly screenQn?: string;
|
|
137
147
|
readonly translate?: Translate;
|
|
138
148
|
readonly onRowClick?: (row: ListRowViewModel, entityName: string) => void;
|
|
@@ -274,6 +284,7 @@ export function createKumikoApp(options: CreateKumikoAppOptions = {}): { readonl
|
|
|
274
284
|
}
|
|
275
285
|
|
|
276
286
|
const dispatcher = options.dispatcher ?? createLiveDispatcher();
|
|
287
|
+
const draftStorage = options.draftStorage ?? createBrowserDraftStorage();
|
|
277
288
|
const primitives: PrimitivesRegistry = { ...defaultPrimitives, ...(options.primitives ?? {}) };
|
|
278
289
|
const liveEvents = createEventSourceLiveEvents();
|
|
279
290
|
|
|
@@ -389,26 +400,28 @@ export function createKumikoApp(options: CreateKumikoAppOptions = {}): { readonl
|
|
|
389
400
|
<PrimitivesProvider value={primitives}>
|
|
390
401
|
<AppFeaturesProvider features={app.features}>
|
|
391
402
|
<DispatcherProvider dispatcher={dispatcher}>
|
|
392
|
-
<
|
|
393
|
-
<
|
|
394
|
-
<
|
|
395
|
-
<
|
|
396
|
-
<
|
|
397
|
-
<
|
|
398
|
-
<
|
|
399
|
-
<
|
|
400
|
-
<
|
|
401
|
-
<
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
403
|
+
<DraftStorageProvider value={draftStorage}>
|
|
404
|
+
<LiveEventsProvider value={liveEvents}>
|
|
405
|
+
<DashboardBodyProvider value={WebDashboardBody}>
|
|
406
|
+
<CustomScreensProvider value={customScreens}>
|
|
407
|
+
<ColumnRenderersProvider value={columnRenderers}>
|
|
408
|
+
<ContentEditorsProvider value={contentEditors}>
|
|
409
|
+
<ExtensionSectionsProvider value={extensionSectionComponents}>
|
|
410
|
+
<NavProvidersProvider value={navProviders} entities={navEntities}>
|
|
411
|
+
<ResolversProvider resolvers={resolvers}>
|
|
412
|
+
<ToastProvider>
|
|
413
|
+
<UpdateChecker />
|
|
414
|
+
{stackWrappers(providers, stackWrappers(gates, screenNode))}
|
|
415
|
+
</ToastProvider>
|
|
416
|
+
</ResolversProvider>
|
|
417
|
+
</NavProvidersProvider>
|
|
418
|
+
</ExtensionSectionsProvider>
|
|
419
|
+
</ContentEditorsProvider>
|
|
420
|
+
</ColumnRenderersProvider>
|
|
421
|
+
</CustomScreensProvider>
|
|
422
|
+
</DashboardBodyProvider>
|
|
423
|
+
</LiveEventsProvider>
|
|
424
|
+
</DraftStorageProvider>
|
|
412
425
|
</DispatcherProvider>
|
|
413
426
|
</AppFeaturesProvider>
|
|
414
427
|
</PrimitivesProvider>
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Browser-backed DraftStorage default for createKumikoApp (issue #1913).
|
|
2
|
+
//
|
|
3
|
+
// Persists a create-mode draftId per screen in `sessionStorage` — same-tab
|
|
4
|
+
// scope only (deliberately, unlike browser-locale.ts's localStorage): the
|
|
5
|
+
// draft row itself already survives a new tab / cleared storage via
|
|
6
|
+
// RenderEdit's `form-draft:query:list` mount-time fallback, so this is only
|
|
7
|
+
// about not losing the draftId on an accidental same-tab reload.
|
|
8
|
+
|
|
9
|
+
import type { DraftStorage } from "@cosmicdrift/kumiko-renderer";
|
|
10
|
+
|
|
11
|
+
export type CreateBrowserDraftStorageOptions = {
|
|
12
|
+
/** sessionStorage-key prefix, one entry per screenId gets appended.
|
|
13
|
+
* Default: `"kumiko:draft-id:"`. */
|
|
14
|
+
readonly storagePrefix?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function keyFor(prefix: string, screenId: string): string {
|
|
18
|
+
return `${prefix}${screenId}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Default DraftStorage when createKumikoApp boots without one. Guards every
|
|
22
|
+
* sessionStorage call — Safari private mode and storage-disabled browsers
|
|
23
|
+
* throw on access, and a lost draftId just falls back to the `list` resume
|
|
24
|
+
* path (same UX as a genuinely cleared storage), not a broken form. */
|
|
25
|
+
export function createBrowserDraftStorage(
|
|
26
|
+
options: CreateBrowserDraftStorageOptions = {},
|
|
27
|
+
): DraftStorage {
|
|
28
|
+
const prefix = options.storagePrefix ?? "kumiko:draft-id:";
|
|
29
|
+
return {
|
|
30
|
+
getDraftId: (screenId) => {
|
|
31
|
+
if (typeof sessionStorage === "undefined") return null;
|
|
32
|
+
try {
|
|
33
|
+
return sessionStorage.getItem(keyFor(prefix, screenId));
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
setDraftId: (screenId, draftId) => {
|
|
39
|
+
// skip: no sessionStorage in this environment (SSR, disabled) — the
|
|
40
|
+
// draftId just stays in-memory-only for this render.
|
|
41
|
+
if (typeof sessionStorage === "undefined") return;
|
|
42
|
+
try {
|
|
43
|
+
sessionStorage.setItem(keyFor(prefix, screenId), draftId);
|
|
44
|
+
} catch {
|
|
45
|
+
// Persistence failure isn't fatal — the draft stays usable in the
|
|
46
|
+
// current tab, only a reload would lose the draftId.
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
clearDraftId: (screenId) => {
|
|
50
|
+
// skip: no sessionStorage in this environment — nothing to clear.
|
|
51
|
+
if (typeof sessionStorage === "undefined") return;
|
|
52
|
+
try {
|
|
53
|
+
sessionStorage.removeItem(keyFor(prefix, screenId));
|
|
54
|
+
} catch {
|
|
55
|
+
// skip: nothing to clean up if storage is unreachable.
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -36,6 +36,8 @@ export type {
|
|
|
36
36
|
PrimitivesProviderProps,
|
|
37
37
|
PrimitivesRegistry,
|
|
38
38
|
RadiusTokens,
|
|
39
|
+
RenderEditChangeState,
|
|
40
|
+
RenderEditControls,
|
|
39
41
|
RenderEditProps,
|
|
40
42
|
RenderFieldProps,
|
|
41
43
|
RenderListProps,
|
|
@@ -93,6 +95,8 @@ export type { CreateKumikoAppOptions } from "./app/create-app";
|
|
|
93
95
|
export { createKumikoApp } from "./app/create-app";
|
|
94
96
|
export type { CreatePublicSurfaceOptions, PublicRoute } from "./app/create-public-surface";
|
|
95
97
|
export { createPublicSurface } from "./app/create-public-surface";
|
|
98
|
+
export type { CreateBrowserDraftStorageOptions } from "./app/draft-storage";
|
|
99
|
+
export { createBrowserDraftStorage } from "./app/draft-storage";
|
|
96
100
|
export type { KumikoLinkProps } from "./app/nav";
|
|
97
101
|
export { KumikoLink, useBrowserNavApi } from "./app/nav";
|
|
98
102
|
export { PlainContentEditor } from "./app/plain-content-editor";
|
|
@@ -6,7 +6,16 @@
|
|
|
6
6
|
|
|
7
7
|
import { describe, expect, test } from "bun:test";
|
|
8
8
|
import { Temporal } from "temporal-polyfill";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
formatDateForInput,
|
|
11
|
+
formatDatePlaceholder,
|
|
12
|
+
parseIso,
|
|
13
|
+
parseTypedDate,
|
|
14
|
+
toIso,
|
|
15
|
+
} from "../date-parse";
|
|
16
|
+
|
|
17
|
+
const dePlaceholderLetters = { year: "J", month: "M", day: "T" };
|
|
18
|
+
const enPlaceholderLetters = { year: "Y", month: "M", day: "D" };
|
|
10
19
|
|
|
11
20
|
describe("parseIso", () => {
|
|
12
21
|
test("valid yyyy-mm-dd → PlainDate (no TZ conversion)", () => {
|
|
@@ -135,3 +144,19 @@ describe("formatDateForInput", () => {
|
|
|
135
144
|
if (roundtrip !== undefined) expect(toIso(roundtrip)).toBe("2026-04-25");
|
|
136
145
|
});
|
|
137
146
|
});
|
|
147
|
+
|
|
148
|
+
// #1865: placeholder shows the locale's format pattern, not a hardcoded
|
|
149
|
+
// example date that reads as an already-filled-in value.
|
|
150
|
+
describe("formatDatePlaceholder", () => {
|
|
151
|
+
test("de-DE → day.month.year pattern with dot separator", () => {
|
|
152
|
+
expect(formatDatePlaceholder("de-DE", dePlaceholderLetters)).toBe("TT.MM.JJJJ");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("en-US → month/day/year pattern with slash separator", () => {
|
|
156
|
+
expect(formatDatePlaceholder("en-US", enPlaceholderLetters)).toBe("MM/DD/YYYY");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("en-GB → day/month/year pattern with slash separator", () => {
|
|
160
|
+
expect(formatDatePlaceholder("en-GB", enPlaceholderLetters)).toBe("DD/MM/YYYY");
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -605,3 +605,32 @@ describe("EmbeddedListInput — currency (#1839)", () => {
|
|
|
605
605
|
expect(totals.textContent).toContain("€");
|
|
606
606
|
});
|
|
607
607
|
});
|
|
608
|
+
|
|
609
|
+
describe("EmbeddedListInput — desktop table width (solon#107)", () => {
|
|
610
|
+
test("the table keeps min-w-max so columns don't shrink below their width classes", () => {
|
|
611
|
+
const rows = [{ description: "A", quantity: 1, amount: 100 }];
|
|
612
|
+
renderWithLocale(<EmbeddedListInput {...baseProps({ rows })} />);
|
|
613
|
+
const desktop = screen.getByTestId("lines-desktop");
|
|
614
|
+
const table = desktop.querySelector("table");
|
|
615
|
+
if (table === null) throw new Error("expected a <table> in the desktop layout");
|
|
616
|
+
expect(table.className).toContain("min-w-max");
|
|
617
|
+
|
|
618
|
+
const headers = desktop.querySelectorAll("th");
|
|
619
|
+
expect(headers[0]?.className).toContain("min-w-[10rem]");
|
|
620
|
+
expect(headers[1]?.className).toContain("w-36");
|
|
621
|
+
expect(headers[2]?.className).toContain("w-44");
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
test("a money column is wider than a decimal/number column (framework#1880)", () => {
|
|
625
|
+
const columns: readonly EmbeddedListColumn[] = [
|
|
626
|
+
{ field: "quantity", label: "Qty", type: "number", required: true, derived: false },
|
|
627
|
+
{ field: "amount", label: "Amount", type: "money", required: false, derived: true },
|
|
628
|
+
];
|
|
629
|
+
const rows = [{ quantity: 1, amount: 100 }];
|
|
630
|
+
renderWithLocale(<EmbeddedListInput {...baseProps({ columns, rows })} />);
|
|
631
|
+
const desktop = screen.getByTestId("lines-desktop");
|
|
632
|
+
const headers = desktop.querySelectorAll("th");
|
|
633
|
+
expect(headers[0]?.className).toContain("w-36");
|
|
634
|
+
expect(headers[1]?.className).toContain("w-44");
|
|
635
|
+
});
|
|
636
|
+
});
|
|
@@ -12,7 +12,14 @@ import { type ReactNode, useState } from "react";
|
|
|
12
12
|
import { Temporal } from "temporal-polyfill";
|
|
13
13
|
import { cn } from "../lib/cn";
|
|
14
14
|
import { CalendarPopover } from "./calendar-popover";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
formatDateForInput,
|
|
17
|
+
formatDatePlaceholder,
|
|
18
|
+
guessLocale,
|
|
19
|
+
parseIso,
|
|
20
|
+
parseTypedDate,
|
|
21
|
+
toIso,
|
|
22
|
+
} from "./date-parse";
|
|
16
23
|
|
|
17
24
|
// CalendarPopover wraps react-day-picker, which only accepts native Date
|
|
18
25
|
// objects — the PlainDate↔Date boundary conversion stays confined to
|
|
@@ -98,10 +105,11 @@ export function DateField({
|
|
|
98
105
|
disabled={disabled}
|
|
99
106
|
required={required}
|
|
100
107
|
aria-invalid={hasError === true ? true : undefined}
|
|
101
|
-
placeholder={
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
108
|
+
placeholder={formatDatePlaceholder(resolvedLocale, {
|
|
109
|
+
year: t("kumiko.field.dateField.placeholderYear"),
|
|
110
|
+
month: t("kumiko.field.dateField.placeholderMonth"),
|
|
111
|
+
day: t("kumiko.field.dateField.placeholderDay"),
|
|
112
|
+
})}
|
|
105
113
|
onChange={(e) => {
|
|
106
114
|
setDraft(e.target.value);
|
|
107
115
|
commitTyped(e.target.value);
|
|
@@ -71,19 +71,23 @@ export function formatDateForInput(d: Temporal.PlainDate, locale: string): strin
|
|
|
71
71
|
|
|
72
72
|
type DateSlot = "y" | "m" | "d";
|
|
73
73
|
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
// millis
|
|
74
|
+
// Shared by localeDateOrder and formatDatePlaceholder — both need the
|
|
75
|
+
// locale's numeric formatToParts breakdown of the same reference date.
|
|
76
|
+
// epoch-millis input instead of a Date object (guard-compliant); timeZone:
|
|
77
77
|
// "UTC" keeps the reference from shifting to the 1st depending on the
|
|
78
78
|
// browser's TZ.
|
|
79
|
-
function
|
|
79
|
+
function localeDateParts(locale: string): Intl.DateTimeFormatPart[] {
|
|
80
80
|
const refEpochMillis = activeTemporal()
|
|
81
81
|
.PlainDate.from({ year: 2026, month: 1, day: 2 })
|
|
82
82
|
.toZonedDateTime("UTC").epochMilliseconds;
|
|
83
|
+
return new Intl.DateTimeFormat(locale, { timeZone: "UTC" }).formatToParts(refEpochMillis);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Field order of the numeric locale format. de → [d,m,y], en-US →
|
|
87
|
+
// [m,d,y], ISO-like locales → [y,m,d].
|
|
88
|
+
function localeDateOrder(locale: string): readonly DateSlot[] {
|
|
83
89
|
const order: DateSlot[] = [];
|
|
84
|
-
for (const part of
|
|
85
|
-
refEpochMillis,
|
|
86
|
-
)) {
|
|
90
|
+
for (const part of localeDateParts(locale)) {
|
|
87
91
|
if (part.type === "year") order.push("y");
|
|
88
92
|
else if (part.type === "month") order.push("m");
|
|
89
93
|
else if (part.type === "day") order.push("d");
|
|
@@ -91,6 +95,25 @@ function localeDateOrder(locale: string): readonly DateSlot[] {
|
|
|
91
95
|
return order;
|
|
92
96
|
}
|
|
93
97
|
|
|
98
|
+
// Locale-shaped placeholder pattern, e.g. de "TT.MM.JJJJ", en-US
|
|
99
|
+
// "MM/DD/YYYY", en-GB "DD/MM/YYYY". Slot order and separator both come
|
|
100
|
+
// from formatToParts — nothing hardcoded per locale. `letters` is one
|
|
101
|
+
// character per slot (from i18n); repeated to the slot's digit count
|
|
102
|
+
// (day/month 2, year 4).
|
|
103
|
+
export function formatDatePlaceholder(
|
|
104
|
+
locale: string,
|
|
105
|
+
letters: { readonly year: string; readonly month: string; readonly day: string },
|
|
106
|
+
): string {
|
|
107
|
+
return localeDateParts(locale)
|
|
108
|
+
.map((part) => {
|
|
109
|
+
if (part.type === "year") return letters.year.repeat(4);
|
|
110
|
+
if (part.type === "month") return letters.month.repeat(2);
|
|
111
|
+
if (part.type === "day") return letters.day.repeat(2);
|
|
112
|
+
return part.value;
|
|
113
|
+
})
|
|
114
|
+
.join("");
|
|
115
|
+
}
|
|
116
|
+
|
|
94
117
|
// Typed input → PlainDate. Accepts ISO (yyyy-mm-dd) directly, plus three
|
|
95
118
|
// numeric tokens in locale order with any separator (".", "/", "-", " ").
|
|
96
119
|
// Two-digit years → 2000s. Partial/invalid input → undefined (caller
|
|
@@ -47,12 +47,13 @@ const FOCUSABLE_SELECTOR = "input:not([type=hidden]), button, [tabindex]";
|
|
|
47
47
|
|
|
48
48
|
function columnWidthClass(type: EmbeddedListCellType): string {
|
|
49
49
|
switch (type) {
|
|
50
|
-
case "money":
|
|
51
50
|
case "number":
|
|
52
51
|
case "decimal":
|
|
53
52
|
case "date":
|
|
54
53
|
return "w-36";
|
|
55
|
-
// Wider than a bare
|
|
54
|
+
// Wider than a bare number: MoneyInput appends stepper buttons, timestamp
|
|
55
|
+
// carries a date field plus a time input.
|
|
56
|
+
case "money":
|
|
56
57
|
case "timestamp":
|
|
57
58
|
return "w-44";
|
|
58
59
|
case "boolean":
|
|
@@ -473,7 +474,8 @@ export function EmbeddedListInput({
|
|
|
473
474
|
{!isMobile && (
|
|
474
475
|
<div data-testid={testIdFor("desktop")} className="hidden md:block">
|
|
475
476
|
<div className="overflow-hidden rounded-lg border bg-card">
|
|
476
|
-
<
|
|
477
|
+
{/* w-full on Table's <table> would shrink columns below columnWidthClass; min-w-max keeps declared widths and lets the wrapper scroll instead */}
|
|
478
|
+
<Table className="min-w-max">
|
|
477
479
|
<TableHeader className="bg-muted">
|
|
478
480
|
<TableRow className="hover:bg-transparent">
|
|
479
481
|
{columns.map((column) => (
|
package/src/primitives/index.tsx
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
type HeadingProps,
|
|
32
32
|
type InputProps,
|
|
33
33
|
type LinkProps,
|
|
34
|
+
type ProgressProps,
|
|
34
35
|
type SectionProps,
|
|
35
36
|
type TextProps,
|
|
36
37
|
useColumnRenderer,
|
|
@@ -80,6 +81,7 @@ import { Input as UiInput } from "../ui/input";
|
|
|
80
81
|
import { Label as UiLabel } from "../ui/label";
|
|
81
82
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table";
|
|
82
83
|
import { Textarea } from "../ui/textarea";
|
|
84
|
+
import { ProgressBar } from "../widgets/progress-bar";
|
|
83
85
|
import { ComboboxInput } from "./combobox";
|
|
84
86
|
import { DateInput } from "./date-input";
|
|
85
87
|
import { DefaultDialog } from "./dialog";
|
|
@@ -623,6 +625,9 @@ function DefaultDataTable({
|
|
|
623
625
|
onFilterChange,
|
|
624
626
|
onFilterReset,
|
|
625
627
|
testId,
|
|
628
|
+
onCellChange,
|
|
629
|
+
getRowTestId,
|
|
630
|
+
getCellTestId,
|
|
626
631
|
}: DataTableProps): ReactNode {
|
|
627
632
|
// Toolbar-Wrapper: gemeinsamer Container für Toolbar+Tabelle damit
|
|
628
633
|
// beide visuell zusammengehören. Toolbar ist NICHT sticky — Lists
|
|
@@ -643,7 +648,18 @@ function DefaultDataTable({
|
|
|
643
648
|
// Page-Background (z.B. Cream) matchen Listen sonst nicht die Cards.
|
|
644
649
|
<div className="overflow-hidden rounded-lg border bg-card">
|
|
645
650
|
<Table data-testid={testId}>
|
|
646
|
-
{tableInner(
|
|
651
|
+
{tableInner(
|
|
652
|
+
columns,
|
|
653
|
+
rows,
|
|
654
|
+
onRowClick,
|
|
655
|
+
sort,
|
|
656
|
+
onSortChange,
|
|
657
|
+
rowActions,
|
|
658
|
+
rowActionMode,
|
|
659
|
+
onCellChange,
|
|
660
|
+
getRowTestId,
|
|
661
|
+
getCellTestId,
|
|
662
|
+
)}
|
|
647
663
|
</Table>
|
|
648
664
|
</div>
|
|
649
665
|
);
|
|
@@ -742,6 +758,9 @@ function tableInner(
|
|
|
742
758
|
onSortChange?: DataTableProps["onSortChange"],
|
|
743
759
|
rowActions?: DataTableProps["rowActions"],
|
|
744
760
|
rowActionMode?: DataTableProps["rowActionMode"],
|
|
761
|
+
onCellChange?: DataTableProps["onCellChange"],
|
|
762
|
+
getRowTestId?: DataTableProps["getRowTestId"],
|
|
763
|
+
getCellTestId?: DataTableProps["getCellTestId"],
|
|
745
764
|
): ReactNode {
|
|
746
765
|
const hasActions = rowActions !== undefined && rowActions.length > 0;
|
|
747
766
|
return (
|
|
@@ -754,6 +773,7 @@ function tableInner(
|
|
|
754
773
|
field={col.field}
|
|
755
774
|
label={col.label}
|
|
756
775
|
sortable={col.sortable === true}
|
|
776
|
+
highlighted={col.highlighted === true}
|
|
757
777
|
{...(sort !== undefined && sort !== null && { sort })}
|
|
758
778
|
{...(onSortChange !== undefined && { onSortChange })}
|
|
759
779
|
/>
|
|
@@ -775,20 +795,21 @@ function tableInner(
|
|
|
775
795
|
{rows.map((row) => (
|
|
776
796
|
<TableRow
|
|
777
797
|
key={row.id}
|
|
778
|
-
data-testid={`row-${row.id}`}
|
|
798
|
+
data-testid={getRowTestId?.(row) ?? `row-${row.id}`}
|
|
779
799
|
onClick={onRowClick !== undefined ? () => onRowClick(row) : undefined}
|
|
780
800
|
className={cn(onRowClick !== undefined && "cursor-pointer")}
|
|
781
801
|
>
|
|
782
802
|
{columns.map((col) => (
|
|
783
803
|
<TableCell
|
|
784
804
|
key={col.field}
|
|
785
|
-
data-testid={`cell-${row.id}-${col.field}`}
|
|
805
|
+
data-testid={getCellTestId?.(row, col.field) ?? `cell-${row.id}-${col.field}`}
|
|
806
|
+
data-highlighted={col.highlighted === true ? "true" : undefined}
|
|
786
807
|
// Cells truncaten lange Werte mit ellipsis statt umzu-
|
|
787
808
|
// brechen — Lists bleiben einzeilig + scannbar (Linear-
|
|
788
809
|
// Pattern). max-w-xs gibt eine vernünftige Default-
|
|
789
810
|
// Obergrenze; der Table-Container scrollt horizontal
|
|
790
811
|
// falls die Summe der Spalten zu breit wird.
|
|
791
|
-
className="max-w-xs truncate"
|
|
812
|
+
className={cn("max-w-xs truncate", col.highlighted === true && "bg-accent/40")}
|
|
792
813
|
title={cellTitle(row.values[col.field])}
|
|
793
814
|
>
|
|
794
815
|
<DataTableCell
|
|
@@ -798,6 +819,9 @@ function tableInner(
|
|
|
798
819
|
type={col.type}
|
|
799
820
|
renderer={col.renderer}
|
|
800
821
|
{...(col.optionLabels !== undefined && { optionLabels: col.optionLabels })}
|
|
822
|
+
{...(onCellChange !== undefined && {
|
|
823
|
+
onChange: (value: unknown) => onCellChange(row.id, col.field, value),
|
|
824
|
+
})}
|
|
801
825
|
/>
|
|
802
826
|
</TableCell>
|
|
803
827
|
))}
|
|
@@ -1280,12 +1304,14 @@ function SortableHeader({
|
|
|
1280
1304
|
field,
|
|
1281
1305
|
label,
|
|
1282
1306
|
sortable,
|
|
1307
|
+
highlighted,
|
|
1283
1308
|
sort,
|
|
1284
1309
|
onSortChange,
|
|
1285
1310
|
}: {
|
|
1286
1311
|
readonly field: string;
|
|
1287
1312
|
readonly label: string;
|
|
1288
1313
|
readonly sortable: boolean;
|
|
1314
|
+
readonly highlighted?: boolean;
|
|
1289
1315
|
readonly sort?: DataTableSort;
|
|
1290
1316
|
readonly onSortChange?: (next: DataTableSort | null) => void;
|
|
1291
1317
|
}): ReactNode {
|
|
@@ -1298,7 +1324,8 @@ function SortableHeader({
|
|
|
1298
1324
|
<TableHead
|
|
1299
1325
|
data-testid={`column-${field}`}
|
|
1300
1326
|
data-sortable={sortable === true ? true : undefined}
|
|
1301
|
-
|
|
1327
|
+
data-highlighted={highlighted === true ? "true" : undefined}
|
|
1328
|
+
className={cn("px-4 text-muted-foreground", highlighted === true && "bg-accent/40")}
|
|
1302
1329
|
>
|
|
1303
1330
|
{label}
|
|
1304
1331
|
</TableHead>
|
|
@@ -1312,8 +1339,9 @@ function SortableHeader({
|
|
|
1312
1339
|
<TableHead
|
|
1313
1340
|
data-testid={`column-${field}`}
|
|
1314
1341
|
data-sortable="true"
|
|
1342
|
+
data-highlighted={highlighted === true ? "true" : undefined}
|
|
1315
1343
|
aria-sort={ariaSort}
|
|
1316
|
-
className="px-4 text-muted-foreground"
|
|
1344
|
+
className={cn("px-4 text-muted-foreground", highlighted === true && "bg-accent/40")}
|
|
1317
1345
|
>
|
|
1318
1346
|
<button
|
|
1319
1347
|
type="button"
|
|
@@ -1443,6 +1471,7 @@ type DataTableCellProps = {
|
|
|
1443
1471
|
readonly type: string;
|
|
1444
1472
|
readonly renderer?: unknown;
|
|
1445
1473
|
readonly optionLabels?: Readonly<Record<string, string>>;
|
|
1474
|
+
readonly onChange?: (value: unknown) => void;
|
|
1446
1475
|
};
|
|
1447
1476
|
|
|
1448
1477
|
// Cell-Renderer als Component (statt reiner Funktion) damit der
|
|
@@ -1461,6 +1490,7 @@ function DataTableCell({
|
|
|
1461
1490
|
type,
|
|
1462
1491
|
renderer,
|
|
1463
1492
|
optionLabels,
|
|
1493
|
+
onChange,
|
|
1464
1494
|
}: DataTableCellProps): ReactNode {
|
|
1465
1495
|
const componentRef = isComponentRendererRef(renderer);
|
|
1466
1496
|
const ResolvedComponent = useColumnRenderer(componentRef?.name);
|
|
@@ -1473,7 +1503,14 @@ function DataTableCell({
|
|
|
1473
1503
|
}
|
|
1474
1504
|
if (componentRef !== undefined) {
|
|
1475
1505
|
if (ResolvedComponent !== undefined) {
|
|
1476
|
-
return
|
|
1506
|
+
return (
|
|
1507
|
+
<ResolvedComponent
|
|
1508
|
+
value={value}
|
|
1509
|
+
row={row}
|
|
1510
|
+
column={{ field }}
|
|
1511
|
+
{...(onChange !== undefined && { onChange })}
|
|
1512
|
+
/>
|
|
1513
|
+
);
|
|
1477
1514
|
}
|
|
1478
1515
|
// Renderer im Schema referenziert, aber client-side kein Map-Eintrag —
|
|
1479
1516
|
// typischer Fall: clientFeatures.columnRenderers vergessen oder
|
|
@@ -1831,6 +1868,10 @@ function DefaultLink({
|
|
|
1831
1868
|
);
|
|
1832
1869
|
}
|
|
1833
1870
|
|
|
1871
|
+
function DefaultProgress({ value, testId }: ProgressProps): ReactNode {
|
|
1872
|
+
return <ProgressBar value={value} testId={testId} />;
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1834
1875
|
function DefaultHeading({ variant = "page", children, testId }: HeadingProps): ReactNode {
|
|
1835
1876
|
// Page-Heading = h1, sehr selten in einer App (max 1 pro Screen).
|
|
1836
1877
|
// Section-Heading = h2 mit uppercase + muted-foreground — derselbe
|
|
@@ -1924,4 +1965,5 @@ export const defaultPrimitives: CorePrimitives = {
|
|
|
1924
1965
|
ConfigSourceBadge: DefaultConfigSourceBadge,
|
|
1925
1966
|
ConfigCascadeView: DefaultConfigCascadeView,
|
|
1926
1967
|
Link: DefaultLink,
|
|
1968
|
+
Progress: DefaultProgress,
|
|
1927
1969
|
};
|