@cosmicdrift/kumiko-renderer-web 0.256.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 +7 -5
- package/src/__tests__/render-edit.test.tsx +71 -0
- package/src/icons.tsx +4 -0
- package/src/layout/shell-breadcrumb.ts +1 -0
- package/src/primitives/__tests__/button.test.tsx +8 -0
- package/src/primitives/__tests__/number-placeholder.test.tsx +24 -0
- package/src/primitives/__tests__/password-placeholder.test.tsx +31 -0
- package/src/primitives/__tests__/secret-reveal.test.tsx +48 -0
- package/src/primitives/__tests__/textarea-placeholder.test.tsx +31 -0
- package/src/primitives/index.tsx +93 -1
- package/src/widgets/__tests__/drawer.test.tsx +117 -0
- package/src/widgets/drawer.tsx +66 -18
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "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.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "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.
|
|
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
|
|
@@ -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
|
+
});
|
package/src/primitives/index.tsx
CHANGED
|
@@ -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
|
-
:
|
|
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(
|
package/src/widgets/drawer.tsx
CHANGED
|
@@ -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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
//
|
|
73
|
-
|
|
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
|
|
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
|
|
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 [
|
|
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() :
|
|
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
|
-
|
|
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 `
|
|
158
|
-
// while maximized,
|
|
159
|
-
// press would otherwise jump the drawer back to that old size
|
|
160
|
-
// of resizing relative to what's on screen.
|
|
161
|
-
|
|
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={
|
|
174
|
-
style={
|
|
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
|