@cosmicdrift/kumiko-renderer-web 0.123.3 → 0.125.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.123.3",
3
+ "version": "0.125.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.123.3",
20
- "@cosmicdrift/kumiko-headless": "0.123.3",
21
- "@cosmicdrift/kumiko-renderer": "0.123.3",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.125.0",
20
+ "@cosmicdrift/kumiko-headless": "0.125.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.125.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
- // shadcn-Style Dialog via @radix-ui/react-dialog. Confirm-Button-
2
- // Variant kommt aus dem Standard-Button-Pattern; Cancel ist immer
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, X } from "lucide-react";
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
- <DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
55
- <DialogPrimitive.Portal>
56
- <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" />
57
- <DialogPrimitive.Content
58
- data-testid={testId}
59
- // Wenn description fehlt, signalisieren wir Radix explizit
60
- // `aria-describedby={undefined}` sonst warnt Radix zur
61
- // Laufzeit (aria-Best-Practice: Modals sollen entweder eine
62
- // Description haben oder den Hinweis tragen dass keine da ist).
63
- {...(description === undefined && { "aria-describedby": undefined })}
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
- "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card p-6 shadow-lg",
66
- "data-[state=open]:animate-in data-[state=closed]:animate-out",
67
- "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
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
- <div className="flex flex-col gap-1.5">
73
- <DialogPrimitive.Title className="text-lg font-semibold tracking-tight">
74
- {title}
75
- </DialogPrimitive.Title>
76
- {description !== undefined && (
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
  }
@@ -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
+ }