@cosmicdrift/kumiko-renderer-web 0.189.0 → 0.190.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.189.0",
3
+ "version": "0.190.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.189.0",
20
- "@cosmicdrift/kumiko-headless": "0.189.0",
21
- "@cosmicdrift/kumiko-renderer": "0.189.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.190.0",
20
+ "@cosmicdrift/kumiko-headless": "0.190.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.190.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",
package/src/ui/sheet.tsx CHANGED
@@ -47,6 +47,7 @@ function SheetOverlay({
47
47
 
48
48
  function SheetContent({
49
49
  className,
50
+ overlayClassName,
50
51
  children,
51
52
  side = "right",
52
53
  showCloseButton = true,
@@ -54,10 +55,11 @@ function SheetContent({
54
55
  }: React.ComponentProps<typeof SheetPrimitive.Content> & {
55
56
  side?: "top" | "right" | "bottom" | "left"
56
57
  showCloseButton?: boolean
58
+ overlayClassName?: string
57
59
  }) {
58
60
  return (
59
61
  <SheetPortal>
60
- <SheetOverlay />
62
+ <SheetOverlay className={overlayClassName} />
61
63
  <SheetPrimitive.Content
62
64
  data-slot="sheet-content"
63
65
  className={cn(
@@ -100,7 +102,13 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
100
102
  return (
101
103
  <div
102
104
  data-slot="sheet-footer"
103
- className={cn("mt-auto flex flex-col gap-2 p-4", className)}
105
+ // Matches the card footer convention (primitives/index.tsx cardFooter
106
+ // + cardFooterBorder) — same padding/border/button-row shape as
107
+ // SectionCard/DefaultCard so a drawer footer reads like a card footer.
108
+ className={cn(
109
+ "mt-auto flex items-center justify-end gap-2 border-t bg-muted/30 px-[var(--card-padding)] py-4",
110
+ className,
111
+ )}
104
112
  {...props}
105
113
  />
106
114
  )
@@ -42,6 +42,17 @@ describe("ProgressBar", () => {
42
42
  render(<ProgressBar value={-3} testId="bar" />);
43
43
  expect(screen.getByTestId("bar").getAttribute("aria-valuenow")).toBe("0");
44
44
  });
45
+
46
+ test("Füll-Element bildet den Wert über Breite ab und erbt die Höhe nicht vom Elternteil", () => {
47
+ render(<ProgressBar value={0.5} testId="bar" />);
48
+ const bar = screen.getByTestId("bar");
49
+ const fill = bar.firstElementChild as HTMLElement;
50
+ expect(fill.style.width).toBe("50%");
51
+ expect(fill.className).not.toContain("h-full");
52
+ expect(fill.className).toContain("absolute");
53
+ expect(fill.className).toContain("inset-y-0");
54
+ expect(bar.className).toContain("relative");
55
+ });
45
56
  });
46
57
 
47
58
  describe("ModeSwitch", () => {
@@ -1,4 +1,6 @@
1
- import type { ReactNode } from "react";
1
+ import { Maximize2Icon, Minimize2Icon } from "lucide-react";
2
+ import { type ReactNode, useRef, useState } from "react";
3
+ import { cn } from "../lib/cn";
2
4
  import {
3
5
  Sheet,
4
6
  SheetContent,
@@ -17,8 +19,40 @@ export type DrawerProps = {
17
19
  readonly footer?: ReactNode;
18
20
  readonly children: ReactNode;
19
21
  readonly testId?: string;
22
+ /** Opt-in drag-to-resize + maximize toggle (left/right sides only). */
23
+ readonly resizable?: boolean;
24
+ readonly defaultWidthPx?: number;
25
+ readonly minWidthPx?: number;
26
+ readonly maxWidthPx?: number;
20
27
  };
21
28
 
29
+ const DEFAULT_WIDTH_PX = 420;
30
+ const MIN_WIDTH_PX = 320;
31
+ const MAX_WIDTH_PX = 800;
32
+
33
+ // Floating panel with a clearly visible margin on every edge, rounded on
34
+ // all four corners — replaces the sheet primitive's flush-to-viewport-edge
35
+ // per-side classes. twMerge resolves each utility group against the base
36
+ // (inset/width/height/border/rounding), so this fully overrides rather than
37
+ // stacking with it. 32px margin + 32px radius so the detachment from the
38
+ // viewport edge reads clearly at a glance, not just on close 1:1 inspection.
39
+ function floatingSideClass(side: "left" | "right" | "top" | "bottom"): string {
40
+ switch (side) {
41
+ case "left":
42
+ return "inset-y-8 left-8 h-auto w-[420px] max-w-[85vw] sm:max-w-[420px] rounded-[2rem] border shadow-2xl";
43
+ case "top":
44
+ return "inset-x-8 top-8 h-auto max-h-[80vh] rounded-[2rem] border shadow-2xl";
45
+ case "bottom":
46
+ return "inset-x-8 bottom-8 h-auto max-h-[80vh] rounded-[2rem] border shadow-2xl";
47
+ default:
48
+ return "inset-y-8 right-8 h-auto w-[420px] max-w-[85vw] sm:max-w-[420px] rounded-[2rem] border shadow-2xl";
49
+ }
50
+ }
51
+
52
+ function clamp(value: number, min: number, max: number): number {
53
+ return Math.min(Math.max(value, min), max);
54
+ }
55
+
22
56
  /** Slide-in panel beside a list (e.g. mail reader next to the inbox) —
23
57
  * thin wrapper over the Sheet primitive with header/body/footer slots
24
58
  * so screens skip per-route Radix boilerplate. */
@@ -31,10 +65,72 @@ export function Drawer({
31
65
  footer,
32
66
  children,
33
67
  testId,
68
+ resizable = false,
69
+ defaultWidthPx = DEFAULT_WIDTH_PX,
70
+ minWidthPx = MIN_WIDTH_PX,
71
+ maxWidthPx = MAX_WIDTH_PX,
34
72
  }: DrawerProps): ReactNode {
73
+ const canResize = resizable && (side === "left" || side === "right");
74
+ const [width, setWidth] = useState(defaultWidthPx);
75
+ const [maximized, setMaximized] = useState(false);
76
+ const dragRef = useRef<{ startX: number; startWidth: number } | null>(null);
77
+
78
+ const effectiveMaxWidthPx = () =>
79
+ typeof window === "undefined"
80
+ ? maxWidthPx
81
+ : Math.min(maxWidthPx, Math.round(window.innerWidth * 0.9));
82
+ const effectiveWidthPx = maximized ? effectiveMaxWidthPx() : width;
83
+
84
+ const onHandlePointerDown = (event: React.PointerEvent<HTMLDivElement>): void => {
85
+ event.currentTarget.setPointerCapture(event.pointerId);
86
+ dragRef.current = { startX: event.clientX, startWidth: effectiveWidthPx };
87
+ setMaximized(false);
88
+ };
89
+ const onHandlePointerMove = (event: React.PointerEvent<HTMLDivElement>): void => {
90
+ if (dragRef.current === null) return;
91
+ const deltaX = event.clientX - dragRef.current.startX;
92
+ const signedDelta = side === "right" ? -deltaX : deltaX;
93
+ setWidth(clamp(dragRef.current.startWidth + signedDelta, minWidthPx, effectiveMaxWidthPx()));
94
+ };
95
+ const onHandlePointerUp = (event: React.PointerEvent<HTMLDivElement>): void => {
96
+ event.currentTarget.releasePointerCapture(event.pointerId);
97
+ dragRef.current = null;
98
+ };
99
+ const onHandleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
100
+ const step = event.shiftKey ? 40 : 16;
101
+ const grow = side === "right" ? "ArrowLeft" : "ArrowRight";
102
+ const shrink = side === "right" ? "ArrowRight" : "ArrowLeft";
103
+ if (event.key !== grow && event.key !== shrink) return;
104
+ event.preventDefault();
105
+ setMaximized(false);
106
+ const delta = event.key === grow ? step : -step;
107
+ setWidth((current) => clamp(current + delta, minWidthPx, effectiveMaxWidthPx()));
108
+ };
109
+
35
110
  return (
36
111
  <Sheet open={open} onOpenChange={onOpenChange}>
37
- <SheetContent side={side} data-testid={testId}>
112
+ <SheetContent
113
+ side={side}
114
+ data-testid={testId}
115
+ overlayClassName="bg-black/20 backdrop-blur-[2px]"
116
+ className={floatingSideClass(side)}
117
+ style={canResize ? { width: effectiveWidthPx, maxWidth: "none" } : undefined}
118
+ >
119
+ {canResize && (
120
+ <button
121
+ type="button"
122
+ onClick={() => setMaximized((m) => !m)}
123
+ aria-pressed={maximized}
124
+ aria-label={maximized ? "Restore drawer width" : "Maximize drawer width"}
125
+ className="absolute top-4 right-14 z-10 rounded-xs p-1 text-muted-foreground opacity-70 transition-opacity hover:opacity-100 hover:bg-secondary focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-hidden"
126
+ >
127
+ {maximized ? (
128
+ <Minimize2Icon className="size-4" />
129
+ ) : (
130
+ <Maximize2Icon className="size-4" />
131
+ )}
132
+ </button>
133
+ )}
38
134
  {(title !== undefined || description !== undefined) && (
39
135
  <SheetHeader>
40
136
  {title !== undefined && <SheetTitle>{title}</SheetTitle>}
@@ -43,6 +139,26 @@ export function Drawer({
43
139
  )}
44
140
  <div className="flex-1 overflow-y-auto px-4">{children}</div>
45
141
  {footer !== undefined && <SheetFooter>{footer}</SheetFooter>}
142
+ {canResize && (
143
+ // biome-ignore lint/a11y/useSemanticElements: <hr> can't carry pointer/keyboard drag interaction or a live width value — a draggable separator needs a div with the ARIA role.
144
+ <div
145
+ role="separator"
146
+ aria-orientation="vertical"
147
+ aria-label="Resize drawer"
148
+ aria-valuenow={effectiveWidthPx}
149
+ aria-valuemin={minWidthPx}
150
+ aria-valuemax={effectiveMaxWidthPx()}
151
+ tabIndex={0}
152
+ onPointerDown={onHandlePointerDown}
153
+ onPointerMove={onHandlePointerMove}
154
+ onPointerUp={onHandlePointerUp}
155
+ onKeyDown={onHandleKeyDown}
156
+ className={cn(
157
+ "absolute inset-y-0 z-10 w-1 cursor-col-resize touch-none after:absolute after:inset-y-0 after:left-1/2 after:w-3 after:-translate-x-1/2 hover:bg-border",
158
+ side === "right" ? "left-0 -translate-x-1/2" : "right-0 translate-x-1/2",
159
+ )}
160
+ />
161
+ )}
46
162
  </SheetContent>
47
163
  </Sheet>
48
164
  );
@@ -19,9 +19,12 @@ export function ProgressBar({
19
19
  aria-valuenow={Math.round(pct * 100)}
20
20
  aria-valuemin={0}
21
21
  aria-valuemax={100}
22
- className={cn("h-2 w-full overflow-hidden rounded-full bg-muted", className)}
22
+ className={cn("relative h-2 w-full overflow-hidden rounded-full bg-muted", className)}
23
23
  >
24
- <div className="h-full rounded-full bg-primary" style={{ width: `${pct * 100}%` }} />
24
+ <div
25
+ className="absolute inset-y-0 left-0 rounded-full bg-primary"
26
+ style={{ width: `${pct * 100}%` }}
27
+ />
25
28
  </div>
26
29
  );
27
30
  }