@umituz/web-design-system 6.0.1 → 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.
Files changed (28) hide show
  1. package/dist/styles.css +1 -1
  2. package/package.json +4 -3
  3. package/src/__tests__/charts/ActivityHeatmap.test.tsx +48 -0
  4. package/src/__tests__/charts/BarChart.test.tsx +91 -0
  5. package/src/__tests__/charts/ChartPrimitives.test.tsx +90 -0
  6. package/src/__tests__/charts/Donut.test.tsx +51 -0
  7. package/src/__tests__/charts/LineChart.test.tsx +81 -0
  8. package/src/__tests__/layout-components.test.tsx +100 -0
  9. package/src/presentation/molecules/TabNav.tsx +46 -0
  10. package/src/presentation/molecules/index.ts +3 -0
  11. package/src/presentation/organisms/AuditLogList.tsx +58 -0
  12. package/src/presentation/organisms/charts/ActivityHeatmap.tsx +58 -0
  13. package/src/presentation/organisms/charts/BarChart.tsx +143 -0
  14. package/src/presentation/organisms/charts/ChartCard.tsx +39 -0
  15. package/src/presentation/organisms/charts/ChartTip.tsx +18 -0
  16. package/src/presentation/organisms/charts/Donut.tsx +100 -0
  17. package/src/presentation/organisms/charts/HBars.tsx +31 -0
  18. package/src/presentation/organisms/charts/Legend.tsx +21 -0
  19. package/src/presentation/organisms/charts/LineChart.tsx +244 -0
  20. package/src/presentation/organisms/charts/Sparkline.tsx +35 -0
  21. package/src/presentation/organisms/charts/index.ts +17 -0
  22. package/src/presentation/organisms/charts/utils.ts +57 -0
  23. package/src/presentation/organisms/index.ts +6 -0
  24. package/src/presentation/templates/PageHeader.tsx +103 -33
  25. package/src/presentation/templates/SectionHeader.tsx +54 -0
  26. package/src/presentation/templates/index.ts +4 -1
  27. package/src/styles/charts.css +455 -0
  28. 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';
@@ -87,3 +87,6 @@ export type { ConfirmButtonProps } from './ConfirmButton';
87
87
 
88
88
  export { Toolbar } from './Toolbar';
89
89
  export type { ToolbarProps } from './Toolbar';
90
+
91
+ export { TabNav } from './TabNav';
92
+ export type { TabNavProps, TabNavItem } from './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
+ }