@clef-sh/ui 0.1.20 → 0.1.21
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/dist/client/assets/index-DPWHjBbB.js +34 -0
- package/dist/client/assets/index-qsLTYpc9.css +2 -0
- package/dist/client/clef.svg +2 -0
- package/dist/client/index.html +3 -31
- package/dist/client-lib/components/Button.d.ts +1 -1
- package/dist/client-lib/components/Button.d.ts.map +1 -1
- package/dist/client-lib/components/CopyButton.d.ts.map +1 -1
- package/dist/client-lib/components/EnvBadge.d.ts.map +1 -1
- package/dist/client-lib/components/MatrixGrid.d.ts.map +1 -1
- package/dist/client-lib/components/Sidebar.d.ts +1 -1
- package/dist/client-lib/components/Sidebar.d.ts.map +1 -1
- package/dist/client-lib/components/StatusDot.d.ts.map +1 -1
- package/dist/client-lib/components/SyncPanel.d.ts.map +1 -1
- package/dist/client-lib/components/TopBar.d.ts +6 -0
- package/dist/client-lib/components/TopBar.d.ts.map +1 -1
- package/dist/client-lib/primitives/Badge.d.ts +11 -0
- package/dist/client-lib/primitives/Badge.d.ts.map +1 -0
- package/dist/client-lib/primitives/Card.d.ts +28 -0
- package/dist/client-lib/primitives/Card.d.ts.map +1 -0
- package/dist/client-lib/primitives/Dialog.d.ts +30 -0
- package/dist/client-lib/primitives/Dialog.d.ts.map +1 -0
- package/dist/client-lib/primitives/EmptyState.d.ts +10 -0
- package/dist/client-lib/primitives/EmptyState.d.ts.map +1 -0
- package/dist/client-lib/primitives/Field.d.ts +36 -0
- package/dist/client-lib/primitives/Field.d.ts.map +1 -0
- package/dist/client-lib/primitives/Input.d.ts +6 -0
- package/dist/client-lib/primitives/Input.d.ts.map +1 -0
- package/dist/client-lib/primitives/Stat.d.ts +11 -0
- package/dist/client-lib/primitives/Stat.d.ts.map +1 -0
- package/dist/client-lib/primitives/Table.d.ts +37 -0
- package/dist/client-lib/primitives/Table.d.ts.map +1 -0
- package/dist/client-lib/primitives/Tabs.d.ts +29 -0
- package/dist/client-lib/primitives/Tabs.d.ts.map +1 -0
- package/dist/client-lib/primitives/Toast.d.ts +16 -0
- package/dist/client-lib/primitives/Toast.d.ts.map +1 -0
- package/dist/client-lib/primitives/Toolbar.d.ts +29 -0
- package/dist/client-lib/primitives/Toolbar.d.ts.map +1 -0
- package/dist/client-lib/primitives/index.d.ts +23 -0
- package/dist/client-lib/primitives/index.d.ts.map +1 -0
- package/dist/client-lib/theme.d.ts +18 -41
- package/dist/client-lib/theme.d.ts.map +1 -1
- package/dist/server/api.d.ts.map +1 -1
- package/dist/server/api.js +215 -0
- package/dist/server/api.js.map +1 -1
- package/dist/server/envelope.d.ts +15 -0
- package/dist/server/envelope.d.ts.map +1 -0
- package/dist/server/envelope.js +310 -0
- package/dist/server/envelope.js.map +1 -0
- package/package.json +7 -2
- package/src/client/App.tsx +16 -41
- package/src/client/components/Button.tsx +13 -22
- package/src/client/components/CopyButton.tsx +5 -12
- package/src/client/components/EnvBadge.tsx +30 -15
- package/src/client/components/MatrixGrid.tsx +108 -252
- package/src/client/components/Sidebar.tsx +123 -199
- package/src/client/components/StatusDot.tsx +10 -15
- package/src/client/components/SyncPanel.tsx +14 -62
- package/src/client/components/TopBar.tsx +11 -36
- package/src/client/index.html +1 -30
- package/src/client/main.tsx +1 -0
- package/src/client/primitives/Badge.test.tsx +47 -0
- package/src/client/primitives/Badge.tsx +64 -0
- package/src/client/primitives/Card.test.tsx +50 -0
- package/src/client/primitives/Card.tsx +85 -0
- package/src/client/primitives/Dialog.test.tsx +55 -0
- package/src/client/primitives/Dialog.tsx +96 -0
- package/src/client/primitives/EmptyState.test.tsx +25 -0
- package/src/client/primitives/EmptyState.tsx +38 -0
- package/src/client/primitives/Field.test.tsx +46 -0
- package/src/client/primitives/Field.tsx +95 -0
- package/src/client/primitives/Input.tsx +26 -0
- package/src/client/primitives/Stat.test.tsx +32 -0
- package/src/client/primitives/Stat.tsx +52 -0
- package/src/client/primitives/Table.test.tsx +58 -0
- package/src/client/primitives/Table.tsx +113 -0
- package/src/client/primitives/Tabs.test.tsx +44 -0
- package/src/client/primitives/Tabs.tsx +100 -0
- package/src/client/primitives/Toast.test.tsx +77 -0
- package/src/client/primitives/Toast.tsx +89 -0
- package/src/client/primitives/Toolbar.test.tsx +50 -0
- package/src/client/primitives/Toolbar.tsx +86 -0
- package/src/client/primitives/index.ts +43 -0
- package/src/client/public/clef.svg +2 -0
- package/src/client/screens/BackendScreen.tsx +104 -363
- package/src/client/screens/DiffView.tsx +187 -378
- package/src/client/screens/EnvelopeScreen.test.tsx +542 -0
- package/src/client/screens/EnvelopeScreen.tsx +948 -0
- package/src/client/screens/GitLogView.tsx +48 -106
- package/src/client/screens/ImportScreen.tsx +105 -308
- package/src/client/screens/LintView.tsx +184 -379
- package/src/client/screens/ManifestScreen.tsx +283 -445
- package/src/client/screens/MatrixView.tsx +75 -91
- package/src/client/screens/NamespaceEditor.tsx +234 -609
- package/src/client/screens/PolicyView.tsx +183 -453
- package/src/client/screens/RecipientsScreen.tsx +71 -350
- package/src/client/screens/ResetScreen.tsx +67 -237
- package/src/client/screens/ScanScreen.tsx +85 -249
- package/src/client/screens/SchemaEditor.test.tsx +237 -0
- package/src/client/screens/SchemaEditor.tsx +435 -0
- package/src/client/screens/ServiceIdentitiesScreen.tsx +251 -788
- package/src/client/styles.css +77 -0
- package/src/client/theme.ts +27 -48
- package/dist/client/assets/index-Db6WgHgY.js +0 -38
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Input, Textarea } from "./Input";
|
|
3
|
+
|
|
4
|
+
function joinClasses(...parts: Array<string | false | null | undefined>): string {
|
|
5
|
+
return parts.filter(Boolean).join(" ");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface FieldProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
9
|
+
label: string;
|
|
10
|
+
hint?: string;
|
|
11
|
+
error?: string;
|
|
12
|
+
required?: boolean;
|
|
13
|
+
className?: string;
|
|
14
|
+
children?: React.ReactNode;
|
|
15
|
+
htmlFor?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface FieldLabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {
|
|
19
|
+
required?: boolean;
|
|
20
|
+
className?: string;
|
|
21
|
+
children?: React.ReactNode;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function FieldLabel({ required, className, children, ...rest }: FieldLabelProps) {
|
|
25
|
+
return (
|
|
26
|
+
<label
|
|
27
|
+
className={joinClasses(
|
|
28
|
+
"font-sans text-[11px] font-medium text-ash uppercase tracking-[0.08em]",
|
|
29
|
+
className,
|
|
30
|
+
)}
|
|
31
|
+
{...rest}
|
|
32
|
+
>
|
|
33
|
+
{children}
|
|
34
|
+
{required ? <span className="text-stop-500"> *</span> : null}
|
|
35
|
+
</label>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface FieldHintProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
40
|
+
className?: string;
|
|
41
|
+
children?: React.ReactNode;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function FieldHint({ className, children, ...rest }: FieldHintProps) {
|
|
45
|
+
return (
|
|
46
|
+
<div className={joinClasses("font-sans text-[11px] text-ash-dim mt-1", className)} {...rest}>
|
|
47
|
+
{children}
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface FieldErrorProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
53
|
+
className?: string;
|
|
54
|
+
children?: React.ReactNode;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function FieldError({ className, children, ...rest }: FieldErrorProps) {
|
|
58
|
+
return (
|
|
59
|
+
<div
|
|
60
|
+
role="alert"
|
|
61
|
+
className={joinClasses("font-sans text-[11px] text-stop-500 mt-1", className)}
|
|
62
|
+
{...rest}
|
|
63
|
+
>
|
|
64
|
+
{children}
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const FieldRoot = React.forwardRef<HTMLDivElement, FieldProps>(function Field(
|
|
70
|
+
{ label, hint, error, required, className, children, htmlFor, ...rest },
|
|
71
|
+
ref,
|
|
72
|
+
) {
|
|
73
|
+
return (
|
|
74
|
+
<div ref={ref} className={joinClasses("flex flex-col gap-1.5", className)} {...rest}>
|
|
75
|
+
<FieldLabel required={required} htmlFor={htmlFor}>
|
|
76
|
+
{label}
|
|
77
|
+
</FieldLabel>
|
|
78
|
+
<div>{children}</div>
|
|
79
|
+
{error ? <FieldError>{error}</FieldError> : hint ? <FieldHint>{hint}</FieldHint> : null}
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
type FieldCompound = typeof FieldRoot & {
|
|
85
|
+
Label: typeof FieldLabel;
|
|
86
|
+
Hint: typeof FieldHint;
|
|
87
|
+
Error: typeof FieldError;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const Field = FieldRoot as FieldCompound;
|
|
91
|
+
Field.Label = FieldLabel;
|
|
92
|
+
Field.Hint = FieldHint;
|
|
93
|
+
Field.Error = FieldError;
|
|
94
|
+
|
|
95
|
+
export { Field, Input, Textarea };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
const INPUT_BASE =
|
|
4
|
+
"w-full bg-ink-950 border border-edge rounded-md px-2.5 py-1.5 font-mono text-[12px] text-bone outline-none focus-visible:border-gold-500 placeholder:text-ash-dim disabled:text-ash-dim disabled:bg-ink-900";
|
|
5
|
+
|
|
6
|
+
function joinClasses(...parts: Array<string | false | null | undefined>): string {
|
|
7
|
+
return parts.filter(Boolean).join(" ");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
|
11
|
+
|
|
12
|
+
export const Input = React.forwardRef<HTMLInputElement, InputProps>(function Input(
|
|
13
|
+
{ className, ...rest },
|
|
14
|
+
ref,
|
|
15
|
+
) {
|
|
16
|
+
return <input ref={ref} className={joinClasses(INPUT_BASE, className)} {...rest} />;
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
|
|
20
|
+
|
|
21
|
+
export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea(
|
|
22
|
+
{ className, ...rest },
|
|
23
|
+
ref,
|
|
24
|
+
) {
|
|
25
|
+
return <textarea ref={ref} className={joinClasses(INPUT_BASE, className)} {...rest} />;
|
|
26
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import "@testing-library/jest-dom";
|
|
4
|
+
import { Stat } from "./Stat";
|
|
5
|
+
|
|
6
|
+
describe("Stat", () => {
|
|
7
|
+
it("renders label and value", () => {
|
|
8
|
+
render(<Stat label="Healthy" value={12} data-testid="s" />);
|
|
9
|
+
expect(screen.getByText("Healthy")).toBeInTheDocument();
|
|
10
|
+
expect(screen.getByText("12")).toBeInTheDocument();
|
|
11
|
+
expect(screen.getByTestId("s")).toHaveAttribute("data-tone", "default");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("applies tone bar class for go tone", () => {
|
|
15
|
+
render(<Stat label="OK" value="100%" tone="go" data-testid="s" />);
|
|
16
|
+
const root = screen.getByTestId("s");
|
|
17
|
+
expect(root).toHaveAttribute("data-tone", "go");
|
|
18
|
+
const bar = root.querySelector("span[aria-hidden]");
|
|
19
|
+
expect(bar?.className).toContain("bg-go-500");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("applies stop tone bar class", () => {
|
|
23
|
+
render(<Stat label="Down" value={3} tone="stop" data-testid="s" />);
|
|
24
|
+
const bar = screen.getByTestId("s").querySelector("span[aria-hidden]");
|
|
25
|
+
expect(bar?.className).toContain("bg-stop-500");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("renders icon slot", () => {
|
|
29
|
+
render(<Stat label="X" value={1} icon={<span data-testid="ico">i</span>} />);
|
|
30
|
+
expect(screen.getByTestId("ico")).toBeInTheDocument();
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
export type StatTone = "default" | "go" | "warn" | "stop" | "gold";
|
|
4
|
+
|
|
5
|
+
export interface StatProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
6
|
+
label: string;
|
|
7
|
+
value: string | number;
|
|
8
|
+
tone?: StatTone;
|
|
9
|
+
icon?: React.ReactNode;
|
|
10
|
+
className?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function joinClasses(...parts: Array<string | false | null | undefined>): string {
|
|
14
|
+
return parts.filter(Boolean).join(" ");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const TONE_BAR: Record<StatTone, string> = {
|
|
18
|
+
default: "bg-ash-deep",
|
|
19
|
+
go: "bg-go-500",
|
|
20
|
+
warn: "bg-warn-500",
|
|
21
|
+
stop: "bg-stop-500",
|
|
22
|
+
gold: "bg-gold-500",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const Stat = React.forwardRef<HTMLDivElement, StatProps>(function Stat(
|
|
26
|
+
{ label, value, tone = "default", icon, className, ...rest },
|
|
27
|
+
ref,
|
|
28
|
+
) {
|
|
29
|
+
return (
|
|
30
|
+
<div
|
|
31
|
+
ref={ref}
|
|
32
|
+
data-tone={tone}
|
|
33
|
+
className={joinClasses(
|
|
34
|
+
"bg-ink-850 border border-edge rounded-card p-4 relative overflow-hidden",
|
|
35
|
+
className,
|
|
36
|
+
)}
|
|
37
|
+
{...rest}
|
|
38
|
+
>
|
|
39
|
+
<span
|
|
40
|
+
aria-hidden
|
|
41
|
+
className={joinClasses("absolute inset-y-0 left-0 w-[3px]", TONE_BAR[tone])}
|
|
42
|
+
/>
|
|
43
|
+
<div className="flex items-start justify-between">
|
|
44
|
+
<div className="font-mono text-[10px] uppercase tracking-[0.08em] text-ash-dim">
|
|
45
|
+
{label}
|
|
46
|
+
</div>
|
|
47
|
+
{icon ? <div className="text-ash-dim">{icon}</div> : null}
|
|
48
|
+
</div>
|
|
49
|
+
<div className="font-mono text-[28px] text-bone mt-2">{value}</div>
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import "@testing-library/jest-dom";
|
|
4
|
+
import { Table } from "./Table";
|
|
5
|
+
|
|
6
|
+
describe("Table", () => {
|
|
7
|
+
it("renders header cells and body cells", () => {
|
|
8
|
+
render(
|
|
9
|
+
<Table data-testid="tbl">
|
|
10
|
+
<Table.Header>
|
|
11
|
+
<Table.Row>
|
|
12
|
+
<Table.HeaderCell>Name</Table.HeaderCell>
|
|
13
|
+
<Table.HeaderCell>Env</Table.HeaderCell>
|
|
14
|
+
</Table.Row>
|
|
15
|
+
</Table.Header>
|
|
16
|
+
<tbody>
|
|
17
|
+
<Table.Row>
|
|
18
|
+
<Table.Cell>auth</Table.Cell>
|
|
19
|
+
<Table.Cell>prod</Table.Cell>
|
|
20
|
+
</Table.Row>
|
|
21
|
+
</tbody>
|
|
22
|
+
</Table>,
|
|
23
|
+
);
|
|
24
|
+
expect(screen.getByTestId("tbl")).toBeInTheDocument();
|
|
25
|
+
expect(screen.getByText("Name")).toBeInTheDocument();
|
|
26
|
+
expect(screen.getByText("auth")).toBeInTheDocument();
|
|
27
|
+
expect(screen.getByText("prod")).toBeInTheDocument();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("applies interactive classes to rows when interactive prop set", () => {
|
|
31
|
+
render(
|
|
32
|
+
<Table>
|
|
33
|
+
<tbody>
|
|
34
|
+
<Table.Row interactive data-testid="row">
|
|
35
|
+
<Table.Cell>x</Table.Cell>
|
|
36
|
+
</Table.Row>
|
|
37
|
+
</tbody>
|
|
38
|
+
</Table>,
|
|
39
|
+
);
|
|
40
|
+
const row = screen.getByTestId("row");
|
|
41
|
+
expect(row.className).toContain("hover:bg-ink-800");
|
|
42
|
+
expect(row.className).toContain("cursor-pointer");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("applies drift tone shadow class", () => {
|
|
46
|
+
render(
|
|
47
|
+
<Table>
|
|
48
|
+
<tbody>
|
|
49
|
+
<Table.Row tone="drift" data-testid="row">
|
|
50
|
+
<Table.Cell>drifted</Table.Cell>
|
|
51
|
+
</Table.Row>
|
|
52
|
+
</tbody>
|
|
53
|
+
</Table>,
|
|
54
|
+
);
|
|
55
|
+
const row = screen.getByTestId("row");
|
|
56
|
+
expect(row.className).toContain("shadow-[inset_4px_0_0_0_var(--color-stop-500)]");
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
function joinClasses(...parts: Array<string | false | null | undefined>): string {
|
|
4
|
+
return parts.filter(Boolean).join(" ");
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface TableProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
8
|
+
className?: string;
|
|
9
|
+
children?: React.ReactNode;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const TableRoot = React.forwardRef<HTMLDivElement, TableProps>(function Table(
|
|
13
|
+
{ className, children, ...rest },
|
|
14
|
+
ref,
|
|
15
|
+
) {
|
|
16
|
+
return (
|
|
17
|
+
<div
|
|
18
|
+
ref={ref}
|
|
19
|
+
className={joinClasses(
|
|
20
|
+
"bg-ink-850 border border-edge rounded-card overflow-hidden",
|
|
21
|
+
className,
|
|
22
|
+
)}
|
|
23
|
+
{...rest}
|
|
24
|
+
>
|
|
25
|
+
<table className="w-full border-collapse">{children}</table>
|
|
26
|
+
</div>
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export interface TableHeaderProps extends React.HTMLAttributes<HTMLTableSectionElement> {
|
|
31
|
+
className?: string;
|
|
32
|
+
children?: React.ReactNode;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function TableHeader({ className, children, ...rest }: TableHeaderProps) {
|
|
36
|
+
return (
|
|
37
|
+
<thead className={joinClasses("bg-ink-800 border-b border-edge", className)} {...rest}>
|
|
38
|
+
{children}
|
|
39
|
+
</thead>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface TableHeaderCellProps extends React.ThHTMLAttributes<HTMLTableCellElement> {
|
|
44
|
+
className?: string;
|
|
45
|
+
children?: React.ReactNode;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function TableHeaderCell({ className, children, ...rest }: TableHeaderCellProps) {
|
|
49
|
+
return (
|
|
50
|
+
<th
|
|
51
|
+
className={joinClasses(
|
|
52
|
+
"px-5 py-3 font-sans text-[11px] font-semibold text-ash-dim uppercase tracking-[0.08em] text-left",
|
|
53
|
+
className,
|
|
54
|
+
)}
|
|
55
|
+
{...rest}
|
|
56
|
+
>
|
|
57
|
+
{children}
|
|
58
|
+
</th>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface TableRowProps extends React.HTMLAttributes<HTMLTableRowElement> {
|
|
63
|
+
interactive?: boolean;
|
|
64
|
+
tone?: "drift";
|
|
65
|
+
className?: string;
|
|
66
|
+
children?: React.ReactNode;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function TableRow({ interactive, tone, className, children, ...rest }: TableRowProps) {
|
|
70
|
+
const interactiveClasses = interactive ? "hover:bg-ink-800 cursor-pointer transition-colors" : "";
|
|
71
|
+
const toneClasses = tone === "drift" ? "shadow-[inset_4px_0_0_0_var(--color-stop-500)]" : "";
|
|
72
|
+
return (
|
|
73
|
+
<tr
|
|
74
|
+
className={joinClasses(
|
|
75
|
+
"border-b border-edge last:border-0",
|
|
76
|
+
interactiveClasses,
|
|
77
|
+
toneClasses,
|
|
78
|
+
className,
|
|
79
|
+
)}
|
|
80
|
+
{...rest}
|
|
81
|
+
>
|
|
82
|
+
{children}
|
|
83
|
+
</tr>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface TableCellProps extends React.TdHTMLAttributes<HTMLTableCellElement> {
|
|
88
|
+
className?: string;
|
|
89
|
+
children?: React.ReactNode;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function TableCell({ className, children, ...rest }: TableCellProps) {
|
|
93
|
+
return (
|
|
94
|
+
<td className={joinClasses("px-5 py-3 font-sans text-[12px] text-bone", className)} {...rest}>
|
|
95
|
+
{children}
|
|
96
|
+
</td>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
type TableCompound = typeof TableRoot & {
|
|
101
|
+
Header: typeof TableHeader;
|
|
102
|
+
Row: typeof TableRow;
|
|
103
|
+
HeaderCell: typeof TableHeaderCell;
|
|
104
|
+
Cell: typeof TableCell;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const Table = TableRoot as TableCompound;
|
|
108
|
+
Table.Header = TableHeader;
|
|
109
|
+
Table.Row = TableRow;
|
|
110
|
+
Table.HeaderCell = TableHeaderCell;
|
|
111
|
+
Table.Cell = TableCell;
|
|
112
|
+
|
|
113
|
+
export { Table };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { render, screen, fireEvent } from "@testing-library/react";
|
|
3
|
+
import "@testing-library/jest-dom";
|
|
4
|
+
import { Tabs } from "./Tabs";
|
|
5
|
+
|
|
6
|
+
function Harness({ initial = "a" }: { initial?: string }) {
|
|
7
|
+
const [v, setV] = useState(initial);
|
|
8
|
+
return (
|
|
9
|
+
<Tabs value={v} onChange={setV}>
|
|
10
|
+
<Tabs.List>
|
|
11
|
+
<Tabs.Tab value="a">A</Tabs.Tab>
|
|
12
|
+
<Tabs.Tab value="b">B</Tabs.Tab>
|
|
13
|
+
</Tabs.List>
|
|
14
|
+
<Tabs.Panel value="a">panel-a-content</Tabs.Panel>
|
|
15
|
+
<Tabs.Panel value="b">panel-b-content</Tabs.Panel>
|
|
16
|
+
</Tabs>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("Tabs", () => {
|
|
21
|
+
it("shows active panel and active tab class", () => {
|
|
22
|
+
render(<Harness initial="a" />);
|
|
23
|
+
expect(screen.getByText("panel-a-content")).toBeInTheDocument();
|
|
24
|
+
expect(screen.queryByText("panel-b-content")).not.toBeInTheDocument();
|
|
25
|
+
const tabA = screen.getByRole("tab", { name: "A" });
|
|
26
|
+
expect(tabA.className).toContain("text-gold-500");
|
|
27
|
+
expect(tabA).toHaveAttribute("aria-selected", "true");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("switches panels when a tab is clicked", () => {
|
|
31
|
+
render(<Harness initial="a" />);
|
|
32
|
+
fireEvent.click(screen.getByRole("tab", { name: "B" }));
|
|
33
|
+
expect(screen.getByText("panel-b-content")).toBeInTheDocument();
|
|
34
|
+
expect(screen.queryByText("panel-a-content")).not.toBeInTheDocument();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("inactive tab uses non-active classes", () => {
|
|
38
|
+
render(<Harness initial="a" />);
|
|
39
|
+
const tabB = screen.getByRole("tab", { name: "B" });
|
|
40
|
+
expect(tabB.className).toContain("text-ash");
|
|
41
|
+
expect(tabB.className).toContain("border-transparent");
|
|
42
|
+
expect(tabB).toHaveAttribute("aria-selected", "false");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import React, { createContext, useContext } from "react";
|
|
2
|
+
|
|
3
|
+
interface TabsContextValue {
|
|
4
|
+
value: string;
|
|
5
|
+
onChange: (v: string) => void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const TabsContext = createContext<TabsContextValue | null>(null);
|
|
9
|
+
|
|
10
|
+
function useTabsContext(componentName: string): TabsContextValue {
|
|
11
|
+
const ctx = useContext(TabsContext);
|
|
12
|
+
if (!ctx) {
|
|
13
|
+
throw new Error(`${componentName} must be used inside <Tabs>`);
|
|
14
|
+
}
|
|
15
|
+
return ctx;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface TabsProps {
|
|
19
|
+
value: string;
|
|
20
|
+
onChange: (v: string) => void;
|
|
21
|
+
children?: React.ReactNode;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function TabsRoot({ value, onChange, children }: TabsProps) {
|
|
25
|
+
return <TabsContext.Provider value={{ value, onChange }}>{children}</TabsContext.Provider>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface TabsListProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
29
|
+
children?: React.ReactNode;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function TabsList({ children, className, ...rest }: TabsListProps) {
|
|
33
|
+
return (
|
|
34
|
+
<div
|
|
35
|
+
role="tablist"
|
|
36
|
+
className={["flex gap-1 border-b border-edge", className].filter(Boolean).join(" ")}
|
|
37
|
+
{...rest}
|
|
38
|
+
>
|
|
39
|
+
{children}
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface TabsTabProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "value"> {
|
|
45
|
+
value: string;
|
|
46
|
+
children?: React.ReactNode;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function TabsTab({ value, children, className, onClick, ...rest }: TabsTabProps) {
|
|
50
|
+
const ctx = useTabsContext("Tabs.Tab");
|
|
51
|
+
const active = ctx.value === value;
|
|
52
|
+
const stateClasses = active
|
|
53
|
+
? "text-gold-500 border-b-2 border-gold-500 -mb-px"
|
|
54
|
+
: "text-ash hover:text-bone border-b-2 border-transparent";
|
|
55
|
+
return (
|
|
56
|
+
<button
|
|
57
|
+
type="button"
|
|
58
|
+
role="tab"
|
|
59
|
+
aria-selected={active}
|
|
60
|
+
className={["px-4 py-2 font-sans text-[12px] font-medium", stateClasses, className]
|
|
61
|
+
.filter(Boolean)
|
|
62
|
+
.join(" ")}
|
|
63
|
+
onClick={(e) => {
|
|
64
|
+
ctx.onChange(value);
|
|
65
|
+
if (onClick) onClick(e);
|
|
66
|
+
}}
|
|
67
|
+
{...rest}
|
|
68
|
+
>
|
|
69
|
+
{children}
|
|
70
|
+
</button>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface TabsPanelProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
75
|
+
value: string;
|
|
76
|
+
children?: React.ReactNode;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function TabsPanel({ value, children, ...rest }: TabsPanelProps) {
|
|
80
|
+
const ctx = useTabsContext("Tabs.Panel");
|
|
81
|
+
if (ctx.value !== value) return null;
|
|
82
|
+
return (
|
|
83
|
+
<div role="tabpanel" {...rest}>
|
|
84
|
+
{children}
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
type TabsCompound = typeof TabsRoot & {
|
|
90
|
+
List: typeof TabsList;
|
|
91
|
+
Tab: typeof TabsTab;
|
|
92
|
+
Panel: typeof TabsPanel;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const Tabs = TabsRoot as TabsCompound;
|
|
96
|
+
Tabs.List = TabsList;
|
|
97
|
+
Tabs.Tab = TabsTab;
|
|
98
|
+
Tabs.Panel = TabsPanel;
|
|
99
|
+
|
|
100
|
+
export { Tabs };
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render, screen, fireEvent, act } from "@testing-library/react";
|
|
3
|
+
import "@testing-library/jest-dom";
|
|
4
|
+
import { ToastProvider, useToast } from "./Toast";
|
|
5
|
+
|
|
6
|
+
function Trigger({
|
|
7
|
+
message,
|
|
8
|
+
tone,
|
|
9
|
+
}: {
|
|
10
|
+
message: string;
|
|
11
|
+
tone?: "default" | "go" | "warn" | "stop";
|
|
12
|
+
}) {
|
|
13
|
+
const { show } = useToast();
|
|
14
|
+
return <button onClick={() => show(message, tone ? { tone } : undefined)}>fire</button>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("Toast", () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
jest.useFakeTimers();
|
|
20
|
+
});
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
jest.useRealTimers();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("shows a toast with default border when fired", () => {
|
|
26
|
+
render(
|
|
27
|
+
<ToastProvider>
|
|
28
|
+
<Trigger message="hello" />
|
|
29
|
+
</ToastProvider>,
|
|
30
|
+
);
|
|
31
|
+
act(() => {
|
|
32
|
+
fireEvent.click(screen.getByText("fire"));
|
|
33
|
+
});
|
|
34
|
+
const toast = screen.getByTestId("toast");
|
|
35
|
+
expect(toast).toHaveTextContent("hello");
|
|
36
|
+
expect(toast.className).toContain("border-edge");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("applies tone border class for warn", () => {
|
|
40
|
+
render(
|
|
41
|
+
<ToastProvider>
|
|
42
|
+
<Trigger message="careful" tone="warn" />
|
|
43
|
+
</ToastProvider>,
|
|
44
|
+
);
|
|
45
|
+
act(() => {
|
|
46
|
+
fireEvent.click(screen.getByText("fire"));
|
|
47
|
+
});
|
|
48
|
+
expect(screen.getByTestId("toast").className).toContain("border-warn-500/40");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("auto-dismisses after default 3000ms", () => {
|
|
52
|
+
render(
|
|
53
|
+
<ToastProvider>
|
|
54
|
+
<Trigger message="bye" />
|
|
55
|
+
</ToastProvider>,
|
|
56
|
+
);
|
|
57
|
+
act(() => {
|
|
58
|
+
fireEvent.click(screen.getByText("fire"));
|
|
59
|
+
});
|
|
60
|
+
expect(screen.getByTestId("toast")).toBeInTheDocument();
|
|
61
|
+
act(() => {
|
|
62
|
+
jest.advanceTimersByTime(3000);
|
|
63
|
+
});
|
|
64
|
+
expect(screen.queryByTestId("toast")).not.toBeInTheDocument();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("throws helpful error when useToast called outside provider", () => {
|
|
68
|
+
function Bad() {
|
|
69
|
+
useToast();
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
// suppress React error boundary console
|
|
73
|
+
const spy = jest.spyOn(console, "error").mockImplementation(() => {});
|
|
74
|
+
expect(() => render(<Bad />)).toThrow(/ToastProvider/);
|
|
75
|
+
spy.mockRestore();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import React, { createContext, useCallback, useContext, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
export type ToastTone = "default" | "go" | "warn" | "stop";
|
|
4
|
+
|
|
5
|
+
export interface ToastOptions {
|
|
6
|
+
tone?: ToastTone;
|
|
7
|
+
durationMs?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface ToastItem {
|
|
11
|
+
id: number;
|
|
12
|
+
message: string;
|
|
13
|
+
tone: ToastTone;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface ToastContextValue {
|
|
17
|
+
show: (message: string, opts?: ToastOptions) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const ToastContext = createContext<ToastContextValue | null>(null);
|
|
21
|
+
|
|
22
|
+
function toneBorderClass(tone: ToastTone): string {
|
|
23
|
+
switch (tone) {
|
|
24
|
+
case "go":
|
|
25
|
+
return "border-go-500/40";
|
|
26
|
+
case "warn":
|
|
27
|
+
return "border-warn-500/40";
|
|
28
|
+
case "stop":
|
|
29
|
+
return "border-stop-500/40";
|
|
30
|
+
default:
|
|
31
|
+
return "border-edge";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ToastProviderProps {
|
|
36
|
+
children?: React.ReactNode;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function ToastProvider({ children }: ToastProviderProps) {
|
|
40
|
+
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
|
41
|
+
const idRef = useRef(0);
|
|
42
|
+
|
|
43
|
+
const dismiss = useCallback((id: number) => {
|
|
44
|
+
setToasts((list) => list.filter((t) => t.id !== id));
|
|
45
|
+
}, []);
|
|
46
|
+
|
|
47
|
+
const show = useCallback(
|
|
48
|
+
(message: string, opts?: ToastOptions) => {
|
|
49
|
+
const id = ++idRef.current;
|
|
50
|
+
const tone: ToastTone = opts?.tone ?? "default";
|
|
51
|
+
const durationMs = opts?.durationMs ?? 3000;
|
|
52
|
+
setToasts((list) => [...list, { id, message, tone }]);
|
|
53
|
+
setTimeout(() => dismiss(id), durationMs);
|
|
54
|
+
},
|
|
55
|
+
[dismiss],
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<ToastContext.Provider value={{ show }}>
|
|
60
|
+
{children}
|
|
61
|
+
<div
|
|
62
|
+
data-testid="toast-container"
|
|
63
|
+
className="fixed bottom-5 right-5 z-50 flex flex-col gap-2 pointer-events-none"
|
|
64
|
+
>
|
|
65
|
+
{toasts.map((t) => (
|
|
66
|
+
<div
|
|
67
|
+
key={t.id}
|
|
68
|
+
role="status"
|
|
69
|
+
data-testid="toast"
|
|
70
|
+
className={[
|
|
71
|
+
"flex items-center gap-2 bg-ink-850 border rounded-md px-3 py-2 shadow-soft-drop font-sans text-[12px] pointer-events-auto",
|
|
72
|
+
toneBorderClass(t.tone),
|
|
73
|
+
].join(" ")}
|
|
74
|
+
>
|
|
75
|
+
{t.message}
|
|
76
|
+
</div>
|
|
77
|
+
))}
|
|
78
|
+
</div>
|
|
79
|
+
</ToastContext.Provider>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function useToast(): ToastContextValue {
|
|
84
|
+
const ctx = useContext(ToastContext);
|
|
85
|
+
if (!ctx) {
|
|
86
|
+
throw new Error("useToast must be used inside <ToastProvider>");
|
|
87
|
+
}
|
|
88
|
+
return ctx;
|
|
89
|
+
}
|