@cosmicdrift/kumiko-renderer-web 0.187.0 → 0.189.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__/entity-edit-redirect.test.tsx +166 -0
- package/src/__tests__/kumiko-screen.test.tsx +144 -0
- package/src/__tests__/primitives.test.tsx +23 -0
- package/src/__tests__/render-edit.test.tsx +1505 -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__/embedded-list-input.test.tsx +14 -1
- package/src/primitives/embedded-list-input.tsx +3 -2
- package/src/primitives/index.tsx +35 -1
- package/src/primitives/located-timestamp-input.tsx +1 -21
- package/src/primitives/money-input.tsx +5 -9
- package/src/primitives/tz-input.tsx +43 -0
- package/src/primitives/tz-options.ts +20 -0
|
@@ -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";
|
|
@@ -618,6 +618,19 @@ describe("EmbeddedListInput — desktop table width (solon#107)", () => {
|
|
|
618
618
|
const headers = desktop.querySelectorAll("th");
|
|
619
619
|
expect(headers[0]?.className).toContain("min-w-[10rem]");
|
|
620
620
|
expect(headers[1]?.className).toContain("w-36");
|
|
621
|
-
expect(headers[2]?.className).toContain("w-
|
|
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");
|
|
622
635
|
});
|
|
623
636
|
});
|
|
@@ -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":
|
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";
|
|
@@ -98,6 +100,7 @@ import { DefaultModal } from "./modal";
|
|
|
98
100
|
import { formatMoney, MoneyInput } from "./money-input";
|
|
99
101
|
import { TimestampInput } from "./timestamp-input";
|
|
100
102
|
import { useToast } from "./toast";
|
|
103
|
+
import { TzInput } from "./tz-input";
|
|
101
104
|
|
|
102
105
|
// ---- Card-Chrome (eine Definition für Form/Section/Card) ----
|
|
103
106
|
|
|
@@ -553,6 +556,18 @@ function DefaultInput(props: InputProps): ReactNode {
|
|
|
553
556
|
className="resize-y"
|
|
554
557
|
/>
|
|
555
558
|
);
|
|
559
|
+
case "tz":
|
|
560
|
+
return (
|
|
561
|
+
<TzInput
|
|
562
|
+
id={props.id}
|
|
563
|
+
name={props.name}
|
|
564
|
+
value={props.value}
|
|
565
|
+
onChange={props.onChange}
|
|
566
|
+
{...(props.disabled !== undefined && { disabled: props.disabled })}
|
|
567
|
+
{...(props.required !== undefined && { required: props.required })}
|
|
568
|
+
{...(props.hasError !== undefined && { hasError: props.hasError })}
|
|
569
|
+
/>
|
|
570
|
+
);
|
|
556
571
|
}
|
|
557
572
|
}
|
|
558
573
|
|
|
@@ -1556,6 +1571,7 @@ function DefaultForm({
|
|
|
1556
1571
|
actions,
|
|
1557
1572
|
testId,
|
|
1558
1573
|
width,
|
|
1574
|
+
stickyActions,
|
|
1559
1575
|
}: FormProps): ReactNode {
|
|
1560
1576
|
// Eingebettet (AuthCard etc.): nacktes <form>, gestapelte Felder mit gap —
|
|
1561
1577
|
// der Container trägt Card/Titel selbst, sonst Card-in-Card.
|
|
@@ -1624,6 +1640,9 @@ function DefaultForm({
|
|
|
1624
1640
|
"[&>section:not(:first-child)]:border-t",
|
|
1625
1641
|
"[&>:not(section)]:px-6 [&>:not(section)]:py-3",
|
|
1626
1642
|
"[&>:not(section):first-child]:pt-6 [&>:not(section):last-child]:pb-6",
|
|
1643
|
+
// ponytail: fixed footer height is a guess (button row + safe-area) —
|
|
1644
|
+
// widen if a wizard step's last field ever renders visibly clipped.
|
|
1645
|
+
stickyActions === true && "max-sm:pb-24",
|
|
1627
1646
|
)}
|
|
1628
1647
|
>
|
|
1629
1648
|
<InsideFormContext.Provider value={true}>{children}</InsideFormContext.Provider>
|
|
@@ -1631,7 +1650,17 @@ function DefaultForm({
|
|
|
1631
1650
|
{actions !== undefined && (
|
|
1632
1651
|
<div
|
|
1633
1652
|
data-testid={testId !== undefined ? `${testId}-actions` : undefined}
|
|
1634
|
-
className={cn(
|
|
1653
|
+
className={cn(
|
|
1654
|
+
cardFooter,
|
|
1655
|
+
cardFooterBorder,
|
|
1656
|
+
// Below sm (640px): pin to the viewport bottom instead of normal
|
|
1657
|
+
// flow, so a virtual keyboard shrinking the viewport can't push
|
|
1658
|
+
// this out of reach (fw#1918). `fixed` escapes the card's
|
|
1659
|
+
// `overflow-hidden` (only transform/filter/contain ancestors trap
|
|
1660
|
+
// it, confirmed against AppLayout/SidebarInset — neither sets those).
|
|
1661
|
+
stickyActions === true &&
|
|
1662
|
+
"max-sm:fixed max-sm:inset-x-0 max-sm:bottom-0 max-sm:z-20 max-sm:bg-background max-sm:shadow-[0_-4px_12px_-4px_rgb(0_0_0_/_0.15)] max-sm:pb-[max(1rem,env(safe-area-inset-bottom))]",
|
|
1663
|
+
)}
|
|
1635
1664
|
>
|
|
1636
1665
|
{actions}
|
|
1637
1666
|
</div>
|
|
@@ -1866,6 +1895,10 @@ function DefaultLink({
|
|
|
1866
1895
|
);
|
|
1867
1896
|
}
|
|
1868
1897
|
|
|
1898
|
+
function DefaultProgress({ value, testId }: ProgressProps): ReactNode {
|
|
1899
|
+
return <ProgressBar value={value} testId={testId} />;
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1869
1902
|
function DefaultHeading({ variant = "page", children, testId }: HeadingProps): ReactNode {
|
|
1870
1903
|
// Page-Heading = h1, sehr selten in einer App (max 1 pro Screen).
|
|
1871
1904
|
// Section-Heading = h2 mit uppercase + muted-foreground — derselbe
|
|
@@ -1959,4 +1992,5 @@ export const defaultPrimitives: CorePrimitives = {
|
|
|
1959
1992
|
ConfigSourceBadge: DefaultConfigSourceBadge,
|
|
1960
1993
|
ConfigCascadeView: DefaultConfigCascadeView,
|
|
1961
1994
|
Link: DefaultLink,
|
|
1995
|
+
Progress: DefaultProgress,
|
|
1962
1996
|
};
|
|
@@ -9,27 +9,7 @@ import { useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
|
9
9
|
import type { ReactNode } from "react";
|
|
10
10
|
import { ComboboxInput } from "./combobox";
|
|
11
11
|
import { TimestampInput } from "./timestamp-input";
|
|
12
|
-
|
|
13
|
-
// Kuratierte Notliste falls die Runtime Intl.supportedValuesOf nicht kennt
|
|
14
|
-
// (vor ES2022). Reicht für die häufigsten Zonen; moderne Browser + Bun liefern
|
|
15
|
-
// die volle Liste.
|
|
16
|
-
const FALLBACK_ZONES: readonly string[] = [
|
|
17
|
-
"UTC",
|
|
18
|
-
"Europe/Berlin",
|
|
19
|
-
"Europe/London",
|
|
20
|
-
"Europe/Paris",
|
|
21
|
-
"Europe/Madrid",
|
|
22
|
-
"America/New_York",
|
|
23
|
-
"America/Los_Angeles",
|
|
24
|
-
"America/Sao_Paulo",
|
|
25
|
-
"Asia/Tokyo",
|
|
26
|
-
"Asia/Singapore",
|
|
27
|
-
"Australia/Sydney",
|
|
28
|
-
];
|
|
29
|
-
|
|
30
|
-
const TZ_OPTIONS: readonly { readonly value: string; readonly label: string }[] = (
|
|
31
|
-
typeof Intl.supportedValuesOf === "function" ? Intl.supportedValuesOf("timeZone") : FALLBACK_ZONES
|
|
32
|
-
).map((zone) => ({ value: zone, label: zone }));
|
|
12
|
+
import { TZ_OPTIONS } from "./tz-options";
|
|
33
13
|
|
|
34
14
|
export type LocatedTimestampValue = {
|
|
35
15
|
readonly at: string;
|
|
@@ -14,10 +14,15 @@
|
|
|
14
14
|
// Klick — also 100 cents bei EUR/USD, 1 yen bei JPY). User der nur
|
|
15
15
|
// Cent-genaue Steps will tippt halt im Focus-Modus.
|
|
16
16
|
|
|
17
|
+
import { currencyDecimals } from "@cosmicdrift/kumiko-headless";
|
|
17
18
|
import { Minus, Plus } from "lucide-react";
|
|
18
19
|
import { type ReactNode, useEffect, useRef, useState } from "react";
|
|
19
20
|
import { cn } from "../lib/cn";
|
|
20
21
|
|
|
22
|
+
// Re-exported for backward compat — callers used to import this from here
|
|
23
|
+
// before it moved to headless (shared with RenderField, kumiko-framework#1923).
|
|
24
|
+
export { currencyDecimals };
|
|
25
|
+
|
|
21
26
|
export type MoneyInputProps = {
|
|
22
27
|
readonly id: string;
|
|
23
28
|
readonly name: string;
|
|
@@ -165,15 +170,6 @@ export function MoneyInput({
|
|
|
165
170
|
);
|
|
166
171
|
}
|
|
167
172
|
|
|
168
|
-
// Currency-Decimal-Stellen — überdeckt die wichtigsten Welt-Währungen.
|
|
169
|
-
// Default 2 wenn Code unbekannt.
|
|
170
|
-
export function currencyDecimals(code: string): number {
|
|
171
|
-
if (code === "JPY" || code === "KRW" || code === "VND" || code === "ISK") return 0;
|
|
172
|
-
if (code === "BHD" || code === "JOD" || code === "KWD" || code === "OMR" || code === "TND")
|
|
173
|
-
return 3;
|
|
174
|
-
return 2;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
173
|
function guessLocale(): string {
|
|
178
174
|
if (typeof navigator !== "undefined" && navigator.language) return navigator.language;
|
|
179
175
|
return "en-US";
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// TzInput (kind:"tz") — standalone IANA-zone picker for `tz`-typed fields
|
|
2
|
+
// (#1925). Same searchable-combobox UX as the zone half of
|
|
3
|
+
// LocatedTimestampInput, shared TZ_OPTIONS source.
|
|
4
|
+
|
|
5
|
+
import { useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
6
|
+
import type { ReactNode } from "react";
|
|
7
|
+
import { ComboboxInput } from "./combobox";
|
|
8
|
+
import { TZ_OPTIONS } from "./tz-options";
|
|
9
|
+
|
|
10
|
+
export type TzInputProps = {
|
|
11
|
+
readonly id: string;
|
|
12
|
+
readonly name: string;
|
|
13
|
+
readonly value: string;
|
|
14
|
+
readonly onChange: (v: string | undefined) => void;
|
|
15
|
+
readonly disabled?: boolean;
|
|
16
|
+
readonly required?: boolean;
|
|
17
|
+
readonly hasError?: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function TzInput({
|
|
21
|
+
id,
|
|
22
|
+
name,
|
|
23
|
+
value,
|
|
24
|
+
onChange,
|
|
25
|
+
disabled,
|
|
26
|
+
required,
|
|
27
|
+
hasError,
|
|
28
|
+
}: TzInputProps): ReactNode {
|
|
29
|
+
const t = useTranslation();
|
|
30
|
+
return (
|
|
31
|
+
<ComboboxInput
|
|
32
|
+
id={id}
|
|
33
|
+
name={name}
|
|
34
|
+
options={TZ_OPTIONS}
|
|
35
|
+
value={value}
|
|
36
|
+
onChange={(v) => onChange(v === "" ? undefined : v)}
|
|
37
|
+
placeholder={t("kumiko.field.timezone")}
|
|
38
|
+
{...(disabled !== undefined && { disabled })}
|
|
39
|
+
{...(required !== undefined && { required })}
|
|
40
|
+
{...(hasError !== undefined && { hasError })}
|
|
41
|
+
/>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Curated fallback list for runtimes without Intl.supportedValuesOf (pre
|
|
2
|
+
// ES2022). Covers the most common zones; modern browsers + Bun return the
|
|
3
|
+
// full IANA list.
|
|
4
|
+
const FALLBACK_ZONES: readonly string[] = [
|
|
5
|
+
"UTC",
|
|
6
|
+
"Europe/Berlin",
|
|
7
|
+
"Europe/London",
|
|
8
|
+
"Europe/Paris",
|
|
9
|
+
"Europe/Madrid",
|
|
10
|
+
"America/New_York",
|
|
11
|
+
"America/Los_Angeles",
|
|
12
|
+
"America/Sao_Paulo",
|
|
13
|
+
"Asia/Tokyo",
|
|
14
|
+
"Asia/Singapore",
|
|
15
|
+
"Australia/Sydney",
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
export const TZ_OPTIONS: readonly { readonly value: string; readonly label: string }[] = (
|
|
19
|
+
typeof Intl.supportedValuesOf === "function" ? Intl.supportedValuesOf("timeZone") : FALLBACK_ZONES
|
|
20
|
+
).map((zone) => ({ value: zone, label: zone }));
|