@cosmicdrift/kumiko-renderer-web 0.123.2 → 0.124.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-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.124.0",
|
|
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.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
19
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.124.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.124.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.124.0",
|
|
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",
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { defaultPrimitives } from "../primitives";
|
|
4
|
+
import { render, screen } from "./test-utils";
|
|
5
|
+
|
|
6
|
+
const { Lightbox } = defaultPrimitives;
|
|
7
|
+
|
|
8
|
+
describe("Lightbox", () => {
|
|
9
|
+
test("open=true renders image with src and alt", () => {
|
|
10
|
+
render(
|
|
11
|
+
<Lightbox
|
|
12
|
+
open
|
|
13
|
+
onOpenChange={() => undefined}
|
|
14
|
+
src="/demo.png"
|
|
15
|
+
alt="Product screenshot"
|
|
16
|
+
testId="lb"
|
|
17
|
+
/>,
|
|
18
|
+
);
|
|
19
|
+
const img = screen.getByRole("img", { name: "Product screenshot" });
|
|
20
|
+
expect(img.getAttribute("src")).toBe("/demo.png");
|
|
21
|
+
expect(screen.getByTestId("lb")).toBeTruthy();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("open=false renders nothing", () => {
|
|
25
|
+
render(
|
|
26
|
+
<Lightbox
|
|
27
|
+
open={false}
|
|
28
|
+
onOpenChange={() => undefined}
|
|
29
|
+
src="/demo.png"
|
|
30
|
+
alt="Hidden"
|
|
31
|
+
testId="lb-hidden"
|
|
32
|
+
/>,
|
|
33
|
+
);
|
|
34
|
+
expect(screen.queryByTestId("lb-hidden")).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("close button calls onOpenChange(false)", async () => {
|
|
38
|
+
const user = userEvent.setup();
|
|
39
|
+
const onOpenChange = mock();
|
|
40
|
+
render(
|
|
41
|
+
<Lightbox open onOpenChange={onOpenChange} src="/demo.png" alt="Preview" testId="lb-close" />,
|
|
42
|
+
);
|
|
43
|
+
await user.click(screen.getByLabelText("Close"));
|
|
44
|
+
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -1,18 +1,13 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// secondary. Async-onConfirm wird über loading-State gerendert
|
|
4
|
-
// (Spinner im Confirm-Button bis der Promise resolved).
|
|
5
|
-
//
|
|
6
|
-
// Ausgelagert von primitives/index.tsx weil das Radix-Setup mehrere
|
|
7
|
-
// Dependencies und Sub-Exports zieht — hält das Primitives-Hauptfile
|
|
8
|
-
// schlank.
|
|
1
|
+
// Confirm dialog built on ModalShell. Cancel is always secondary; async
|
|
2
|
+
// onConfirm shows a spinner on the confirm button until the promise settles.
|
|
9
3
|
|
|
10
4
|
import type { DialogProps } from "@cosmicdrift/kumiko-renderer";
|
|
11
5
|
import { useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
12
6
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
|
13
|
-
import { Loader2
|
|
7
|
+
import { Loader2 } from "lucide-react";
|
|
14
8
|
import { type ReactNode, useState } from "react";
|
|
15
9
|
import { cn } from "../lib/cn";
|
|
10
|
+
import { ModalShell } from "./modal-shell";
|
|
16
11
|
|
|
17
12
|
export function DefaultDialog({
|
|
18
13
|
open,
|
|
@@ -38,9 +33,6 @@ export function DefaultDialog({
|
|
|
38
33
|
await onConfirm();
|
|
39
34
|
} finally {
|
|
40
35
|
setLoading(false);
|
|
41
|
-
// Auch bei rejected onConfirm schließen — ein offen hängendes Modal
|
|
42
|
-
// ohne Botschaft wirkt eingefroren; der Fehler selbst wird vom
|
|
43
|
-
// onConfirm-Pfad surfaced (Row-Actions: Toast).
|
|
44
36
|
onOpenChange(false);
|
|
45
37
|
}
|
|
46
38
|
}
|
|
@@ -51,72 +43,51 @@ export function DefaultDialog({
|
|
|
51
43
|
: "bg-primary text-primary-foreground shadow hover:bg-primary/90";
|
|
52
44
|
|
|
53
45
|
return (
|
|
54
|
-
<
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
46
|
+
<ModalShell
|
|
47
|
+
open={open}
|
|
48
|
+
onOpenChange={onOpenChange}
|
|
49
|
+
testId={testId}
|
|
50
|
+
closeLabel={t("kumiko.dialog.close")}
|
|
51
|
+
noAriaDescription={description === undefined}
|
|
52
|
+
contentClassName={cn("grid w-full max-w-lg gap-4 border bg-card p-6 shadow-lg rounded-lg")}
|
|
53
|
+
>
|
|
54
|
+
<div className="flex flex-col gap-1.5">
|
|
55
|
+
<DialogPrimitive.Title className="text-lg font-semibold tracking-tight">
|
|
56
|
+
{title}
|
|
57
|
+
</DialogPrimitive.Title>
|
|
58
|
+
{description !== undefined && (
|
|
59
|
+
<DialogPrimitive.Description className="text-sm text-muted-foreground">
|
|
60
|
+
{description}
|
|
61
|
+
</DialogPrimitive.Description>
|
|
62
|
+
)}
|
|
63
|
+
</div>
|
|
64
|
+
{children !== undefined && <div>{children}</div>}
|
|
65
|
+
<div className="flex items-center justify-end gap-2">
|
|
66
|
+
<DialogPrimitive.Close asChild>
|
|
67
|
+
<button
|
|
68
|
+
type="button"
|
|
69
|
+
disabled={loading}
|
|
70
|
+
data-testid={testId !== undefined ? `${testId}-cancel` : undefined}
|
|
71
|
+
className="inline-flex h-9 items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium shadow-sm hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50"
|
|
72
|
+
>
|
|
73
|
+
{effectiveCancelLabel}
|
|
74
|
+
</button>
|
|
75
|
+
</DialogPrimitive.Close>
|
|
76
|
+
<button
|
|
77
|
+
type="button"
|
|
78
|
+
onClick={() => void handleConfirm()}
|
|
79
|
+
disabled={loading}
|
|
80
|
+
data-testid={testId !== undefined ? `${testId}-confirm` : undefined}
|
|
64
81
|
className={cn(
|
|
65
|
-
"
|
|
66
|
-
"
|
|
67
|
-
|
|
68
|
-
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
|
69
|
-
"rounded-lg",
|
|
82
|
+
"inline-flex h-9 items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-medium",
|
|
83
|
+
"disabled:pointer-events-none disabled:opacity-50",
|
|
84
|
+
confirmClass,
|
|
70
85
|
)}
|
|
71
86
|
>
|
|
72
|
-
<
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
<DialogPrimitive.Description className="text-sm text-muted-foreground">
|
|
78
|
-
{description}
|
|
79
|
-
</DialogPrimitive.Description>
|
|
80
|
-
)}
|
|
81
|
-
</div>
|
|
82
|
-
{children !== undefined && <div>{children}</div>}
|
|
83
|
-
<div className="flex items-center justify-end gap-2">
|
|
84
|
-
<DialogPrimitive.Close asChild>
|
|
85
|
-
<button
|
|
86
|
-
type="button"
|
|
87
|
-
disabled={loading}
|
|
88
|
-
data-testid={testId !== undefined ? `${testId}-cancel` : undefined}
|
|
89
|
-
className="inline-flex h-9 items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium shadow-sm hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50"
|
|
90
|
-
>
|
|
91
|
-
{effectiveCancelLabel}
|
|
92
|
-
</button>
|
|
93
|
-
</DialogPrimitive.Close>
|
|
94
|
-
<button
|
|
95
|
-
type="button"
|
|
96
|
-
onClick={() => void handleConfirm()}
|
|
97
|
-
disabled={loading}
|
|
98
|
-
data-testid={testId !== undefined ? `${testId}-confirm` : undefined}
|
|
99
|
-
className={cn(
|
|
100
|
-
"inline-flex h-9 items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-medium",
|
|
101
|
-
"disabled:pointer-events-none disabled:opacity-50",
|
|
102
|
-
confirmClass,
|
|
103
|
-
)}
|
|
104
|
-
>
|
|
105
|
-
{loading && <Loader2 className="size-4 animate-spin" aria-hidden="true" />}
|
|
106
|
-
{effectiveConfirmLabel}
|
|
107
|
-
</button>
|
|
108
|
-
</div>
|
|
109
|
-
<DialogPrimitive.Close asChild>
|
|
110
|
-
<button
|
|
111
|
-
type="button"
|
|
112
|
-
aria-label={t("kumiko.dialog.close")}
|
|
113
|
-
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
|
|
114
|
-
>
|
|
115
|
-
<X className="size-4" />
|
|
116
|
-
</button>
|
|
117
|
-
</DialogPrimitive.Close>
|
|
118
|
-
</DialogPrimitive.Content>
|
|
119
|
-
</DialogPrimitive.Portal>
|
|
120
|
-
</DialogPrimitive.Root>
|
|
87
|
+
{loading && <Loader2 className="size-4 animate-spin" aria-hidden="true" />}
|
|
88
|
+
{effectiveConfirmLabel}
|
|
89
|
+
</button>
|
|
90
|
+
</div>
|
|
91
|
+
</ModalShell>
|
|
121
92
|
);
|
|
122
93
|
}
|
package/src/primitives/index.tsx
CHANGED
|
@@ -76,6 +76,7 @@ import {
|
|
|
76
76
|
DropdownMenuTrigger,
|
|
77
77
|
} from "./dropdown-menu";
|
|
78
78
|
import { FileUploadInput } from "./file-upload";
|
|
79
|
+
import { DefaultLightbox } from "./lightbox";
|
|
79
80
|
import { LocatedTimestampInput } from "./located-timestamp-input";
|
|
80
81
|
import { MoneyInput } from "./money-input";
|
|
81
82
|
import { TimestampInput } from "./timestamp-input";
|
|
@@ -1665,6 +1666,7 @@ export const defaultPrimitives: CorePrimitives = {
|
|
|
1665
1666
|
Text: DefaultText,
|
|
1666
1667
|
Heading: DefaultHeading,
|
|
1667
1668
|
Dialog: DefaultDialog,
|
|
1669
|
+
Lightbox: DefaultLightbox,
|
|
1668
1670
|
ConfigSourceBadge: DefaultConfigSourceBadge,
|
|
1669
1671
|
ConfigCascadeView: DefaultConfigCascadeView,
|
|
1670
1672
|
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Full-size image overlay — ModalShell without confirm/cancel actions.
|
|
2
|
+
|
|
3
|
+
import type { LightboxProps } from "@cosmicdrift/kumiko-renderer";
|
|
4
|
+
import { useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
5
|
+
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
|
6
|
+
import type { ReactNode } from "react";
|
|
7
|
+
import { cn } from "../lib/cn";
|
|
8
|
+
import { ModalShell } from "./modal-shell";
|
|
9
|
+
|
|
10
|
+
export function DefaultLightbox({
|
|
11
|
+
open,
|
|
12
|
+
onOpenChange,
|
|
13
|
+
src,
|
|
14
|
+
alt,
|
|
15
|
+
testId,
|
|
16
|
+
}: LightboxProps): ReactNode {
|
|
17
|
+
const t = useTranslation();
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<ModalShell
|
|
21
|
+
open={open}
|
|
22
|
+
onOpenChange={onOpenChange}
|
|
23
|
+
testId={testId}
|
|
24
|
+
closeLabel={t("kumiko.dialog.close")}
|
|
25
|
+
noAriaDescription
|
|
26
|
+
contentClassName={cn(
|
|
27
|
+
"border-0 bg-transparent p-0 shadow-none",
|
|
28
|
+
"max-w-[95vw] max-h-[90vh] w-auto",
|
|
29
|
+
)}
|
|
30
|
+
>
|
|
31
|
+
<DialogPrimitive.Title className="sr-only">{alt}</DialogPrimitive.Title>
|
|
32
|
+
<img
|
|
33
|
+
src={src}
|
|
34
|
+
alt={alt}
|
|
35
|
+
className="block max-h-[85vh] max-w-[90vw] w-auto rounded-lg border border-border bg-card shadow-lg"
|
|
36
|
+
/>
|
|
37
|
+
</ModalShell>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Shared Radix dialog chrome for confirm Dialog + image Lightbox.
|
|
2
|
+
// Not exported from the package — only an internal building block.
|
|
3
|
+
|
|
4
|
+
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
|
5
|
+
import { X } from "lucide-react";
|
|
6
|
+
import type { ReactNode } from "react";
|
|
7
|
+
import { cn } from "../lib/cn";
|
|
8
|
+
|
|
9
|
+
export type ModalShellProps = {
|
|
10
|
+
readonly open: boolean;
|
|
11
|
+
readonly onOpenChange: (open: boolean) => void;
|
|
12
|
+
readonly children: ReactNode;
|
|
13
|
+
readonly testId?: string;
|
|
14
|
+
readonly contentClassName?: string;
|
|
15
|
+
/** Radix wants aria-describedby={undefined} when there is no Description. */
|
|
16
|
+
readonly noAriaDescription?: boolean;
|
|
17
|
+
readonly closeLabel?: string;
|
|
18
|
+
readonly showCloseButton?: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function ModalShell({
|
|
22
|
+
open,
|
|
23
|
+
onOpenChange,
|
|
24
|
+
children,
|
|
25
|
+
testId,
|
|
26
|
+
contentClassName,
|
|
27
|
+
noAriaDescription,
|
|
28
|
+
closeLabel,
|
|
29
|
+
showCloseButton = true,
|
|
30
|
+
}: ModalShellProps): ReactNode {
|
|
31
|
+
return (
|
|
32
|
+
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
|
|
33
|
+
<DialogPrimitive.Portal>
|
|
34
|
+
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
|
|
35
|
+
<DialogPrimitive.Content
|
|
36
|
+
data-testid={testId}
|
|
37
|
+
{...(noAriaDescription && { "aria-describedby": undefined })}
|
|
38
|
+
className={cn(
|
|
39
|
+
"fixed left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%]",
|
|
40
|
+
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
|
41
|
+
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
|
42
|
+
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
|
43
|
+
contentClassName,
|
|
44
|
+
)}
|
|
45
|
+
>
|
|
46
|
+
{children}
|
|
47
|
+
{showCloseButton && closeLabel !== undefined && (
|
|
48
|
+
<DialogPrimitive.Close asChild>
|
|
49
|
+
<button
|
|
50
|
+
type="button"
|
|
51
|
+
aria-label={closeLabel}
|
|
52
|
+
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
|
|
53
|
+
>
|
|
54
|
+
<X className="size-4" />
|
|
55
|
+
</button>
|
|
56
|
+
</DialogPrimitive.Close>
|
|
57
|
+
)}
|
|
58
|
+
</DialogPrimitive.Content>
|
|
59
|
+
</DialogPrimitive.Portal>
|
|
60
|
+
</DialogPrimitive.Root>
|
|
61
|
+
);
|
|
62
|
+
}
|