@spatika/react 1.3.1 → 1.5.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/AGENTS.md +6 -3
- package/README.md +1 -1
- package/dist/composites/EventCalendar.d.ts +13 -2
- package/dist/composites/EventCalendar.d.ts.map +1 -1
- package/dist/composites/EventCalendar.js +13 -27
- package/dist/composites/GlassCard.d.ts +1 -1
- package/dist/composites/RichTextEditor.d.ts +1 -4
- package/dist/composites/RichTextEditor.d.ts.map +1 -1
- package/dist/composites/RichTextEditor.js +12 -19
- package/dist/composites/SurfaceCard.d.ts +1 -1
- package/dist/composites/Tag.d.ts +2 -2
- package/dist/composites/cartesian-charts.d.ts +12 -2
- package/dist/composites/cartesian-charts.d.ts.map +1 -1
- package/dist/composites/cartesian-charts.js +27 -13
- package/dist/composites/chart-composition.d.ts +19 -1
- package/dist/composites/chart-composition.d.ts.map +1 -1
- package/dist/composites/chart-composition.js +124 -62
- package/dist/composites/chart-interaction.d.ts +81 -0
- package/dist/composites/chart-interaction.d.ts.map +1 -0
- package/dist/composites/chart-interaction.js +56 -0
- package/dist/composites/chart-ui.d.ts +8 -13
- package/dist/composites/chart-ui.d.ts.map +1 -1
- package/dist/composites/chart-ui.js +9 -6
- package/dist/composites/flow-charts.d.ts +6 -1
- package/dist/composites/flow-charts.d.ts.map +1 -1
- package/dist/composites/flow-charts.js +28 -14
- package/dist/composites/radial-charts.d.ts +11 -1
- package/dist/composites/radial-charts.d.ts.map +1 -1
- package/dist/composites/radial-charts.js +33 -14
- package/dist/composites/scheduler-ui.d.ts +14 -1
- package/dist/composites/scheduler-ui.d.ts.map +1 -1
- package/dist/composites/scheduler-ui.js +23 -2
- package/dist/index.d.ts +14 -26
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -14
- package/dist/lib/breakpoints.d.ts +17 -0
- package/dist/lib/breakpoints.d.ts.map +1 -0
- package/dist/lib/breakpoints.js +23 -0
- package/dist/lib/chips.d.ts +3 -0
- package/dist/lib/chips.d.ts.map +1 -1
- package/dist/lib/chips.js +10 -0
- package/dist/lib/create-theme.d.ts +162 -0
- package/dist/lib/create-theme.d.ts.map +1 -0
- package/dist/lib/create-theme.js +138 -0
- package/dist/lib/scheduler.d.ts +12 -0
- package/dist/lib/scheduler.d.ts.map +1 -1
- package/dist/lib/scheduler.js +24 -2
- package/dist/lib/themes.d.ts +17 -7
- package/dist/lib/themes.d.ts.map +1 -1
- package/dist/lib/themes.js +22 -8
- package/dist/lib/use-media-query.d.ts +9 -0
- package/dist/lib/use-media-query.d.ts.map +1 -1
- package/dist/lib/use-media-query.js +28 -1
- package/dist/primitives/Alert.d.ts +1 -1
- package/dist/primitives/Badge.d.ts +2 -2
- package/dist/primitives/Button.d.ts +2 -2
- package/dist/primitives/Container.d.ts +2 -2
- package/dist/primitives/Fab.d.ts +4 -4
- package/dist/primitives/Grid.d.ts +2 -1
- package/dist/primitives/Grid.d.ts.map +1 -1
- package/dist/primitives/Grid.js +10 -2
- package/dist/primitives/IconButton.d.ts +2 -2
- package/dist/primitives/NativeSelect.d.ts +10 -1
- package/dist/primitives/NativeSelect.d.ts.map +1 -1
- package/dist/primitives/NativeSelect.js +2 -2
- package/dist/primitives/ToggleButton.d.ts +1 -1
- package/dist/theme/SpatikaThemeProvider.d.ts +4 -5
- package/dist/theme/SpatikaThemeProvider.d.ts.map +1 -1
- package/dist/theme/SpatikaThemeProvider.js +28 -13
- package/package.json +4 -2
- package/skills/spatika-ui/SKILL.md +28 -5
- package/src/composites/EventCalendar.test.tsx +293 -0
- package/src/composites/EventCalendar.tsx +38 -27
- package/src/composites/RichTextEditor.test.tsx +51 -0
- package/src/composites/RichTextEditor.tsx +20 -84
- package/src/composites/chart-ui.test.tsx +21 -0
- package/src/composites/scheduler-ui.tsx +128 -26
- package/src/composites/scheduler.test.tsx +0 -219
- package/src/index.test.ts +10 -0
- package/src/index.ts +148 -65
- package/src/lib/breakpoints.test.ts +29 -0
- package/src/lib/breakpoints.ts +28 -0
- package/src/lib/chips.ts +16 -0
- package/src/lib/create-theme.test.ts +79 -0
- package/src/lib/create-theme.ts +249 -0
- package/src/lib/scheduler.test.ts +26 -0
- package/src/lib/scheduler.ts +36 -2
- package/src/lib/themes.test.ts +17 -0
- package/src/lib/themes.ts +43 -12
- package/src/lib/use-media-query.test.tsx +80 -0
- package/src/lib/use-media-query.ts +35 -1
- package/src/primitives/Grid.test.tsx +55 -0
- package/src/primitives/Grid.tsx +13 -3
- package/src/primitives/NativeSelect.test.tsx +28 -0
- package/src/primitives/NativeSelect.tsx +14 -4
- package/src/primitives/mui-more.test.tsx +17 -0
- package/src/theme/SpatikaThemeProvider.test.tsx +38 -0
- package/src/theme/SpatikaThemeProvider.tsx +43 -15
- package/src/composites/ChartDataGrid.test.tsx +0 -57
- package/src/composites/ChartDataGrid.tsx +0 -196
- package/src/composites/MapChart.test.tsx +0 -39
- package/src/composites/MapChart.tsx +0 -130
- package/src/composites/ScatterWebGL.test.tsx +0 -25
- package/src/composites/ScatterWebGL.tsx +0 -79
- package/src/composites/cartesian-charts.test.tsx +0 -52
- package/src/composites/cartesian-charts.tsx +0 -1287
- package/src/composites/chart-composition.test.tsx +0 -53
- package/src/composites/chart-composition.tsx +0 -898
- package/src/composites/chart-ui.tsx +0 -328
- package/src/composites/charts-3d.test.tsx +0 -25
- package/src/composites/charts-3d.tsx +0 -318
- package/src/composites/charts.test.tsx +0 -254
- package/src/composites/flow-charts.test.tsx +0 -79
- package/src/composites/flow-charts.tsx +0 -607
- package/src/composites/radial-charts.test.tsx +0 -74
- package/src/composites/radial-charts.tsx +0 -617
- package/src/lib/chart-export.test.ts +0 -20
- package/src/lib/chart-export.ts +0 -99
- package/src/lib/charts-platform.test.ts +0 -93
- package/src/lib/charts.test.ts +0 -132
- package/src/lib/charts.ts +0 -1021
- package/src/lib/geo.test.ts +0 -42
- package/src/lib/geo.ts +0 -138
- package/src/lib/iso.test.ts +0 -22
- package/src/lib/iso.ts +0 -102
- package/src/lib/webgl-scatter.test.ts +0 -36
- package/src/lib/webgl-scatter.ts +0 -230
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { Box, Grid } from "./Grid";
|
|
4
|
+
|
|
5
|
+
describe("Grid", () => {
|
|
6
|
+
it("applies container layout and breakpoint spans including xl", () => {
|
|
7
|
+
const { container } = render(
|
|
8
|
+
<Grid container spacing={3}>
|
|
9
|
+
<Grid xs={12} sm={6} md={4} lg={3} xl={2}>
|
|
10
|
+
Cell
|
|
11
|
+
</Grid>
|
|
12
|
+
</Grid>,
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
const wrap = container.querySelector("[data-slot='grid'][data-container]");
|
|
16
|
+
expect(wrap).toBeTruthy();
|
|
17
|
+
expect(screen.getByText("Cell")).toBeInTheDocument();
|
|
18
|
+
|
|
19
|
+
const item = container.querySelector("[data-slot='grid'][data-item]");
|
|
20
|
+
expect(item?.className).toContain("col-span-12");
|
|
21
|
+
expect(item?.className).toContain("sm:col-span-6");
|
|
22
|
+
expect(item?.className).toContain("md:col-span-4");
|
|
23
|
+
expect(item?.className).toContain("lg:col-span-3");
|
|
24
|
+
expect(item?.className).toContain("xl:col-span-2");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("skips auto spans and falls back to a full row", () => {
|
|
28
|
+
const { container } = render(
|
|
29
|
+
<Grid container>
|
|
30
|
+
<Grid xs="auto" sm="auto" xl="auto">
|
|
31
|
+
Auto
|
|
32
|
+
</Grid>
|
|
33
|
+
<Grid size="grow">Grow</Grid>
|
|
34
|
+
</Grid>,
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const items = container.querySelectorAll("[data-slot='grid'][data-item]");
|
|
38
|
+
expect(items[0]?.className).not.toMatch(/\b(sm|md|lg|xl):col-span-/);
|
|
39
|
+
expect(items[0]?.className).toContain("col-span-12");
|
|
40
|
+
expect(items[1]?.className).toContain("col-span-12");
|
|
41
|
+
expect(screen.getByText("Auto")).toBeInTheDocument();
|
|
42
|
+
expect(screen.getByText("Grow")).toBeInTheDocument();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("Box", () => {
|
|
47
|
+
it("renders as a generic wrapper and can change the element", () => {
|
|
48
|
+
render(
|
|
49
|
+
<Box>
|
|
50
|
+
<Box component="section">Panel</Box>
|
|
51
|
+
</Box>,
|
|
52
|
+
);
|
|
53
|
+
expect(screen.getByText("Panel").tagName).toBe("SECTION");
|
|
54
|
+
});
|
|
55
|
+
});
|
package/src/primitives/Grid.tsx
CHANGED
|
@@ -53,6 +53,7 @@ export type GridProps = React.ComponentPropsWithoutRef<"div"> & {
|
|
|
53
53
|
sm?: number | "auto";
|
|
54
54
|
md?: number | "auto";
|
|
55
55
|
lg?: number | "auto";
|
|
56
|
+
xl?: number | "auto";
|
|
56
57
|
size?: number | "grow";
|
|
57
58
|
};
|
|
58
59
|
|
|
@@ -61,6 +62,13 @@ function spanFor(value?: number | "auto" | "grow") {
|
|
|
61
62
|
return spanClass[value] ?? "col-span-12";
|
|
62
63
|
}
|
|
63
64
|
|
|
65
|
+
function prefixedSpan(prefix: string, value?: number | "auto") {
|
|
66
|
+
if (typeof value !== "number") return undefined;
|
|
67
|
+
const base = spanClass[value];
|
|
68
|
+
if (!base) return undefined;
|
|
69
|
+
return base.replace("col-span", `${prefix}:col-span`);
|
|
70
|
+
}
|
|
71
|
+
|
|
64
72
|
/**
|
|
65
73
|
* 12-column CSS grid. Use `container` on the parent and breakpoint spans on items.
|
|
66
74
|
*/
|
|
@@ -73,6 +81,7 @@ function Grid({
|
|
|
73
81
|
sm,
|
|
74
82
|
md,
|
|
75
83
|
lg,
|
|
84
|
+
xl,
|
|
76
85
|
size,
|
|
77
86
|
className,
|
|
78
87
|
...props
|
|
@@ -87,9 +96,10 @@ function Grid({
|
|
|
87
96
|
container && "grid grid-cols-12",
|
|
88
97
|
container && (gapClass[spacing] ?? "gap-2"),
|
|
89
98
|
!container && (span ?? (size === "grow" ? "col-span-12" : "col-span-12")),
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
99
|
+
prefixedSpan("sm", sm),
|
|
100
|
+
prefixedSpan("md", md),
|
|
101
|
+
prefixedSpan("lg", lg),
|
|
102
|
+
prefixedSpan("xl", xl),
|
|
93
103
|
className,
|
|
94
104
|
)}
|
|
95
105
|
{...props}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { NativeSelect } from "./NativeSelect";
|
|
4
|
+
|
|
5
|
+
describe("NativeSelect", () => {
|
|
6
|
+
it("renders a full-width form select by default", () => {
|
|
7
|
+
render(
|
|
8
|
+
<NativeSelect aria-label="Rows">
|
|
9
|
+
<option value="10">10</option>
|
|
10
|
+
</NativeSelect>,
|
|
11
|
+
);
|
|
12
|
+
const select = screen.getByLabelText("Rows");
|
|
13
|
+
expect(select).toHaveAttribute("data-size", "default");
|
|
14
|
+
expect(select).not.toHaveAttribute("size");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("renders a compact toolbar select without the native size attribute", () => {
|
|
18
|
+
render(
|
|
19
|
+
<NativeSelect size="sm" aria-label="Month">
|
|
20
|
+
<option value="7">AUG</option>
|
|
21
|
+
</NativeSelect>,
|
|
22
|
+
);
|
|
23
|
+
const select = screen.getByLabelText("Month");
|
|
24
|
+
expect(select).toHaveAttribute("data-size", "sm");
|
|
25
|
+
expect(select.className).toMatch(/h-8|min-h-8/);
|
|
26
|
+
expect(select).not.toHaveAttribute("size");
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -1,16 +1,26 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
2
|
import { cn } from "../lib/cn";
|
|
3
|
-
import {
|
|
3
|
+
import { nativeSelectSizeClass, type NativeSelectSize } from "../lib/chips";
|
|
4
|
+
|
|
5
|
+
export type NativeSelectProps = Omit<React.ComponentProps<"select">, "size"> & {
|
|
6
|
+
/**
|
|
7
|
+
* Visual size. `default` stays full-width for forms.
|
|
8
|
+
* `sm` is a compact pill for toolbars. `touch` is a 44px target.
|
|
9
|
+
* Omits the native HTML `size` (visible option rows) to avoid the clash.
|
|
10
|
+
*/
|
|
11
|
+
size?: NativeSelectSize;
|
|
12
|
+
};
|
|
4
13
|
|
|
5
14
|
/**
|
|
6
15
|
* Styled native `<select>` — prefer compound `Select` for custom menus.
|
|
7
16
|
*/
|
|
8
|
-
const NativeSelect = React.forwardRef<HTMLSelectElement,
|
|
9
|
-
({ className, children, ...props }, ref) => (
|
|
17
|
+
const NativeSelect = React.forwardRef<HTMLSelectElement, NativeSelectProps>(
|
|
18
|
+
({ className, size = "default", children, ...props }, ref) => (
|
|
10
19
|
<select
|
|
11
20
|
ref={ref}
|
|
12
21
|
data-slot="native-select"
|
|
13
|
-
|
|
22
|
+
data-size={size}
|
|
23
|
+
className={cn(nativeSelectSizeClass(size), className)}
|
|
14
24
|
{...props}
|
|
15
25
|
>
|
|
16
26
|
{children}
|
|
@@ -62,6 +62,23 @@ describe("layout and form extras", () => {
|
|
|
62
62
|
expect(onTransfer).toHaveBeenCalled();
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
+
it("applies Grid breakpoint span classes including xl", () => {
|
|
66
|
+
const { container } = render(
|
|
67
|
+
<Grid container>
|
|
68
|
+
<Grid xs={12} sm={6} md={4} lg={3} xl={2}>
|
|
69
|
+
Cell
|
|
70
|
+
</Grid>
|
|
71
|
+
</Grid>,
|
|
72
|
+
);
|
|
73
|
+
const item = container.querySelector("[data-slot='grid'][data-item]");
|
|
74
|
+
expect(item?.className).toContain("col-span-12");
|
|
75
|
+
expect(item?.className).toContain("sm:col-span-6");
|
|
76
|
+
expect(item?.className).toContain("md:col-span-4");
|
|
77
|
+
expect(item?.className).toContain("lg:col-span-3");
|
|
78
|
+
expect(item?.className).toContain("xl:col-span-2");
|
|
79
|
+
expect(screen.getByText("Cell")).toBeInTheDocument();
|
|
80
|
+
});
|
|
81
|
+
|
|
65
82
|
it("switches BottomNavigation actions", async () => {
|
|
66
83
|
const user = userEvent.setup();
|
|
67
84
|
render(
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { cleanup, render, screen } from "@testing-library/react";
|
|
2
2
|
import userEvent from "@testing-library/user-event";
|
|
3
3
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { createTheme } from "../lib/create-theme";
|
|
4
5
|
import { SPATIKA_THEME_STORAGE_KEY } from "../lib/themes";
|
|
5
6
|
import { SpatikaThemeProvider, useSpatikaTheme } from "./SpatikaThemeProvider";
|
|
6
7
|
|
|
@@ -21,6 +22,7 @@ function resetDocumentTheme() {
|
|
|
21
22
|
document.documentElement.className = "";
|
|
22
23
|
document.documentElement.removeAttribute("data-spk-theme");
|
|
23
24
|
document.documentElement.style.colorScheme = "";
|
|
25
|
+
document.getElementById("spk-custom-themes")?.remove();
|
|
24
26
|
localStorage.removeItem(SPATIKA_THEME_STORAGE_KEY);
|
|
25
27
|
localStorage.removeItem("spk-theme-test");
|
|
26
28
|
}
|
|
@@ -79,4 +81,40 @@ describe("SpatikaThemeProvider", () => {
|
|
|
79
81
|
expect(() => render(<ThemeProbe />)).toThrow(/SpatikaThemeProvider/);
|
|
80
82
|
spy.mockRestore();
|
|
81
83
|
});
|
|
84
|
+
|
|
85
|
+
it("registers a custom theme and applies its base class on the wrapper", async () => {
|
|
86
|
+
const user = userEvent.setup();
|
|
87
|
+
const brand = createTheme({
|
|
88
|
+
id: "brand",
|
|
89
|
+
extends: "neelam",
|
|
90
|
+
palette: { primary: "#22c55e" },
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
function CustomProbe() {
|
|
94
|
+
const { theme, setTheme, themes } = useSpatikaTheme();
|
|
95
|
+
return (
|
|
96
|
+
<div>
|
|
97
|
+
<span data-testid="theme">{theme}</span>
|
|
98
|
+
<span data-testid="count">{themes.length}</span>
|
|
99
|
+
<button type="button" onClick={() => setTheme("brand")}>
|
|
100
|
+
Brand
|
|
101
|
+
</button>
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const { container } = render(
|
|
107
|
+
<SpatikaThemeProvider defaultTheme="mukta" syncDocument={false} customThemes={[brand]}>
|
|
108
|
+
<CustomProbe />
|
|
109
|
+
</SpatikaThemeProvider>,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
expect(screen.getByTestId("count")).toHaveTextContent("5");
|
|
113
|
+
await user.click(screen.getByRole("button", { name: "Brand" }));
|
|
114
|
+
expect(screen.getByTestId("theme")).toHaveTextContent("brand");
|
|
115
|
+
const root = container.querySelector("[data-slot='spatika-theme-provider']");
|
|
116
|
+
expect(root).toHaveAttribute("data-spk-theme", "brand");
|
|
117
|
+
expect(root).toHaveClass("neelam");
|
|
118
|
+
expect(document.getElementById("spk-custom-themes")?.textContent).toContain("--primary: #22c55e;");
|
|
119
|
+
});
|
|
82
120
|
});
|
|
@@ -7,12 +7,21 @@ import {
|
|
|
7
7
|
useState,
|
|
8
8
|
type ReactNode,
|
|
9
9
|
} from "react";
|
|
10
|
+
import { cn } from "../lib/cn";
|
|
11
|
+
import {
|
|
12
|
+
findCustomTheme,
|
|
13
|
+
injectCustomThemeStyles,
|
|
14
|
+
type SpatikaTheme,
|
|
15
|
+
} from "../lib/create-theme";
|
|
10
16
|
import {
|
|
11
17
|
applyTheme,
|
|
12
18
|
DEFAULT_THEME,
|
|
19
|
+
isDarkTheme,
|
|
20
|
+
isThemeId,
|
|
13
21
|
readStoredTheme,
|
|
14
22
|
THEME_IDS,
|
|
15
23
|
writeStoredTheme,
|
|
24
|
+
type BuiltinThemeId,
|
|
16
25
|
type ThemeId,
|
|
17
26
|
} from "../lib/themes";
|
|
18
27
|
|
|
@@ -44,12 +53,24 @@ export type SpatikaThemeProviderProps = {
|
|
|
44
53
|
/** Optional element to theme instead of (or in addition to) document */
|
|
45
54
|
target?: HTMLElement | null;
|
|
46
55
|
className?: string;
|
|
56
|
+
/** Custom themes from `createTheme`. Switch them with `setTheme(id)`. */
|
|
57
|
+
customThemes?: readonly SpatikaTheme[];
|
|
47
58
|
};
|
|
48
59
|
|
|
60
|
+
function baseThemeClass(
|
|
61
|
+
theme: ThemeId,
|
|
62
|
+
custom: SpatikaTheme | undefined,
|
|
63
|
+
): BuiltinThemeId | undefined {
|
|
64
|
+
const base = custom?.extends ?? (isThemeId(theme) ? theme : DEFAULT_THEME);
|
|
65
|
+
return base === DEFAULT_THEME ? undefined : base;
|
|
66
|
+
}
|
|
67
|
+
|
|
49
68
|
/**
|
|
50
|
-
*
|
|
69
|
+
* Theme provider for Spatika.
|
|
51
70
|
* Wraps the app (or a subtree) and applies theme classes / data attributes.
|
|
52
71
|
*/
|
|
72
|
+
const EMPTY_CUSTOM_THEMES: readonly SpatikaTheme[] = [];
|
|
73
|
+
|
|
53
74
|
export function SpatikaThemeProvider({
|
|
54
75
|
children,
|
|
55
76
|
theme: themeProp,
|
|
@@ -59,11 +80,14 @@ export function SpatikaThemeProvider({
|
|
|
59
80
|
syncDocument = true,
|
|
60
81
|
target,
|
|
61
82
|
className,
|
|
83
|
+
customThemes = EMPTY_CUSTOM_THEMES,
|
|
62
84
|
}: SpatikaThemeProviderProps) {
|
|
85
|
+
const customIds = useMemo(() => customThemes.map((item) => item.id), [customThemes]);
|
|
63
86
|
const [uncontrolled, setUncontrolled] = useState<ThemeId>(
|
|
64
|
-
() => readStoredTheme(storageKey) ?? defaultTheme,
|
|
87
|
+
() => readStoredTheme(storageKey, customIds) ?? defaultTheme,
|
|
65
88
|
);
|
|
66
89
|
const theme = themeProp ?? uncontrolled;
|
|
90
|
+
const custom = findCustomTheme(theme, customThemes);
|
|
67
91
|
|
|
68
92
|
const setTheme = useCallback(
|
|
69
93
|
(next: ThemeId) => {
|
|
@@ -74,27 +98,31 @@ export function SpatikaThemeProvider({
|
|
|
74
98
|
);
|
|
75
99
|
|
|
76
100
|
useLayoutEffect(() => {
|
|
77
|
-
|
|
78
|
-
if (
|
|
101
|
+
injectCustomThemeStyles(customThemes);
|
|
102
|
+
if (syncDocument) applyTheme(theme, undefined, custom);
|
|
103
|
+
if (target) applyTheme(theme, target, custom);
|
|
79
104
|
writeStoredTheme(storageKey, theme);
|
|
80
|
-
}, [theme, syncDocument, target, storageKey]);
|
|
105
|
+
}, [theme, syncDocument, target, storageKey, custom, customThemes]);
|
|
81
106
|
|
|
82
|
-
const value = useMemo(
|
|
83
|
-
() =>
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
107
|
+
const value = useMemo(() => {
|
|
108
|
+
const extras = customThemes.map((item) => item.id).filter((id) => !isThemeId(id));
|
|
109
|
+
return {
|
|
110
|
+
theme,
|
|
111
|
+
setTheme,
|
|
112
|
+
themes: extras.length ? [...THEME_IDS, ...extras] : THEME_IDS,
|
|
113
|
+
} satisfies SpatikaThemeContextValue;
|
|
114
|
+
}, [theme, setTheme, customThemes]);
|
|
115
|
+
|
|
116
|
+
const dark = custom ? custom.colorScheme === "dark" : isDarkTheme(theme);
|
|
117
|
+
const themeClass = baseThemeClass(theme, custom);
|
|
91
118
|
|
|
92
119
|
return (
|
|
93
120
|
<SpatikaThemeContext.Provider value={value}>
|
|
94
121
|
<div
|
|
95
|
-
className={className}
|
|
122
|
+
className={cn(className, themeClass)}
|
|
96
123
|
data-spk-theme={theme}
|
|
97
124
|
data-slot="spatika-theme-provider"
|
|
125
|
+
style={{ colorScheme: dark ? "dark" : "light" }}
|
|
98
126
|
>
|
|
99
127
|
{children}
|
|
100
128
|
</div>
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
import { render, screen } from "@testing-library/react";
|
|
2
|
-
import userEvent from "@testing-library/user-event";
|
|
3
|
-
import { describe, expect, it } from "vitest";
|
|
4
|
-
import { ChartDataGrid, renderChartCell } from "./ChartDataGrid";
|
|
5
|
-
|
|
6
|
-
describe("ChartDataGrid", () => {
|
|
7
|
-
it("renders a linked table and highlights a selected row", async () => {
|
|
8
|
-
const user = userEvent.setup();
|
|
9
|
-
const { container } = render(
|
|
10
|
-
<ChartDataGrid
|
|
11
|
-
height={140}
|
|
12
|
-
categoryField="name"
|
|
13
|
-
valueField="views"
|
|
14
|
-
rows={[
|
|
15
|
-
{ id: "web", name: "Web", views: 10, trend: [1, 2, 3] },
|
|
16
|
-
{ id: "ios", name: "iOS", views: 6, trend: [2, 2, 1] },
|
|
17
|
-
]}
|
|
18
|
-
columns={[
|
|
19
|
-
{ field: "name", headerName: "Channel" },
|
|
20
|
-
{ field: "views", headerName: "Views", type: "number" },
|
|
21
|
-
{ field: "trend", headerName: "Trend", type: "sparkline" },
|
|
22
|
-
]}
|
|
23
|
-
/>,
|
|
24
|
-
);
|
|
25
|
-
expect(container.querySelector('[data-slot="chart-data-grid"]')).toBeTruthy();
|
|
26
|
-
expect(screen.getByText("Channel")).toBeInTheDocument();
|
|
27
|
-
const row = container.querySelector(
|
|
28
|
-
'[data-slot="chart-data-grid"] tbody [data-slot="table-row"]:nth-child(2)',
|
|
29
|
-
);
|
|
30
|
-
await user.click(row!);
|
|
31
|
-
expect(row).toHaveAttribute("data-state", "selected");
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it("hides the chart when requested", () => {
|
|
35
|
-
const { container } = render(
|
|
36
|
-
<ChartDataGrid
|
|
37
|
-
hideChart
|
|
38
|
-
categoryField="name"
|
|
39
|
-
valueField="views"
|
|
40
|
-
rows={[{ id: "web", name: "Web", views: 10 }]}
|
|
41
|
-
columns={[{ field: "name", headerName: "Channel" }]}
|
|
42
|
-
/>,
|
|
43
|
-
);
|
|
44
|
-
expect(container.querySelector('[data-slot="chart-container"]')).toBeNull();
|
|
45
|
-
});
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
describe("renderChartCell", () => {
|
|
49
|
-
it("returns a dash for non-numeric values", () => {
|
|
50
|
-
expect(renderChartCell("n/a")).toBe("—");
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it("renders a sparkline for numeric arrays", () => {
|
|
54
|
-
const { container } = render(<>{renderChartCell([1, 4, 2], { variant: "sparkline" })}</>);
|
|
55
|
-
expect(container.querySelector('[data-slot="sparkline"]')).toBeTruthy();
|
|
56
|
-
});
|
|
57
|
-
});
|
|
@@ -1,196 +0,0 @@
|
|
|
1
|
-
import { useState, type ReactNode } from "react";
|
|
2
|
-
import { chartColor, formatChartNumber } from "../lib/charts";
|
|
3
|
-
import { cn } from "../lib/cn";
|
|
4
|
-
import {
|
|
5
|
-
Table,
|
|
6
|
-
TableBody,
|
|
7
|
-
TableCell,
|
|
8
|
-
TableHead,
|
|
9
|
-
TableHeader,
|
|
10
|
-
TableRow,
|
|
11
|
-
} from "../primitives/Table";
|
|
12
|
-
import { ChartContainer } from "./chart-composition";
|
|
13
|
-
import { SparkLineChart } from "./cartesian-charts";
|
|
14
|
-
import { PieChart } from "./radial-charts";
|
|
15
|
-
|
|
16
|
-
export type ChartGridColumnType = "text" | "number" | "sparkline" | "bar";
|
|
17
|
-
|
|
18
|
-
export type ChartGridColumn = {
|
|
19
|
-
field: string;
|
|
20
|
-
headerName: string;
|
|
21
|
-
type?: ChartGridColumnType;
|
|
22
|
-
width?: number;
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
export type ChartDataGridRow = { id: string } & Record<string, unknown>;
|
|
26
|
-
|
|
27
|
-
export type ChartDataGridProps = {
|
|
28
|
-
rows: ChartDataGridRow[];
|
|
29
|
-
columns: ChartGridColumn[];
|
|
30
|
-
chartType?: "bar" | "line" | "pie";
|
|
31
|
-
categoryField: string;
|
|
32
|
-
valueField: string;
|
|
33
|
-
height?: number;
|
|
34
|
-
className?: string;
|
|
35
|
-
selectedIds?: string[];
|
|
36
|
-
defaultSelectedIds?: string[];
|
|
37
|
-
onSelectionChange?: (ids: string[]) => void;
|
|
38
|
-
hideChart?: boolean;
|
|
39
|
-
hideLegend?: boolean;
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
export function renderChartCell(
|
|
43
|
-
value: unknown,
|
|
44
|
-
options?: {
|
|
45
|
-
variant?: "sparkline" | "bar";
|
|
46
|
-
color?: string;
|
|
47
|
-
height?: number;
|
|
48
|
-
max?: number;
|
|
49
|
-
},
|
|
50
|
-
): ReactNode {
|
|
51
|
-
const color = options?.color ?? "var(--chart-1)";
|
|
52
|
-
if (Array.isArray(value) && value.every((item) => typeof item === "number")) {
|
|
53
|
-
return (
|
|
54
|
-
<SparkLineChart
|
|
55
|
-
data={value as number[]}
|
|
56
|
-
plotType={options?.variant === "bar" ? "bar" : "line"}
|
|
57
|
-
color={color}
|
|
58
|
-
height={options?.height ?? 32}
|
|
59
|
-
showHighlight={false}
|
|
60
|
-
area={options?.variant !== "bar"}
|
|
61
|
-
/>
|
|
62
|
-
);
|
|
63
|
-
}
|
|
64
|
-
const n = typeof value === "number" ? value : Number(value);
|
|
65
|
-
if (!Number.isFinite(n)) return "—";
|
|
66
|
-
const max = options?.max && options.max > 0 ? options.max : Math.max(n, 1);
|
|
67
|
-
const pct = Math.max(0, Math.min(100, (n / max) * 100));
|
|
68
|
-
return (
|
|
69
|
-
<div className="spk-chart-cell-bar" title={formatChartNumber(n)}>
|
|
70
|
-
<span style={{ width: `${pct}%`, background: color }} />
|
|
71
|
-
</div>
|
|
72
|
-
);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export function ChartDataGrid({
|
|
76
|
-
rows,
|
|
77
|
-
columns,
|
|
78
|
-
chartType = "bar",
|
|
79
|
-
categoryField,
|
|
80
|
-
valueField,
|
|
81
|
-
height = 200,
|
|
82
|
-
className,
|
|
83
|
-
selectedIds,
|
|
84
|
-
defaultSelectedIds,
|
|
85
|
-
onSelectionChange,
|
|
86
|
-
hideChart,
|
|
87
|
-
hideLegend = true,
|
|
88
|
-
}: ChartDataGridProps) {
|
|
89
|
-
const [internal, setInternal] = useState<string[]>(defaultSelectedIds ?? []);
|
|
90
|
-
const selected = selectedIds ?? internal;
|
|
91
|
-
const setSelected = (ids: string[]) => {
|
|
92
|
-
if (selectedIds == null) setInternal(ids);
|
|
93
|
-
onSelectionChange?.(ids);
|
|
94
|
-
};
|
|
95
|
-
const selectedIndex = selected.length ? rows.findIndex((row) => row.id === selected[0]) : -1;
|
|
96
|
-
const categories = rows.map((row) => String(row[categoryField] ?? row.id));
|
|
97
|
-
const values = rows.map((row) => Number(row[valueField] ?? 0));
|
|
98
|
-
const maxByField = new Map<string, number>();
|
|
99
|
-
for (const col of columns) {
|
|
100
|
-
if (col.type === "bar" || col.type === "number") {
|
|
101
|
-
const nums = rows.map((row) => Number(row[col.field] ?? 0)).filter((n) => Number.isFinite(n));
|
|
102
|
-
maxByField.set(col.field, nums.length ? Math.max(...nums) : 1);
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const toggleRow = (id: string) => {
|
|
107
|
-
setSelected(selected[0] === id ? [] : [id]);
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
return (
|
|
111
|
-
<div data-slot="chart-data-grid" className={cn("spk-chart-data-grid", className)}>
|
|
112
|
-
{hideChart ? null : chartType === "pie" ? (
|
|
113
|
-
<PieChart
|
|
114
|
-
height={height}
|
|
115
|
-
hideLegend={hideLegend}
|
|
116
|
-
series={[
|
|
117
|
-
{
|
|
118
|
-
innerRadius: 36,
|
|
119
|
-
paddingAngle: 2,
|
|
120
|
-
data: rows.map((row, i) => ({
|
|
121
|
-
id: row.id,
|
|
122
|
-
value: values[i] ?? 0,
|
|
123
|
-
label: categories[i],
|
|
124
|
-
color:
|
|
125
|
-
selected.length && !selected.includes(row.id)
|
|
126
|
-
? `color-mix(in srgb, ${chartColor(i)} 28%, transparent)`
|
|
127
|
-
: undefined,
|
|
128
|
-
})),
|
|
129
|
-
},
|
|
130
|
-
]}
|
|
131
|
-
/>
|
|
132
|
-
) : (
|
|
133
|
-
<ChartContainer
|
|
134
|
-
height={height}
|
|
135
|
-
hideLegend={hideLegend}
|
|
136
|
-
highlightScope="item"
|
|
137
|
-
selected={
|
|
138
|
-
selectedIndex >= 0 ? { seriesId: "value", dataIndex: selectedIndex } : null
|
|
139
|
-
}
|
|
140
|
-
xAxis={[{ data: categories }]}
|
|
141
|
-
series={[{ type: chartType, id: "value", label: valueField, data: values }]}
|
|
142
|
-
aria-label="Linked chart"
|
|
143
|
-
/>
|
|
144
|
-
)}
|
|
145
|
-
<Table containerClassName="spk-chart-grid-table">
|
|
146
|
-
<TableHeader>
|
|
147
|
-
<TableRow>
|
|
148
|
-
{columns.map((col) => (
|
|
149
|
-
<TableHead key={col.field} style={col.width ? { width: col.width } : undefined}>
|
|
150
|
-
{col.headerName}
|
|
151
|
-
</TableHead>
|
|
152
|
-
))}
|
|
153
|
-
</TableRow>
|
|
154
|
-
</TableHeader>
|
|
155
|
-
<TableBody>
|
|
156
|
-
{rows.map((row, rowIndex) => (
|
|
157
|
-
<TableRow
|
|
158
|
-
key={row.id}
|
|
159
|
-
data-state={selected.includes(row.id) ? "selected" : undefined}
|
|
160
|
-
onClick={() => toggleRow(row.id)}
|
|
161
|
-
style={{ cursor: "pointer" }}
|
|
162
|
-
>
|
|
163
|
-
{columns.map((col, colIndex) => {
|
|
164
|
-
const value = row[col.field];
|
|
165
|
-
const color = chartColor(rowIndex);
|
|
166
|
-
let content: ReactNode;
|
|
167
|
-
if (col.type === "sparkline") {
|
|
168
|
-
content = renderChartCell(value, { variant: "sparkline", color, height: 32 });
|
|
169
|
-
} else if (col.type === "bar") {
|
|
170
|
-
content = renderChartCell(value, {
|
|
171
|
-
variant: "bar",
|
|
172
|
-
color,
|
|
173
|
-
max: maxByField.get(col.field),
|
|
174
|
-
});
|
|
175
|
-
} else if (col.type === "number" && typeof value === "number") {
|
|
176
|
-
content = formatChartNumber(value);
|
|
177
|
-
} else {
|
|
178
|
-
content = value == null ? "—" : String(value);
|
|
179
|
-
}
|
|
180
|
-
return (
|
|
181
|
-
<TableCell key={col.field} data-field={col.field}>
|
|
182
|
-
{colIndex === 0 ? (
|
|
183
|
-
<span className="spk-chart-grid-name">{content}</span>
|
|
184
|
-
) : (
|
|
185
|
-
content
|
|
186
|
-
)}
|
|
187
|
-
</TableCell>
|
|
188
|
-
);
|
|
189
|
-
})}
|
|
190
|
-
</TableRow>
|
|
191
|
-
))}
|
|
192
|
-
</TableBody>
|
|
193
|
-
</Table>
|
|
194
|
-
</div>
|
|
195
|
-
);
|
|
196
|
-
}
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import { render } from "@testing-library/react";
|
|
2
|
-
import { describe, expect, it } from "vitest";
|
|
3
|
-
import { MapChart } from "./MapChart";
|
|
4
|
-
|
|
5
|
-
describe("MapChart", () => {
|
|
6
|
-
it("draws choropleth paths from GeoJSON", () => {
|
|
7
|
-
const { container } = render(
|
|
8
|
-
<MapChart
|
|
9
|
-
width={240}
|
|
10
|
-
height={160}
|
|
11
|
-
geoData={{
|
|
12
|
-
type: "FeatureCollection",
|
|
13
|
-
features: [
|
|
14
|
-
{
|
|
15
|
-
type: "Feature",
|
|
16
|
-
id: "a",
|
|
17
|
-
properties: { name: "A" },
|
|
18
|
-
geometry: {
|
|
19
|
-
type: "Polygon",
|
|
20
|
-
coordinates: [
|
|
21
|
-
[
|
|
22
|
-
[0, 0],
|
|
23
|
-
[4, 0],
|
|
24
|
-
[4, 3],
|
|
25
|
-
[0, 3],
|
|
26
|
-
[0, 0],
|
|
27
|
-
],
|
|
28
|
-
],
|
|
29
|
-
},
|
|
30
|
-
},
|
|
31
|
-
],
|
|
32
|
-
}}
|
|
33
|
-
series={[{ data: [{ id: "a", value: 12, label: "A" }] }]}
|
|
34
|
-
/>,
|
|
35
|
-
);
|
|
36
|
-
expect(container.querySelector('[data-slot="map-chart"]')).toBeTruthy();
|
|
37
|
-
expect(container.querySelector('[data-slot="map-chart"] path')).toBeTruthy();
|
|
38
|
-
});
|
|
39
|
-
});
|