@cosmicdrift/kumiko-renderer-web 0.257.0 → 0.258.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.257.0",
3
+ "version": "0.258.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.257.0",
20
- "@cosmicdrift/kumiko-headless": "0.257.0",
21
- "@cosmicdrift/kumiko-renderer": "0.257.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.258.0",
20
+ "@cosmicdrift/kumiko-headless": "0.258.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.258.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",
@@ -32,6 +32,7 @@
32
32
  "clsx": "^2.1.1",
33
33
  "cmdk": "^1.1.1",
34
34
  "lucide-react": "^1.14.0",
35
+ "qrcode": "^1.5.4",
35
36
  "radix-ui": "^1.4.3",
36
37
  "react": "^19.2.6",
37
38
  "react-day-picker": "^10.0.0",
@@ -60,11 +61,12 @@
60
61
  "@tailwindcss/cli": "^4.3.0",
61
62
  "@testing-library/react": "^16.3.2",
62
63
  "@testing-library/user-event": "^14.6.1",
64
+ "@types/qrcode": "^1.5.5",
63
65
  "@types/react": "^19.2.14",
64
66
  "@types/react-dom": "^19.2.3",
65
67
  "jsdom": "^29.1.1",
66
68
  "tailwindcss": "^4.3.0",
67
- "@cosmicdrift/kumiko-locale-de": "0.257.0"
69
+ "@cosmicdrift/kumiko-locale-de": "0.258.0"
68
70
  },
69
71
  "repository": {
70
72
  "type": "git",
@@ -1851,6 +1851,77 @@ describe("RenderEdit wizard draft", () => {
1851
1851
  expect(titleInputAgain.value).toBe("Acme");
1852
1852
  });
1853
1853
 
1854
+ // fw#2548 security: a `format: "password"` field must never reach the
1855
+ // persisted draft blob — it would be stored in the clear server-side and
1856
+ // restored in plaintext on resume.
1857
+ test("a password-format field is stripped from the draft save payload", async () => {
1858
+ const passwordEntity = {
1859
+ fields: {
1860
+ title: { type: "text", required: true },
1861
+ apiToken: { type: "text", format: "password" },
1862
+ count: { type: "number" },
1863
+ },
1864
+ } as unknown as EntityDefinition;
1865
+
1866
+ const passwordScreen: EntityEditScreenDefinition = {
1867
+ id: "orders:screen:order-wizard-draft-password",
1868
+ type: "entityEdit",
1869
+ entity: "order",
1870
+ layout: {
1871
+ mode: "wizard",
1872
+ draft: true,
1873
+ sections: [
1874
+ { title: "Basics", columns: 1, fields: [{ field: "title" }, { field: "apiToken" }] },
1875
+ { title: "Details", columns: 1, fields: [{ field: "count" }] },
1876
+ ],
1877
+ },
1878
+ };
1879
+
1880
+ const savedPayloads: DraftBlob[] = [];
1881
+ const dispatcher = createMockDispatcher({
1882
+ query: (async () => ({ isSuccess: true, data: {} })) as Dispatcher["query"],
1883
+ write: (async (type: string, payload: unknown) => {
1884
+ if (type === "form-draft:write:save" && isDraftSavePayload(payload)) {
1885
+ savedPayloads.push(payload);
1886
+ }
1887
+ return { isSuccess: true, data: { id: "1" } };
1888
+ }) as Dispatcher["write"],
1889
+ });
1890
+
1891
+ render(
1892
+ <DispatcherProvider dispatcher={dispatcher}>
1893
+ <DraftStorageProvider value={createFakeDraftStorage()}>
1894
+ <RenderEdit<TestValues & { apiToken?: string }>
1895
+ screen={passwordScreen}
1896
+ entity={passwordEntity}
1897
+ featureName="orders"
1898
+ initial={{ title: "", count: 0, apiToken: "" }}
1899
+ writeCommand="order:create"
1900
+ />
1901
+ </DraftStorageProvider>
1902
+ </DispatcherProvider>,
1903
+ );
1904
+
1905
+ fireEvent.change(screen.getByTestId("field-title").querySelector("input") as HTMLInputElement, {
1906
+ target: { value: "Acme" },
1907
+ });
1908
+ fireEvent.change(
1909
+ screen.getByTestId("field-apiToken").querySelector("input") as HTMLInputElement,
1910
+ { target: { value: "hunter2" } },
1911
+ );
1912
+
1913
+ await act(async () => {
1914
+ fireEvent.click(screen.getByTestId("render-edit-wizard-next"));
1915
+ await Promise.resolve();
1916
+ });
1917
+
1918
+ expect(savedPayloads.length).toBeGreaterThan(0);
1919
+ const saved = savedPayloads[0] as DraftBlob;
1920
+ expect(saved.values["title"]).toBe("Acme");
1921
+ expect(Object.keys(saved.values)).not.toContain("apiToken");
1922
+ expect(JSON.stringify(saved.values)).not.toContain("hunter2");
1923
+ });
1924
+
1854
1925
  // fw#1929: bare crypto.randomUUID() breaks in non-secure contexts and
1855
1926
  // React Native/Hermes without a polyfill. With it deleted, minting a
1856
1927
  // draftId must still work through the mintDraftId() fallback instead of
package/src/icons.tsx CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  CheckCircle2,
28
28
  ChevronDown,
29
29
  ChevronRight,
30
+ CircleStop,
30
31
  ClipboardList,
31
32
  Clock,
32
33
  Coins,
@@ -57,6 +58,7 @@ import {
57
58
  Lock,
58
59
  Mail,
59
60
  MapPin,
61
+ Mic,
60
62
  MoreHorizontal,
61
63
  MoreVertical,
62
64
  Package,
@@ -175,6 +177,8 @@ export const NAV_ICONS = {
175
177
  "check-circle": CheckCircle2,
176
178
  "x-circle": XCircle,
177
179
  loader: Loader2,
180
+ mic: Mic,
181
+ "circle-stop": CircleStop,
178
182
  } as const satisfies Readonly<Record<NavIconKey, typeof Folder>>;
179
183
 
180
184
  // Widened alias for runtime lookups against the plain `string` icon keys
@@ -19,6 +19,7 @@ function explicitListScreenId(screen: ScreenDefinition): string | undefined {
19
19
  case "projectionDetail":
20
20
  case "entityEdit":
21
21
  case "actionForm":
22
+ case "secretMint":
22
23
  return screen.listScreenId;
23
24
  default:
24
25
  return undefined;
@@ -50,6 +50,14 @@ describe("DefaultButton icon (fw-ui-defaults)", () => {
50
50
  expect(btn.getAttribute("aria-label")).toBe("Delete");
51
51
  });
52
52
 
53
+ test("size='icon' without children renders the icon, accessible name comes from ariaLabel", () => {
54
+ render(<Button icon="trash" size="icon" ariaLabel="Delete" testId="btn" />);
55
+ const btn = screen.getByTestId("btn");
56
+ expect(btn.querySelector("svg")).not.toBeNull();
57
+ expect(btn.textContent).toBe("");
58
+ expect(screen.getByRole("button", { name: "Delete" })).toBe(btn);
59
+ });
60
+
53
61
  test("unknown icon key: no crash, falls back to rendering children only", () => {
54
62
  render(
55
63
  // @ts-expect-error — exercising the runtime fallback for a schema-supplied key outside the closed IconKey union
@@ -0,0 +1,24 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { render } from "@testing-library/react";
3
+ import { defaultPrimitives } from "../index";
4
+
5
+ const { Input } = defaultPrimitives;
6
+ const noop = () => {};
7
+
8
+ describe("number placeholder", () => {
9
+ test("a declared placeholder reaches the rendered input", () => {
10
+ const { getByPlaceholderText } = render(
11
+ <Input kind="number" id="qty" name="qty" value="" onChange={noop} placeholder="0" />,
12
+ );
13
+ expect(getByPlaceholderText("0").tagName).toBe("INPUT");
14
+ });
15
+
16
+ test("without placeholder the attribute is not set", () => {
17
+ const { container } = render(
18
+ <Input kind="number" id="qty" name="qty" value="" onChange={noop} />,
19
+ );
20
+ const input = container.querySelector("input");
21
+ if (input === null) throw new Error("no input rendered");
22
+ expect(input.hasAttribute("placeholder")).toBe(false);
23
+ });
24
+ });
@@ -0,0 +1,31 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { render } from "@testing-library/react";
3
+ import { defaultPrimitives } from "../index";
4
+
5
+ const { Input } = defaultPrimitives;
6
+ const noop = () => {};
7
+
8
+ describe("password placeholder", () => {
9
+ test("a declared placeholder reaches the rendered input", () => {
10
+ const { getByPlaceholderText } = render(
11
+ <Input
12
+ kind="password"
13
+ id="pw"
14
+ name="pw"
15
+ value=""
16
+ onChange={noop}
17
+ placeholder="Passwort eingeben"
18
+ />,
19
+ );
20
+ expect(getByPlaceholderText("Passwort eingeben").tagName).toBe("INPUT");
21
+ });
22
+
23
+ test("without placeholder the attribute is not set", () => {
24
+ const { container } = render(
25
+ <Input kind="password" id="pw" name="pw" value="" onChange={noop} />,
26
+ );
27
+ const input = container.querySelector("input");
28
+ if (input === null) throw new Error("no input rendered");
29
+ expect(input.hasAttribute("placeholder")).toBe(false);
30
+ });
31
+ });
@@ -0,0 +1,48 @@
1
+ // fw#2838: SecretRevealValue.qr renders a scannable QR code (otpauth://-style
2
+ // enrollment URI) instead of the default monospaced text display.
3
+ import { describe, expect, test } from "bun:test";
4
+ import { render, screen, waitFor } from "@testing-library/react";
5
+ import { defaultPrimitives } from "../index";
6
+
7
+ const { SecretReveal } = defaultPrimitives;
8
+ if (SecretReveal === undefined) {
9
+ throw new Error("defaultPrimitives.SecretReveal is not registered");
10
+ }
11
+
12
+ describe("DefaultSecretReveal (fw#2548 / fw#2838)", () => {
13
+ test("without qr, renders the value as monospaced text", () => {
14
+ render(
15
+ <SecretReveal
16
+ values={[{ label: "Token", value: "kpat_secret", copyable: true, multiline: false }]}
17
+ copyLabel="Copy"
18
+ copiedLabel="Copied"
19
+ testId="reveal"
20
+ />,
21
+ );
22
+ const reveal = screen.getByTestId("reveal");
23
+ expect(reveal.textContent).toContain("kpat_secret");
24
+ expect(reveal.querySelector("svg")).toBeNull();
25
+ });
26
+
27
+ test("with qr: true, renders a scannable QR code instead of monospaced text", async () => {
28
+ render(
29
+ <SecretReveal
30
+ values={[
31
+ {
32
+ label: "Token",
33
+ value: "otpauth://totp/Example:alice?secret=ABC&issuer=Example",
34
+ copyable: false,
35
+ multiline: false,
36
+ qr: true,
37
+ },
38
+ ]}
39
+ copyLabel="Copy"
40
+ copiedLabel="Copied"
41
+ testId="reveal"
42
+ />,
43
+ );
44
+ const reveal = screen.getByTestId("reveal");
45
+ await waitFor(() => expect(reveal.querySelector("svg")).not.toBeNull());
46
+ expect(reveal.textContent).not.toContain("otpauth://");
47
+ });
48
+ });
@@ -0,0 +1,31 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { render } from "@testing-library/react";
3
+ import { defaultPrimitives } from "../index";
4
+
5
+ const { Input } = defaultPrimitives;
6
+ const noop = () => {};
7
+
8
+ describe("textarea placeholder", () => {
9
+ test("a declared placeholder reaches the rendered textarea", () => {
10
+ const { getByPlaceholderText } = render(
11
+ <Input
12
+ kind="textarea"
13
+ id="notes"
14
+ name="notes"
15
+ value=""
16
+ onChange={noop}
17
+ placeholder="Nachricht an den Agenten …"
18
+ />,
19
+ );
20
+ expect(getByPlaceholderText("Nachricht an den Agenten …").tagName).toBe("TEXTAREA");
21
+ });
22
+
23
+ test("without placeholder the attribute is not set", () => {
24
+ const { container } = render(
25
+ <Input kind="textarea" id="notes" name="notes" value="" onChange={noop} />,
26
+ );
27
+ const textarea = container.querySelector("textarea");
28
+ if (textarea === null) throw new Error("no textarea rendered");
29
+ expect(textarea.hasAttribute("placeholder")).toBe(false);
30
+ });
31
+ });
@@ -36,6 +36,7 @@ import {
36
36
  type InputProps,
37
37
  type LinkProps,
38
38
  type ProgressProps,
39
+ type SecretRevealProps,
39
40
  type SectionProps,
40
41
  type StepBarProps,
41
42
  shouldRenderActionsIconOnly,
@@ -73,6 +74,7 @@ import {
73
74
  User,
74
75
  X,
75
76
  } from "lucide-react";
77
+ import QRCode from "qrcode";
76
78
  import {
77
79
  type ChangeEvent,
78
80
  Children,
@@ -240,6 +242,7 @@ function DefaultBanner({
240
242
  id,
241
243
  }: BannerProps): ReactNode {
242
244
  const isError = variant === "error";
245
+ const isWarning = variant === "warning";
243
246
  // `id` is set when a <Field> wraps this Banner as its control (see
244
247
  // BannerProps.id). A <div> isn't labelable, so a <label htmlFor> pointing
245
248
  // at it is inert for screen readers — role="group" + aria-labelledby is
@@ -255,7 +258,9 @@ function DefaultBanner({
255
258
  "relative w-full rounded-lg border px-4 py-3 text-sm flex items-center gap-3",
256
259
  isError
257
260
  ? "border-destructive/50 text-destructive bg-destructive/10 dark:border-destructive"
258
- : "bg-card text-card-foreground",
261
+ : isWarning
262
+ ? "border-status-warn/30 bg-status-warn/10 text-status-warn"
263
+ : "bg-card text-card-foreground",
259
264
  )}
260
265
  >
261
266
  <div className="flex-1">{children}</div>
@@ -591,6 +596,7 @@ function DefaultInput(props: InputProps): ReactNode {
591
596
  data-testid={props.testId}
592
597
  value={props.value}
593
598
  onChange={(e: ChangeEvent<HTMLInputElement>) => props.onChange(e.target.value)}
599
+ {...(props.placeholder !== undefined && { placeholder: props.placeholder })}
594
600
  autoComplete={props.autoComplete ?? "current-password"}
595
601
  />
596
602
  );
@@ -609,6 +615,7 @@ function DefaultInput(props: InputProps): ReactNode {
609
615
  const v = e.target.value;
610
616
  props.onChange(v === "" ? undefined : Number(v));
611
617
  }}
618
+ {...(props.placeholder !== undefined && { placeholder: props.placeholder })}
612
619
  className={cn(
613
620
  "text-right tabular-nums",
614
621
  fieldIconFor(props.icon) !== undefined ? "pl-8" : undefined,
@@ -815,6 +822,7 @@ function DefaultInput(props: InputProps): ReactNode {
815
822
  onChange={(e: ChangeEvent<HTMLTextAreaElement>) => props.onChange(e.target.value)}
816
823
  rows={rows ?? 4}
817
824
  className="resize-y"
825
+ {...(props.placeholder !== undefined && { placeholder: props.placeholder })}
818
826
  {...(rows !== undefined && { style: textareaMinHeight(rows) })}
819
827
  />
820
828
  );
@@ -2892,6 +2900,89 @@ export function DefaultCard({ slots, options, className, testId, children }: Car
2892
2900
  );
2893
2901
  }
2894
2902
 
2903
+ // otpauth:// enrollment URI → scannable QR (errorCorrectionLevel "H", ~30%
2904
+ // redundancy — more resilient to camera/lighting issues than the default, no
2905
+ // downside for a code this short-lived). Own component (not inline in
2906
+ // values.map) because QRCode.toString is async — a hook inside .map would
2907
+ // violate the rules of hooks.
2908
+ function QrSecretValue({ value }: { readonly value: string }): ReactNode {
2909
+ const [svg, setSvg] = useState<string | null>(null);
2910
+ useEffect(() => {
2911
+ let cancelled = false;
2912
+ QRCode.toString(value, { type: "svg", errorCorrectionLevel: "H" })
2913
+ .then((result) => {
2914
+ if (!cancelled) setSvg(result);
2915
+ })
2916
+ .catch(() => {
2917
+ // a reveal value always has the plaintext/manual-entry display alongside — QR is just convenience
2918
+ });
2919
+ return () => {
2920
+ cancelled = true;
2921
+ };
2922
+ }, [value]);
2923
+ if (svg === null) return null;
2924
+ return (
2925
+ <div
2926
+ className="h-40 w-40"
2927
+ // qrcode's own SVG string output, not user input — safe to inline
2928
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: qrcode-generated SVG, no user input
2929
+ dangerouslySetInnerHTML={{ __html: svg }}
2930
+ />
2931
+ );
2932
+ }
2933
+
2934
+ // One-time secret reveal (fw#2548, secretMint confirm phase) — monospaced
2935
+ // per-value display with a copy button that flips to `copiedLabel` on
2936
+ // success. Silent-catch on clipboard error mirrors the copy-link pattern in
2937
+ // create-app.tsx.
2938
+ function DefaultSecretReveal({
2939
+ values,
2940
+ copyLabel,
2941
+ copiedLabel,
2942
+ testId,
2943
+ }: SecretRevealProps): ReactNode {
2944
+ const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
2945
+ return (
2946
+ <div data-testid={testId} className="flex flex-col gap-3">
2947
+ {values.map((v, i) => (
2948
+ <div key={v.label} className="flex flex-col gap-1.5">
2949
+ <span className="text-sm font-medium text-muted-foreground">{v.label}</span>
2950
+ <div className="flex items-start gap-2">
2951
+ {v.qr === true ? (
2952
+ <QrSecretValue value={v.value} />
2953
+ ) : v.multiline ? (
2954
+ <pre className="flex-1 overflow-x-auto whitespace-pre-wrap break-all rounded bg-muted px-3 py-2 font-mono text-sm">
2955
+ {v.value}
2956
+ </pre>
2957
+ ) : (
2958
+ <code className="flex-1 overflow-x-auto whitespace-pre-wrap break-all rounded bg-muted px-3 py-2 font-mono text-sm">
2959
+ {v.value}
2960
+ </code>
2961
+ )}
2962
+ {v.copyable && (
2963
+ <UiButton
2964
+ type="button"
2965
+ variant="outline"
2966
+ size="sm"
2967
+ onClick={async () => {
2968
+ try {
2969
+ await navigator.clipboard.writeText(v.value);
2970
+ setCopiedIndex(i);
2971
+ } catch {
2972
+ // clipboard blocked (non-secure context) — no fallback UI needed here
2973
+ }
2974
+ }}
2975
+ >
2976
+ {copiedIndex === i ? copiedLabel : copyLabel}
2977
+ </UiButton>
2978
+ )}
2979
+ </div>
2980
+ </div>
2981
+ ))}
2982
+ </div>
2983
+ );
2984
+ }
2985
+
2895
2986
  export const defaultPrimitives: CorePrimitives = {
2896
2987
  Button: DefaultButton,
2897
2988
  Banner: DefaultBanner,
@@ -2922,4 +3013,5 @@ export const defaultPrimitives: CorePrimitives = {
2922
3013
  JsonView: DefaultJsonView,
2923
3014
  FillContainer: DefaultFillContainer,
2924
3015
  ActionOverflowMenu,
3016
+ SecretReveal: DefaultSecretReveal,
2925
3017
  };
@@ -111,6 +111,100 @@ describe("Drawer", () => {
111
111
  });
112
112
  });
113
113
 
114
+ describe("variant", () => {
115
+ test("default (no variant prop): floating classes unchanged (inset-y-8, right-8, rounded-[2rem], default width)", () => {
116
+ render(
117
+ <Drawer open={true} onOpenChange={() => {}} side="right" testId="drawer">
118
+ <div>Body</div>
119
+ </Drawer>,
120
+ );
121
+ const content = screen.getByTestId("drawer");
122
+ expect(content.className).toContain("inset-y-8");
123
+ expect(content.className).toContain("right-8");
124
+ expect(content.className).toContain("rounded-[2rem]");
125
+ expect(content.className).toContain("w-[max(600px,37.5vw)]");
126
+ });
127
+
128
+ test('variant="flush" with side="right": inset-y-0, no radius, border-l only', () => {
129
+ render(
130
+ <Drawer open={true} onOpenChange={() => {}} side="right" variant="flush" testId="drawer">
131
+ <div>Body</div>
132
+ </Drawer>,
133
+ );
134
+ const content = screen.getByTestId("drawer");
135
+ expect(content.className).toContain("inset-y-0");
136
+ expect(content.className).not.toContain("rounded-[2rem]");
137
+ expect(content.className).not.toContain("inset-y-8");
138
+ expect(content.className).toContain("border-l");
139
+ expect(content.className).not.toContain("border-r");
140
+ });
141
+
142
+ test('variant="flush" with side="left": border-r only', () => {
143
+ render(
144
+ <Drawer open={true} onOpenChange={() => {}} side="left" variant="flush" testId="drawer">
145
+ <div>Body</div>
146
+ </Drawer>,
147
+ );
148
+ const content = screen.getByTestId("drawer");
149
+ expect(content.className).toContain("border-r");
150
+ expect(content.className).not.toContain("border-l");
151
+ });
152
+ });
153
+
154
+ describe("width", () => {
155
+ test("width={420}: reflected as inline pixel width", () => {
156
+ render(
157
+ <Drawer open={true} onOpenChange={() => {}} side="right" width={420} testId="drawer">
158
+ <div>Body</div>
159
+ </Drawer>,
160
+ );
161
+ expect(screen.getByTestId("drawer").style.width).toBe("420px");
162
+ });
163
+
164
+ test('width="30rem": reflected as-is (CSS length string)', () => {
165
+ render(
166
+ <Drawer open={true} onOpenChange={() => {}} side="right" width="30rem" testId="drawer">
167
+ <div>Body</div>
168
+ </Drawer>,
169
+ );
170
+ expect(screen.getByTestId("drawer").style.width).toBe("30rem");
171
+ });
172
+
173
+ test("no width prop: no inline width style (default stays class-driven)", () => {
174
+ render(
175
+ <Drawer open={true} onOpenChange={() => {}} side="right" testId="drawer">
176
+ <div>Body</div>
177
+ </Drawer>,
178
+ );
179
+ expect(screen.getByTestId("drawer").style.width).toBe("");
180
+ });
181
+
182
+ test('side="top": width prop is ignored', () => {
183
+ render(
184
+ <Drawer open={true} onOpenChange={() => {}} side="top" width={420} testId="drawer">
185
+ <div>Body</div>
186
+ </Drawer>,
187
+ );
188
+ expect(screen.getByTestId("drawer").style.width).toBe("");
189
+ });
190
+
191
+ test("resize set + width set: resize wins", () => {
192
+ render(
193
+ <Drawer
194
+ open={true}
195
+ onOpenChange={() => {}}
196
+ side="right"
197
+ width={420}
198
+ resize={{ defaultWidthPx: 350, minWidthPx: 300, maxWidthPx: 500 }}
199
+ testId="drawer"
200
+ >
201
+ <div>Body</div>
202
+ </Drawer>,
203
+ );
204
+ expect(screen.getByTestId("drawer").style.width).toBe("350px");
205
+ });
206
+ });
207
+
114
208
  describe("resize", () => {
115
209
  // happy-dom exposes innerWidth as an accessor (get/set) on the window
116
210
  // instance. Object.defineProperty(...) with a plain `value` replaces
@@ -423,6 +517,29 @@ describe("Drawer", () => {
423
517
  expect(content.className).not.toContain("inset-y-8");
424
518
  });
425
519
 
520
+ test('narrow with variant="flush": fullscreen branch unaffected, no inline width', () => {
521
+ mockMatchMedia(true);
522
+ render(
523
+ <Drawer
524
+ open={true}
525
+ onOpenChange={() => {}}
526
+ side="right"
527
+ variant="flush"
528
+ width={420}
529
+ testId="drawer"
530
+ >
531
+ <div>Body</div>
532
+ </Drawer>,
533
+ );
534
+ const content = screen.getByTestId("drawer");
535
+ expect(content.className).toContain("inset-0");
536
+ expect(content.className).toContain("w-full");
537
+ expect(content.className).not.toContain("rounded-[2rem]");
538
+ expect(content.className).not.toContain("inset-y-8");
539
+ expect(content.className).not.toContain("border-l");
540
+ expect(content.style.width).toBe("");
541
+ });
542
+
426
543
  test("narrow with resize set: no inline width style", () => {
427
544
  mockMatchMedia(true);
428
545
  render(
@@ -28,6 +28,17 @@ export type DrawerProps = {
28
28
  * footer already has a dedicated close/cancel action, so there is only
29
29
  * one way to dismiss the drawer. */
30
30
  readonly showCloseButton?: boolean;
31
+ /** Panel treatment. `"floating"` (default) keeps the detached-panel look
32
+ * (margin to the viewport edge, full corner radius). `"flush"` docks the
33
+ * panel against the edge instead — full extent, no radius, and a border
34
+ * only on the edge facing the app content. Ignored in the narrow-viewport
35
+ * fullscreen layout. */
36
+ readonly variant?: "floating" | "flush";
37
+ /** Panel width for `side="left"|"right"` (ignored for top/bottom and in
38
+ * the narrow-viewport layout). A number is pixels, a string any CSS
39
+ * length. Superseded by `resize` when that's set. Default matches the
40
+ * panel's built-in `max(600px, 37.5vw)`. */
41
+ readonly width?: number | string;
31
42
  /** Opt-in drag-to-resize + maximize toggle (left/right sides only). */
32
43
  readonly resize?: {
33
44
  readonly defaultWidthPx?: number;
@@ -65,22 +76,49 @@ function defaultWidthFromViewport(): number {
65
76
  : Math.max(DEFAULT_WIDTH_MIN_PX, Math.round(window.innerWidth * DEFAULT_WIDTH_VIEWPORT_RATIO));
66
77
  }
67
78
 
68
- // 32px margin + 32px radius so the panel reads as detached from the
69
- // viewport edge, unlike the sheet primitive's flush-edge default. Below the
70
- // narrow-viewport breakpoint the floating treatment doesn't fit — the JS
71
- // hook decides, not a `sm:` Tailwind prefix, since the resize width below
72
- // already comes from JS and two decision sources is the bug this avoids.
73
- function floatingSideClass(side: "left" | "right" | "top" | "bottom", narrow: boolean): string {
79
+ function normalizeWidth(width: number | string): string {
80
+ return typeof width === "number" ? `${width}px` : width;
81
+ }
82
+
83
+ // Must match the static `max(600px,37.5vw)` in sidePanelClass Tailwind's
84
+ // JIT can't read DEFAULT_WIDTH_MIN_PX/DEFAULT_WIDTH_VIEWPORT_RATIO at
85
+ // runtime, so the two are kept in sync by hand.
86
+ const WIDTH_CLASS = "w-[max(600px,37.5vw)] max-w-[85vw] sm:max-w-[max(600px,37.5vw)]";
87
+
88
+ // "floating": 32px margin + 32px radius so the panel reads as detached from
89
+ // the viewport edge, unlike the sheet primitive's flush-edge default.
90
+ // "flush" docks it back to that flush-edge default instead, borrowing the
91
+ // vendored Sheet's per-side border (only the edge facing the app content).
92
+ // Below the narrow-viewport breakpoint neither treatment fits — the JS hook
93
+ // decides, not a `sm:` Tailwind prefix, since the resize width below already
94
+ // comes from JS and two decision sources is the bug this avoids.
95
+ function sidePanelClass(
96
+ side: "left" | "right" | "top" | "bottom",
97
+ narrow: boolean,
98
+ variant: "floating" | "flush",
99
+ ): string {
74
100
  if (narrow) return "inset-0 h-full w-full max-w-none rounded-none border-0 overflow-hidden";
101
+ if (variant === "flush") {
102
+ switch (side) {
103
+ case "left":
104
+ return `inset-y-0 left-0 h-full ${WIDTH_CLASS} border-r shadow-2xl overflow-hidden`;
105
+ case "top":
106
+ return "inset-x-0 top-0 h-auto max-h-[80vh] border-b shadow-2xl overflow-hidden";
107
+ case "bottom":
108
+ return "inset-x-0 bottom-0 h-auto max-h-[80vh] border-t shadow-2xl overflow-hidden";
109
+ default:
110
+ return `inset-y-0 right-0 h-full ${WIDTH_CLASS} border-l shadow-2xl overflow-hidden`;
111
+ }
112
+ }
75
113
  switch (side) {
76
114
  case "left":
77
- return "inset-y-8 left-8 h-auto w-[max(600px,37.5vw)] max-w-[85vw] sm:max-w-[max(600px,37.5vw)] rounded-[2rem] border shadow-2xl overflow-hidden";
115
+ return `inset-y-8 left-8 h-auto ${WIDTH_CLASS} rounded-[2rem] border shadow-2xl overflow-hidden`;
78
116
  case "top":
79
117
  return "inset-x-8 top-8 h-auto max-h-[80vh] rounded-[2rem] border shadow-2xl overflow-hidden";
80
118
  case "bottom":
81
119
  return "inset-x-8 bottom-8 h-auto max-h-[80vh] rounded-[2rem] border shadow-2xl overflow-hidden";
82
120
  default:
83
- return "inset-y-8 right-8 h-auto w-[max(600px,37.5vw)] max-w-[85vw] sm:max-w-[max(600px,37.5vw)] rounded-[2rem] border shadow-2xl overflow-hidden";
121
+ return `inset-y-8 right-8 h-auto ${WIDTH_CLASS} rounded-[2rem] border shadow-2xl overflow-hidden`;
84
122
  }
85
123
  }
86
124
 
@@ -97,19 +135,25 @@ export function Drawer({
97
135
  children,
98
136
  testId,
99
137
  showCloseButton = true,
138
+ variant = "floating",
139
+ width,
100
140
  resize,
101
141
  backdrop,
102
142
  }: DrawerProps): ReactNode {
103
143
  const t = useTranslation();
104
144
  const narrow = useIsNarrowViewport();
105
145
  const canResize = resize !== undefined && (side === "left" || side === "right");
146
+ const customWidthStyle =
147
+ !canResize && !narrow && (side === "left" || side === "right") && width !== undefined
148
+ ? { width: normalizeWidth(width), maxWidth: "none" }
149
+ : undefined;
106
150
  const minWidthPx = resize?.minWidthPx ?? MIN_WIDTH_PX;
107
151
  const maxWidthPx = resize?.maxWidthPx ?? MAX_WIDTH_PX;
108
152
  const effectiveMaxWidthPx = () =>
109
153
  typeof window === "undefined"
110
154
  ? maxWidthPx
111
155
  : Math.min(maxWidthPx, Math.round(window.innerWidth * 0.9));
112
- const [width, setWidth] = useState(() =>
156
+ const [resizedWidthPx, setResizedWidthPx] = useState(() =>
113
157
  clamp(resize?.defaultWidthPx ?? defaultWidthFromViewport(), minWidthPx, effectiveMaxWidthPx()),
114
158
  );
115
159
  const [maximized, setMaximized] = useState(false);
@@ -121,7 +165,7 @@ export function Drawer({
121
165
  ...(blurPx > 0 ? { backdropFilter: `blur(${blurPx}px)` } : {}),
122
166
  };
123
167
 
124
- const effectiveWidthPx = maximized ? effectiveMaxWidthPx() : width;
168
+ const effectiveWidthPx = maximized ? effectiveMaxWidthPx() : resizedWidthPx;
125
169
 
126
170
  const onHandlePointerDown = (event: React.PointerEvent<HTMLDivElement>): void => {
127
171
  event.preventDefault();
@@ -137,7 +181,9 @@ export function Drawer({
137
181
  if (dragRef.current === null) return;
138
182
  const deltaX = event.clientX - dragRef.current.startX;
139
183
  const signedDelta = side === "right" ? -deltaX : deltaX;
140
- setWidth(clamp(dragRef.current.startWidth + signedDelta, minWidthPx, effectiveMaxWidthPx()));
184
+ setResizedWidthPx(
185
+ clamp(dragRef.current.startWidth + signedDelta, minWidthPx, effectiveMaxWidthPx()),
186
+ );
141
187
  };
142
188
  const endHandlePointerDrag = (event: React.PointerEvent<HTMLDivElement>): void => {
143
189
  if (event.currentTarget.hasPointerCapture(event.pointerId)) {
@@ -154,11 +200,11 @@ export function Drawer({
154
200
  event.preventDefault();
155
201
  setMaximized(false);
156
202
  const delta = event.key === grow ? step : -step;
157
- // Seed from the currently visible width, not the stale `width` state —
158
- // while maximized, `width` still holds the pre-maximize value, so a key
159
- // press would otherwise jump the drawer back to that old size instead
160
- // of resizing relative to what's on screen.
161
- setWidth((current) =>
203
+ // Seed from the currently visible width, not the stale `resizedWidthPx`
204
+ // state — while maximized, it still holds the pre-maximize value, so a
205
+ // key press would otherwise jump the drawer back to that old size
206
+ // instead of resizing relative to what's on screen.
207
+ setResizedWidthPx((current) =>
162
208
  clamp((maximized ? effectiveWidthPx : current) + delta, minWidthPx, effectiveMaxWidthPx()),
163
209
  );
164
210
  };
@@ -170,8 +216,10 @@ export function Drawer({
170
216
  data-testid={testId}
171
217
  overlayStyle={overlayStyle}
172
218
  showCloseButton={showCloseButton}
173
- className={floatingSideClass(side, narrow)}
174
- style={canResize && !narrow ? { width: effectiveWidthPx, maxWidth: "none" } : undefined}
219
+ className={sidePanelClass(side, narrow, variant)}
220
+ style={
221
+ canResize && !narrow ? { width: effectiveWidthPx, maxWidth: "none" } : customWidthStyle
222
+ }
175
223
  >
176
224
  {canResize && !narrow && (
177
225
  <button