@umituz/web-design-system 6.0.2 → 6.1.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/dist/styles.css +1 -1
- package/package.json +2 -1
- package/src/__tests__/charts/ActivityHeatmap.test.tsx +48 -0
- package/src/__tests__/charts/BarChart.test.tsx +91 -0
- package/src/__tests__/charts/ChartPrimitives.test.tsx +90 -0
- package/src/__tests__/charts/Donut.test.tsx +51 -0
- package/src/__tests__/charts/LineChart.test.tsx +81 -0
- package/src/__tests__/layout-components.test.tsx +100 -0
- package/src/presentation/molecules/TabNav.tsx +46 -0
- package/src/presentation/molecules/index.ts +3 -0
- package/src/presentation/organisms/AuditLogList.tsx +58 -0
- package/src/presentation/organisms/charts/ActivityHeatmap.tsx +58 -0
- package/src/presentation/organisms/charts/BarChart.tsx +143 -0
- package/src/presentation/organisms/charts/ChartCard.tsx +39 -0
- package/src/presentation/organisms/charts/ChartTip.tsx +18 -0
- package/src/presentation/organisms/charts/Donut.tsx +100 -0
- package/src/presentation/organisms/charts/HBars.tsx +31 -0
- package/src/presentation/organisms/charts/Legend.tsx +21 -0
- package/src/presentation/organisms/charts/LineChart.tsx +244 -0
- package/src/presentation/organisms/charts/Sparkline.tsx +35 -0
- package/src/presentation/organisms/charts/index.ts +17 -0
- package/src/presentation/organisms/charts/utils.ts +57 -0
- package/src/presentation/organisms/index.ts +6 -0
- package/src/presentation/templates/PageHeader.tsx +103 -33
- package/src/presentation/templates/SectionHeader.tsx +54 -0
- package/src/presentation/templates/index.ts +4 -1
- package/src/styles/charts.css +455 -0
- package/src/styles/compile.css +1 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { render } from "@testing-library/react";
|
|
3
|
+
import { ChartCard } from "../../presentation/organisms/charts/ChartCard";
|
|
4
|
+
import { Legend } from "../../presentation/organisms/charts/Legend";
|
|
5
|
+
import { HBars } from "../../presentation/organisms/charts/HBars";
|
|
6
|
+
import { Sparkline } from "../../presentation/organisms/charts/Sparkline";
|
|
7
|
+
import type { TipState } from "../../presentation/organisms/charts/utils";
|
|
8
|
+
|
|
9
|
+
describe("ChartCard", () => {
|
|
10
|
+
const tip: TipState = { x: 10, y: 20, title: "bugün", rows: [{ label: "A", value: "7", color: "#111111" }] };
|
|
11
|
+
|
|
12
|
+
it("renders the heading, tools, body and tooltip", () => {
|
|
13
|
+
const { container } = render(
|
|
14
|
+
<ChartCard
|
|
15
|
+
title="Gelir"
|
|
16
|
+
subtitle="son 30 gün"
|
|
17
|
+
legend={<Legend items={[{ label: "A", color: "#111111", value: "7" }]} />}
|
|
18
|
+
actions={<button type="button">act</button>}
|
|
19
|
+
tip={tip}
|
|
20
|
+
>
|
|
21
|
+
<div>plot</div>
|
|
22
|
+
</ChartCard>,
|
|
23
|
+
);
|
|
24
|
+
expect(container.querySelector(".udb-chartcard__title")!.textContent).toBe("Gelir");
|
|
25
|
+
expect(container.querySelector(".udb-chartcard__sub")!.textContent).toBe("son 30 gün");
|
|
26
|
+
expect(container.querySelector(".udb-chartlegend__value")!.textContent).toBe("7");
|
|
27
|
+
expect(container.querySelector(".udb-chartcard__tools button")).not.toBeNull();
|
|
28
|
+
expect(container.querySelector(".udb-charttip__title")!.textContent).toBe("bugün");
|
|
29
|
+
expect(container.querySelector(".udb-charttip")!.textContent).toContain("7");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("omits the tooltip when tip is null and zeroes body padding when bare", () => {
|
|
33
|
+
const { container } = render(
|
|
34
|
+
<ChartCard title="T" tip={null} bare>
|
|
35
|
+
<div>x</div>
|
|
36
|
+
</ChartCard>,
|
|
37
|
+
);
|
|
38
|
+
expect(container.querySelector(".udb-charttip")).toBeNull();
|
|
39
|
+
expect(container.querySelector<HTMLElement>(".udb-chartcard__body")!.style.padding).toBe("0px");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("Legend", () => {
|
|
44
|
+
it("renders items with optional value and hint", () => {
|
|
45
|
+
const { container } = render(
|
|
46
|
+
<Legend
|
|
47
|
+
items={[
|
|
48
|
+
{ label: "A", color: "#111111", value: "3" },
|
|
49
|
+
{ label: "B", color: "#222222", hint: "tahmini" },
|
|
50
|
+
]}
|
|
51
|
+
/>,
|
|
52
|
+
);
|
|
53
|
+
const items = container.querySelectorAll(".udb-chartlegend__item");
|
|
54
|
+
expect(items).toHaveLength(2);
|
|
55
|
+
expect(items[0]!.textContent).toContain("3");
|
|
56
|
+
expect(items[1]!.textContent).toContain("tahmini");
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("HBars", () => {
|
|
61
|
+
it("scales fill widths against the largest row and applies tones", () => {
|
|
62
|
+
const { container } = render(
|
|
63
|
+
<HBars
|
|
64
|
+
rows={[
|
|
65
|
+
{ label: "A", value: 5 },
|
|
66
|
+
{ label: "B", value: 10, tone: "#00ff00" },
|
|
67
|
+
]}
|
|
68
|
+
formatValue={(n) => `${n}₺`}
|
|
69
|
+
/>,
|
|
70
|
+
);
|
|
71
|
+
const fills = [...container.querySelectorAll(".udb-hbars__fill")] as HTMLElement[];
|
|
72
|
+
expect(fills.map((f) => f.style.width)).toEqual(["50%", "100%"]);
|
|
73
|
+
expect(fills[1]!.style.background).toBe("rgb(0, 255, 0)");
|
|
74
|
+
expect(container.querySelector(".udb-hbars__value")!.textContent).toBe("5₺");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("Sparkline", () => {
|
|
79
|
+
it("renders a gradient area, line and end dot", () => {
|
|
80
|
+
const { container } = render(<Sparkline data={[1, 5, 3]} />);
|
|
81
|
+
expect(container.querySelector("linearGradient")).not.toBeNull();
|
|
82
|
+
expect(container.querySelector("circle")).not.toBeNull();
|
|
83
|
+
expect(container.querySelectorAll("path")).toHaveLength(2);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("renders nothing for fewer than two points", () => {
|
|
87
|
+
const { container } = render(<Sparkline data={[1]} />);
|
|
88
|
+
expect(container.firstChild).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { fireEvent, render } from "@testing-library/react";
|
|
3
|
+
import { Donut } from "../../presentation/organisms/charts/Donut";
|
|
4
|
+
|
|
5
|
+
describe("Donut", () => {
|
|
6
|
+
it("renders one segment per entry with computed percentages", () => {
|
|
7
|
+
const { container } = render(
|
|
8
|
+
<Donut
|
|
9
|
+
segments={[
|
|
10
|
+
{ label: "A", value: 3 },
|
|
11
|
+
{ label: "B", value: 1 },
|
|
12
|
+
]}
|
|
13
|
+
centerValue="4"
|
|
14
|
+
centerLabel="total"
|
|
15
|
+
/>,
|
|
16
|
+
);
|
|
17
|
+
expect(container.querySelectorAll(".udb-donut__seg")).toHaveLength(2);
|
|
18
|
+
expect(container.querySelectorAll(".udb-donut__item")).toHaveLength(2);
|
|
19
|
+
expect(container.querySelector(".udb-donut__pct")!.textContent).toBe("75%");
|
|
20
|
+
expect(container.querySelector(".udb-donut__value")!.textContent).toBe("4");
|
|
21
|
+
expect(container.querySelector(".udb-donut__label")!.textContent).toBe("total");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("swaps the center readout to the hovered segment", () => {
|
|
25
|
+
const { container } = render(
|
|
26
|
+
<Donut
|
|
27
|
+
segments={[
|
|
28
|
+
{ label: "A", value: 3 },
|
|
29
|
+
{ label: "B", value: 1 },
|
|
30
|
+
]}
|
|
31
|
+
centerValue="4"
|
|
32
|
+
/>,
|
|
33
|
+
);
|
|
34
|
+
fireEvent.mouseEnter(container.querySelectorAll(".udb-donut__seg")[1]!);
|
|
35
|
+
expect(container.querySelector(".udb-donut__value")!.textContent).toBe("1");
|
|
36
|
+
expect(container.querySelector(".udb-donut__label")!.textContent).toBe("B");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("colors segments through the --udb-chart tokens by default", () => {
|
|
40
|
+
const { container } = render(<Donut segments={[{ label: "A", value: 1 }]} />);
|
|
41
|
+
const seg = container.querySelector(".udb-donut__seg")!;
|
|
42
|
+
expect(seg.getAttribute("stroke")).toContain("var(--udb-chart-1");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("lets an explicit tone win over the series tokens", () => {
|
|
46
|
+
const { container } = render(
|
|
47
|
+
<Donut segments={[{ label: "A", value: 1, tone: "#ff0000" }]} />,
|
|
48
|
+
);
|
|
49
|
+
expect(container.querySelector(".udb-donut__seg")!.getAttribute("stroke")).toBe("#ff0000");
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { fireEvent, render } from "@testing-library/react";
|
|
3
|
+
import { LineChart } from "../../presentation/organisms/charts/LineChart";
|
|
4
|
+
|
|
5
|
+
const labels = ["1", "2", "3", "4"];
|
|
6
|
+
|
|
7
|
+
describe("LineChart", () => {
|
|
8
|
+
it("renders nothing without points", () => {
|
|
9
|
+
const { container } = render(<LineChart labels={[]} series={[]} />);
|
|
10
|
+
expect(container.firstChild).toBeNull();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("renders a flat area by default", () => {
|
|
14
|
+
const { container } = render(
|
|
15
|
+
<LineChart labels={labels} series={[{ label: "A", values: [1, 2, 3, 4] }]} />,
|
|
16
|
+
);
|
|
17
|
+
const paths = container.querySelectorAll("path");
|
|
18
|
+
expect(paths).toHaveLength(2);
|
|
19
|
+
expect(paths[0]!.getAttribute("opacity")).toBe("0.07");
|
|
20
|
+
expect(container.querySelector("linearGradient")).toBeNull();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("paints the area with a per-series gradient when requested", () => {
|
|
24
|
+
const { container } = render(
|
|
25
|
+
<LineChart
|
|
26
|
+
labels={labels}
|
|
27
|
+
series={[{ label: "A", values: [1, 2, 3, 4], areaFill: "gradient" }]}
|
|
28
|
+
/>,
|
|
29
|
+
);
|
|
30
|
+
const gradient = container.querySelector("linearGradient");
|
|
31
|
+
expect(gradient).not.toBeNull();
|
|
32
|
+
expect(gradient!.querySelectorAll("stop")).toHaveLength(2);
|
|
33
|
+
const areaPath = [...container.querySelectorAll("path")].find((p) =>
|
|
34
|
+
p.getAttribute("fill")?.startsWith("url(#"),
|
|
35
|
+
);
|
|
36
|
+
expect(areaPath).toBeDefined();
|
|
37
|
+
expect(areaPath!.getAttribute("opacity")).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("drops the area for dashed series", () => {
|
|
41
|
+
const { container } = render(
|
|
42
|
+
<LineChart labels={labels} series={[{ label: "A", values: [1, 2, 3, 4], dashed: true }]} />,
|
|
43
|
+
);
|
|
44
|
+
expect(container.querySelectorAll("path")).toHaveLength(1);
|
|
45
|
+
expect(container.querySelector("linearGradient")).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("renders the band and a crosshair with tooltip on hover", () => {
|
|
49
|
+
const { container } = render(
|
|
50
|
+
<LineChart
|
|
51
|
+
labels={labels}
|
|
52
|
+
band={{ from: 1, to: 2 }}
|
|
53
|
+
series={[{ label: "A", values: [1, 2, 3, 4] }]}
|
|
54
|
+
/>,
|
|
55
|
+
);
|
|
56
|
+
expect(container.querySelector(".udb-linechart__band")).not.toBeNull();
|
|
57
|
+
|
|
58
|
+
const svg = container.querySelector("svg")!;
|
|
59
|
+
fireEvent.mouseMove(svg, { clientX: 700, clientY: 50 });
|
|
60
|
+
expect(container.querySelector(".udb-linechart__crosshair")).not.toBeNull();
|
|
61
|
+
const tip = container.querySelector(".udb-linechart__tip");
|
|
62
|
+
expect(tip).not.toBeNull();
|
|
63
|
+
expect(tip!.textContent).toContain("A");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("renders the built-in legend from the series", () => {
|
|
67
|
+
const { container } = render(
|
|
68
|
+
<LineChart
|
|
69
|
+
labels={labels}
|
|
70
|
+
series={[
|
|
71
|
+
{ label: "A", values: [1, 2, 3, 4] },
|
|
72
|
+
{ label: "B", values: [4, 3, 2, 1], axis: "right" },
|
|
73
|
+
]}
|
|
74
|
+
/>,
|
|
75
|
+
);
|
|
76
|
+
const items = container.querySelectorAll(".udb-chartlegend__item");
|
|
77
|
+
expect(items).toHaveLength(2);
|
|
78
|
+
expect(items[0]!.textContent).toContain("A");
|
|
79
|
+
expect(items[1]!.textContent).toContain("B");
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import { MemoryRouter } from 'react-router';
|
|
4
|
+
import { SectionHeader } from '../presentation/templates/SectionHeader';
|
|
5
|
+
import { PageHeader } from '../presentation/templates/PageHeader';
|
|
6
|
+
import { TabNav } from '../presentation/molecules/TabNav';
|
|
7
|
+
import { AuditLogList } from '../presentation/organisms/AuditLogList';
|
|
8
|
+
|
|
9
|
+
describe('SectionHeader', () => {
|
|
10
|
+
it('renders kicker, title, description, and actions', () => {
|
|
11
|
+
render(
|
|
12
|
+
<SectionHeader
|
|
13
|
+
kicker="Overview"
|
|
14
|
+
title="Account Settings"
|
|
15
|
+
description="Manage your account preferences"
|
|
16
|
+
actions={<button type="button">Save</button>}
|
|
17
|
+
/>
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
expect(screen.getByText('Overview')).toBeDefined();
|
|
21
|
+
expect(screen.getByText('Account Settings')).toBeDefined();
|
|
22
|
+
expect(screen.getByText('Manage your account preferences')).toBeDefined();
|
|
23
|
+
expect(screen.getByRole('button', { name: 'Save' })).toBeDefined();
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('PageHeader enhanced', () => {
|
|
28
|
+
it('renders breadcrumbs, kicker, title, and actions', () => {
|
|
29
|
+
render(
|
|
30
|
+
<MemoryRouter>
|
|
31
|
+
<PageHeader
|
|
32
|
+
breadcrumbs={[
|
|
33
|
+
{ label: 'Home', to: '/' },
|
|
34
|
+
{ label: 'Settings', to: '/settings' },
|
|
35
|
+
{ label: 'Security' },
|
|
36
|
+
]}
|
|
37
|
+
kicker="Security Panel"
|
|
38
|
+
title="Two-Factor Auth"
|
|
39
|
+
description="Protect your workspace"
|
|
40
|
+
actions={<button type="button">Enable</button>}
|
|
41
|
+
/>
|
|
42
|
+
</MemoryRouter>
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
expect(screen.getByText('Home')).toBeDefined();
|
|
46
|
+
expect(screen.getByText('Settings')).toBeDefined();
|
|
47
|
+
expect(screen.getByText('Security')).toBeDefined();
|
|
48
|
+
expect(screen.getByText('Security Panel')).toBeDefined();
|
|
49
|
+
expect(screen.getByText('Two-Factor Auth')).toBeDefined();
|
|
50
|
+
expect(screen.getByText('Protect your workspace')).toBeDefined();
|
|
51
|
+
expect(screen.getByRole('button', { name: 'Enable' })).toBeDefined();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('TabNav', () => {
|
|
56
|
+
it('renders router links with active status', () => {
|
|
57
|
+
render(
|
|
58
|
+
<MemoryRouter initialEntries={['/profile']}>
|
|
59
|
+
<TabNav
|
|
60
|
+
items={[
|
|
61
|
+
{ label: 'General', to: '/general' },
|
|
62
|
+
{ label: 'Profile', to: '/profile' },
|
|
63
|
+
{ label: 'Billing', to: '/billing' },
|
|
64
|
+
]}
|
|
65
|
+
/>
|
|
66
|
+
</MemoryRouter>
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const profileLink = screen.getByText('Profile');
|
|
70
|
+
expect(profileLink).toBeDefined();
|
|
71
|
+
expect(profileLink.closest('a')?.getAttribute('href')).toBe('/profile');
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('AuditLogList', () => {
|
|
76
|
+
it('renders empty state when no entries', () => {
|
|
77
|
+
render(<AuditLogList entries={[]} emptyLabel="No activity recorded" />);
|
|
78
|
+
expect(screen.getByText('No activity recorded')).toBeDefined();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('renders list of entries with actor, action, and target', () => {
|
|
82
|
+
render(
|
|
83
|
+
<AuditLogList
|
|
84
|
+
entries={[
|
|
85
|
+
{
|
|
86
|
+
id: '1',
|
|
87
|
+
actor: 'alice@example.com',
|
|
88
|
+
action: 'updated role',
|
|
89
|
+
target: 'bob@example.com',
|
|
90
|
+
at: '2026-11-06T12:00:00Z',
|
|
91
|
+
},
|
|
92
|
+
]}
|
|
93
|
+
/>
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
expect(screen.getByText('alice@example.com')).toBeDefined();
|
|
97
|
+
expect(screen.getByText('updated role')).toBeDefined();
|
|
98
|
+
expect(screen.getByText('bob@example.com')).toBeDefined();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { NavLink } from 'react-router';
|
|
3
|
+
import { cn } from '../../infrastructure/utils';
|
|
4
|
+
import type { BaseProps } from '../../domain/types';
|
|
5
|
+
|
|
6
|
+
export interface TabNavItem {
|
|
7
|
+
to: string;
|
|
8
|
+
label: string;
|
|
9
|
+
end?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface TabNavProps extends BaseProps {
|
|
13
|
+
items: readonly TabNavItem[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const TabNav: React.FC<TabNavProps> = ({ items, className }) => {
|
|
17
|
+
return (
|
|
18
|
+
<nav
|
|
19
|
+
className={cn(
|
|
20
|
+
'flex items-center gap-1 border-b border-border overflow-x-auto py-1',
|
|
21
|
+
className
|
|
22
|
+
)}
|
|
23
|
+
aria-label="Section tabs"
|
|
24
|
+
>
|
|
25
|
+
{items.map((item) => (
|
|
26
|
+
<NavLink
|
|
27
|
+
key={item.to}
|
|
28
|
+
to={item.to}
|
|
29
|
+
end={item.end}
|
|
30
|
+
className={({ isActive }) =>
|
|
31
|
+
cn(
|
|
32
|
+
'px-3.5 py-1.5 text-xs font-semibold rounded-lg transition-colors whitespace-nowrap',
|
|
33
|
+
isActive
|
|
34
|
+
? 'bg-primary/10 text-primary font-bold'
|
|
35
|
+
: 'text-muted-foreground hover:text-foreground hover:bg-muted/60'
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
>
|
|
39
|
+
{item.label}
|
|
40
|
+
</NavLink>
|
|
41
|
+
))}
|
|
42
|
+
</nav>
|
|
43
|
+
);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
TabNav.displayName = 'TabNav';
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { ScrollText } from 'lucide-react';
|
|
3
|
+
import { cn } from '../../infrastructure/utils';
|
|
4
|
+
import type { BaseProps } from '../../domain/types';
|
|
5
|
+
|
|
6
|
+
export interface AuditEntry {
|
|
7
|
+
id: string;
|
|
8
|
+
actor: string;
|
|
9
|
+
action: string;
|
|
10
|
+
target?: string;
|
|
11
|
+
at: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface AuditLogListProps extends BaseProps {
|
|
15
|
+
entries: AuditEntry[];
|
|
16
|
+
emptyLabel?: string;
|
|
17
|
+
formatAt?: (iso: string) => string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const defaultFormatAt = (iso: string): string => {
|
|
21
|
+
const date = new Date(iso);
|
|
22
|
+
return Number.isNaN(date.getTime()) ? iso : date.toISOString().slice(0, 16).replace('T', ' ');
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const AuditLogList: React.FC<AuditLogListProps> = ({
|
|
26
|
+
entries,
|
|
27
|
+
emptyLabel = 'Henüz denetim kaydı bulunmuyor.',
|
|
28
|
+
formatAt = defaultFormatAt,
|
|
29
|
+
className,
|
|
30
|
+
}) => {
|
|
31
|
+
if (entries.length === 0) {
|
|
32
|
+
return (
|
|
33
|
+
<div className={cn('flex flex-col items-center justify-center p-8 text-center text-muted-foreground', className)}>
|
|
34
|
+
<ScrollText className="size-8 opacity-40 mb-2" aria-hidden="true" />
|
|
35
|
+
<p className="text-xs sm:text-sm">{emptyLabel}</p>
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<ul className={cn('divide-y divide-border border border-border rounded-xl overflow-hidden bg-card text-xs', className)}>
|
|
42
|
+
{entries.map((entry) => (
|
|
43
|
+
<li key={entry.id} className="flex flex-wrap items-center gap-3 p-3 hover:bg-muted/40 transition-colors">
|
|
44
|
+
<span className="font-mono text-muted-foreground shrink-0">{formatAt(entry.at)}</span>
|
|
45
|
+
<span className="font-semibold text-foreground">{entry.actor}</span>
|
|
46
|
+
<span className="text-muted-foreground">{entry.action}</span>
|
|
47
|
+
{entry.target && (
|
|
48
|
+
<span className="font-medium text-primary bg-primary/10 px-1.5 py-0.5 rounded text-[11px]">
|
|
49
|
+
{entry.target}
|
|
50
|
+
</span>
|
|
51
|
+
)}
|
|
52
|
+
</li>
|
|
53
|
+
))}
|
|
54
|
+
</ul>
|
|
55
|
+
);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
AuditLogList.displayName = 'AuditLogList';
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export interface HeatmapCell {
|
|
2
|
+
day: string;
|
|
3
|
+
value: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface ActivityHeatmapProps {
|
|
7
|
+
cells: HeatmapCell[];
|
|
8
|
+
max?: number;
|
|
9
|
+
titleFor?: (cell: HeatmapCell) => string;
|
|
10
|
+
ariaLabel?: string;
|
|
11
|
+
className?: string;
|
|
12
|
+
levelClassNames?: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const DEFAULT_HEATMAP_LEVELS = [
|
|
16
|
+
'udb-heatmap__cell--l0',
|
|
17
|
+
'udb-heatmap__cell--l1',
|
|
18
|
+
'udb-heatmap__cell--l2',
|
|
19
|
+
'udb-heatmap__cell--l3',
|
|
20
|
+
'udb-heatmap__cell--l4',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export function ActivityHeatmap({
|
|
24
|
+
cells,
|
|
25
|
+
max,
|
|
26
|
+
titleFor,
|
|
27
|
+
ariaLabel = 'Activity heatmap',
|
|
28
|
+
className = '',
|
|
29
|
+
levelClassNames = DEFAULT_HEATMAP_LEVELS,
|
|
30
|
+
}: ActivityHeatmapProps) {
|
|
31
|
+
const peak = Math.max(1, max ?? Math.max(0, ...cells.map((cell) => cell.value)));
|
|
32
|
+
const maxIntensity = levelClassNames.length - 1;
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div
|
|
36
|
+
className={['udb-heatmap', className].filter(Boolean).join(' ')}
|
|
37
|
+
role="img"
|
|
38
|
+
aria-label={ariaLabel}
|
|
39
|
+
>
|
|
40
|
+
{cells.map((cell, index) => {
|
|
41
|
+
const intensity =
|
|
42
|
+
cell.value <= 0 ? 0 : Math.min(maxIntensity, Math.ceil((cell.value / peak) * maxIntensity));
|
|
43
|
+
const cellClass = levelClassNames[intensity] ?? levelClassNames[0];
|
|
44
|
+
const cellTitle = titleFor ? titleFor(cell) : `${cell.day}: ${cell.value}`;
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<span
|
|
48
|
+
key={`${cell.day}-${index}`}
|
|
49
|
+
className={`udb-heatmap__cell ${cellClass}`}
|
|
50
|
+
title={cellTitle}
|
|
51
|
+
>
|
|
52
|
+
<span className="udb-heatmap__sr">{cellTitle}</span>
|
|
53
|
+
</span>
|
|
54
|
+
);
|
|
55
|
+
})}
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { seriesColor } from './utils';
|
|
3
|
+
|
|
4
|
+
export interface BarSeries {
|
|
5
|
+
key?: string;
|
|
6
|
+
label: string;
|
|
7
|
+
values?: readonly number[];
|
|
8
|
+
data?: readonly number[];
|
|
9
|
+
tone?: string;
|
|
10
|
+
color?: string;
|
|
11
|
+
accents?: readonly (string | undefined)[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface BarChartGoalLine {
|
|
15
|
+
value: number;
|
|
16
|
+
label?: string;
|
|
17
|
+
tone?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface BarChartProps {
|
|
21
|
+
labels: readonly string[];
|
|
22
|
+
series: BarSeries[];
|
|
23
|
+
stacking?: 'grouped' | 'stacked';
|
|
24
|
+
totalLabel?: string;
|
|
25
|
+
height?: number;
|
|
26
|
+
formatValue?: (v: number) => string;
|
|
27
|
+
formatLabel?: (l: string) => string;
|
|
28
|
+
goalLine?: BarChartGoalLine;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function BarChart({
|
|
32
|
+
labels,
|
|
33
|
+
series,
|
|
34
|
+
stacking = 'grouped',
|
|
35
|
+
totalLabel = 'Σ',
|
|
36
|
+
height = 130,
|
|
37
|
+
formatValue = (v: number) => String(v),
|
|
38
|
+
formatLabel = (l: string) => l,
|
|
39
|
+
goalLine,
|
|
40
|
+
}: BarChartProps) {
|
|
41
|
+
const [hover, setHover] = useState<number | null>(null);
|
|
42
|
+
const vals = (s: BarSeries) => s.values ?? s.data ?? [];
|
|
43
|
+
const columnTotals = labels.map((_, i) => series.reduce((sum, s) => sum + (vals(s)[i] ?? 0), 0));
|
|
44
|
+
const max =
|
|
45
|
+
stacking === 'stacked'
|
|
46
|
+
? Math.max(1, ...columnTotals, goalLine?.value ?? 0)
|
|
47
|
+
: Math.max(1, ...series.flatMap((s) => vals(s)), goalLine?.value ?? 0);
|
|
48
|
+
const toneOf = (s: BarSeries, i: number) => s.tone ?? s.color ?? seriesColor(i);
|
|
49
|
+
const accentOf = (s: BarSeries, si: number, i: number) => s.accents?.[i] ?? toneOf(s, si);
|
|
50
|
+
const goalPct = goalLine !== undefined ? Math.min(100, (goalLine.value / max) * 100) : null;
|
|
51
|
+
return (
|
|
52
|
+
<div className="udb-barchart" style={{ height }}>
|
|
53
|
+
{goalLine !== undefined && goalPct !== null && (
|
|
54
|
+
<div
|
|
55
|
+
className="udb-barchart__goal"
|
|
56
|
+
style={{
|
|
57
|
+
bottom: `${goalPct}%`,
|
|
58
|
+
...(goalLine.tone !== undefined ? { borderTopColor: goalLine.tone } : {}),
|
|
59
|
+
}}
|
|
60
|
+
title={goalLine.label ?? formatValue(goalLine.value)}
|
|
61
|
+
aria-hidden="true"
|
|
62
|
+
>
|
|
63
|
+
{goalLine.label !== undefined && (
|
|
64
|
+
<span
|
|
65
|
+
className="udb-barchart__goallabel"
|
|
66
|
+
style={
|
|
67
|
+
goalLine.tone !== undefined
|
|
68
|
+
? { color: goalLine.tone, borderColor: goalLine.tone }
|
|
69
|
+
: undefined
|
|
70
|
+
}
|
|
71
|
+
>
|
|
72
|
+
{goalLine.label}
|
|
73
|
+
</span>
|
|
74
|
+
)}
|
|
75
|
+
</div>
|
|
76
|
+
)}
|
|
77
|
+
{labels.map((label, i) => (
|
|
78
|
+
<div
|
|
79
|
+
key={label}
|
|
80
|
+
className="udb-barchart__col"
|
|
81
|
+
onMouseEnter={() => setHover(i)}
|
|
82
|
+
onMouseLeave={() => setHover(null)}
|
|
83
|
+
>
|
|
84
|
+
{stacking === 'stacked' ? (
|
|
85
|
+
<div className="udb-barchart__bars udb-barchart__bars--stacked">
|
|
86
|
+
{series.map((s, si) => {
|
|
87
|
+
const v = vals(s)[i] ?? 0;
|
|
88
|
+
return (
|
|
89
|
+
<div
|
|
90
|
+
key={s.key ?? s.label}
|
|
91
|
+
className="udb-barchart__bar"
|
|
92
|
+
style={{
|
|
93
|
+
height: `${(v / max) * 100}%`,
|
|
94
|
+
background: accentOf(s, si, i),
|
|
95
|
+
opacity: hover === null || hover === i ? 1 : 0.4,
|
|
96
|
+
...(v === 0 ? { minHeight: 0 } : {}),
|
|
97
|
+
}}
|
|
98
|
+
/>
|
|
99
|
+
);
|
|
100
|
+
})}
|
|
101
|
+
</div>
|
|
102
|
+
) : (
|
|
103
|
+
<div className="udb-barchart__bars">
|
|
104
|
+
{series.map((s, si) => (
|
|
105
|
+
<div
|
|
106
|
+
key={s.key ?? s.label}
|
|
107
|
+
className="udb-barchart__bar"
|
|
108
|
+
style={{
|
|
109
|
+
height: `${Math.max(2, ((vals(s)[i] ?? 0) / max) * 100)}%`,
|
|
110
|
+
background: accentOf(s, si, i),
|
|
111
|
+
opacity: hover === null || hover === i ? 1 : 0.4,
|
|
112
|
+
}}
|
|
113
|
+
/>
|
|
114
|
+
))}
|
|
115
|
+
</div>
|
|
116
|
+
)}
|
|
117
|
+
{hover === i && (
|
|
118
|
+
<div className="udb-barchart__tip">
|
|
119
|
+
<div className="udb-barchart__tiptitle">{formatLabel(label)}</div>
|
|
120
|
+
{series.map((s, si) => (
|
|
121
|
+
<div key={s.key ?? s.label} className="udb-barchart__tiprow">
|
|
122
|
+
<span className="udb-barchart__tiplabel">
|
|
123
|
+
<span className="udb-chartlegend__dot" style={{ background: accentOf(s, si, i) }} />
|
|
124
|
+
{s.label}
|
|
125
|
+
</span>
|
|
126
|
+
<b>{formatValue(vals(s)[i] ?? 0)}</b>
|
|
127
|
+
</div>
|
|
128
|
+
))}
|
|
129
|
+
{stacking === 'stacked' && (
|
|
130
|
+
<div className="udb-barchart__tiprow udb-barchart__tiprow--total">
|
|
131
|
+
<span className="udb-barchart__tiplabel">
|
|
132
|
+
<b>{totalLabel}</b>
|
|
133
|
+
</span>
|
|
134
|
+
<b>{formatValue(columnTotals[i] ?? 0)}</b>
|
|
135
|
+
</div>
|
|
136
|
+
)}
|
|
137
|
+
</div>
|
|
138
|
+
)}
|
|
139
|
+
</div>
|
|
140
|
+
))}
|
|
141
|
+
</div>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { TipState } from './utils';
|
|
3
|
+
import { ChartTip } from './ChartTip';
|
|
4
|
+
|
|
5
|
+
export interface ChartCardProps {
|
|
6
|
+
title: string;
|
|
7
|
+
subtitle?: string;
|
|
8
|
+
legend?: ReactNode;
|
|
9
|
+
actions?: ReactNode;
|
|
10
|
+
children: ReactNode;
|
|
11
|
+
tip: TipState | null;
|
|
12
|
+
hostRef?: React.Ref<HTMLDivElement>;
|
|
13
|
+
onMouseLeave?: () => void;
|
|
14
|
+
bare?: boolean;
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function ChartCard({ title, subtitle, legend, actions, children, tip, hostRef, onMouseLeave, bare, className }: ChartCardProps) {
|
|
19
|
+
return (
|
|
20
|
+
<div className={['udb-chartcard', className].filter(Boolean).join(' ')}>
|
|
21
|
+
<div className="udb-chartcard__head">
|
|
22
|
+
<div className="udb-chartcard__heading">
|
|
23
|
+
<div className="udb-chartcard__title">{title}</div>
|
|
24
|
+
{subtitle && <div className="udb-chartcard__sub">{subtitle}</div>}
|
|
25
|
+
</div>
|
|
26
|
+
<div className="udb-chartcard__tools">
|
|
27
|
+
{legend}
|
|
28
|
+
{actions}
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
<div className="udb-chartcard__body" style={bare ? { padding: 0 } : undefined}>
|
|
32
|
+
<div ref={hostRef} className="udb-chartcard__plot" onMouseLeave={onMouseLeave}>
|
|
33
|
+
{children}
|
|
34
|
+
{tip && <ChartTip tip={tip} />}
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|