@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,79 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
createTheme,
|
|
4
|
+
injectCustomThemeStyles,
|
|
5
|
+
PALETTE_CSS_VARS,
|
|
6
|
+
themeToCss,
|
|
7
|
+
} from "./create-theme";
|
|
8
|
+
|
|
9
|
+
describe("createTheme", () => {
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
document.getElementById("spk-custom-themes")?.remove();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("overlays palette, glass, and shape onto a built-in theme", () => {
|
|
15
|
+
const theme = createTheme({
|
|
16
|
+
id: "brand",
|
|
17
|
+
label: "Brand",
|
|
18
|
+
extends: "neelam",
|
|
19
|
+
palette: { primary: "#22c55e", primaryForeground: "#052e16", chart1: "#4ade80" },
|
|
20
|
+
glass: { blur: "12px" },
|
|
21
|
+
shape: { radius: "0.75rem" },
|
|
22
|
+
typography: { fontFamily: '"Source Sans 3", sans-serif' },
|
|
23
|
+
vars: { "--shadow-soft": "none" },
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
expect(theme.id).toBe("brand");
|
|
27
|
+
expect(theme.label).toBe("Brand");
|
|
28
|
+
expect(theme.extends).toBe("neelam");
|
|
29
|
+
expect(theme.colorScheme).toBe("dark");
|
|
30
|
+
expect(theme.vars["--primary"]).toBe("#22c55e");
|
|
31
|
+
expect(theme.vars["--primary-foreground"]).toBe("#052e16");
|
|
32
|
+
expect(theme.vars["--chart-1"]).toBe("#4ade80");
|
|
33
|
+
expect(theme.vars["--glass-blur"]).toBe("12px");
|
|
34
|
+
expect(theme.vars["--radius"]).toBe("0.75rem");
|
|
35
|
+
expect(theme.vars["--font-sans"]).toBe('"Source Sans 3", sans-serif');
|
|
36
|
+
expect(theme.vars["--shadow-soft"]).toBe("none");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("defaults to a light Mukta base", () => {
|
|
40
|
+
const theme = createTheme({ id: "lilac", palette: { primary: "#7c3aed" } });
|
|
41
|
+
expect(theme.extends).toBe("mukta");
|
|
42
|
+
expect(theme.colorScheme).toBe("light");
|
|
43
|
+
expect(theme.label).toBe("lilac");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("rejects empty and built-in ids", () => {
|
|
47
|
+
expect(() => createTheme({ id: " " })).toThrow(/required/);
|
|
48
|
+
expect(() => createTheme({ id: "neelam" })).toThrow(/built-in/);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("serializes an attribute selector stylesheet", () => {
|
|
52
|
+
const theme = createTheme({
|
|
53
|
+
id: "brand",
|
|
54
|
+
palette: { primary: "#7c3aed" },
|
|
55
|
+
typography: { fontFamily: "Georgia, serif" },
|
|
56
|
+
});
|
|
57
|
+
const css = themeToCss(theme);
|
|
58
|
+
expect(css).toContain('[data-spk-theme="brand"]');
|
|
59
|
+
expect(css).toContain("--primary: #7c3aed;");
|
|
60
|
+
expect(css).toContain("font-family: var(--font-sans);");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("injects and replaces a document stylesheet", () => {
|
|
64
|
+
const brand = createTheme({ id: "brand", palette: { primary: "#7c3aed" } });
|
|
65
|
+
injectCustomThemeStyles([brand]);
|
|
66
|
+
const el = document.getElementById("spk-custom-themes");
|
|
67
|
+
expect(el?.tagName).toBe("STYLE");
|
|
68
|
+
expect(el?.textContent).toContain("--primary: #7c3aed;");
|
|
69
|
+
|
|
70
|
+
injectCustomThemeStyles([]);
|
|
71
|
+
expect(document.getElementById("spk-custom-themes")).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("maps every palette key to a CSS variable", () => {
|
|
75
|
+
expect(PALETTE_CSS_VARS.primary).toBe("--primary");
|
|
76
|
+
expect(PALETTE_CSS_VARS.mutedForeground).toBe("--muted-foreground");
|
|
77
|
+
expect(Object.keys(PALETTE_CSS_VARS).length).toBeGreaterThan(20);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { isDarkTheme, isThemeId, type BuiltinThemeId, type ThemeId } from "./themes";
|
|
2
|
+
|
|
3
|
+
/** Color tokens that map 1:1 onto Spatika CSS variables. */
|
|
4
|
+
export type ThemePalette = {
|
|
5
|
+
background?: string;
|
|
6
|
+
foreground?: string;
|
|
7
|
+
card?: string;
|
|
8
|
+
cardForeground?: string;
|
|
9
|
+
popover?: string;
|
|
10
|
+
popoverForeground?: string;
|
|
11
|
+
primary?: string;
|
|
12
|
+
primaryForeground?: string;
|
|
13
|
+
secondary?: string;
|
|
14
|
+
secondaryForeground?: string;
|
|
15
|
+
muted?: string;
|
|
16
|
+
mutedForeground?: string;
|
|
17
|
+
accent?: string;
|
|
18
|
+
accentForeground?: string;
|
|
19
|
+
destructive?: string;
|
|
20
|
+
destructiveForeground?: string;
|
|
21
|
+
border?: string;
|
|
22
|
+
input?: string;
|
|
23
|
+
inputBackground?: string;
|
|
24
|
+
switchBackground?: string;
|
|
25
|
+
ring?: string;
|
|
26
|
+
sidebar?: string;
|
|
27
|
+
sidebarForeground?: string;
|
|
28
|
+
sidebarPrimary?: string;
|
|
29
|
+
sidebarPrimaryForeground?: string;
|
|
30
|
+
sidebarAccent?: string;
|
|
31
|
+
sidebarAccentForeground?: string;
|
|
32
|
+
sidebarBorder?: string;
|
|
33
|
+
sidebarRing?: string;
|
|
34
|
+
accentInfo?: string;
|
|
35
|
+
accentInfoBg?: string;
|
|
36
|
+
accentHighlight?: string;
|
|
37
|
+
accentHighlightBg?: string;
|
|
38
|
+
accentWarm?: string;
|
|
39
|
+
accentWarmBg?: string;
|
|
40
|
+
accentDangerBg?: string;
|
|
41
|
+
chart1?: string;
|
|
42
|
+
chart2?: string;
|
|
43
|
+
chart3?: string;
|
|
44
|
+
chart4?: string;
|
|
45
|
+
chart5?: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type ThemeGlass = {
|
|
49
|
+
bg?: string;
|
|
50
|
+
panelBg?: string;
|
|
51
|
+
mutedBg?: string;
|
|
52
|
+
headerBg?: string;
|
|
53
|
+
border?: string;
|
|
54
|
+
blur?: string;
|
|
55
|
+
headerBlur?: string;
|
|
56
|
+
menuBlur?: string;
|
|
57
|
+
saturate?: string;
|
|
58
|
+
shadow?: string;
|
|
59
|
+
highlight?: string;
|
|
60
|
+
overlayBg?: string;
|
|
61
|
+
overlayBlur?: string;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export type ThemeShape = {
|
|
65
|
+
radius?: string;
|
|
66
|
+
radiusCard?: string;
|
|
67
|
+
radiusControl?: string;
|
|
68
|
+
radiusPill?: string;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export type ThemeTypography = {
|
|
72
|
+
fontFamily?: string;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export type CreateThemeOptions = {
|
|
76
|
+
/** Unique id written to `data-spk-theme`. Must not collide with a built-in id. */
|
|
77
|
+
id: string;
|
|
78
|
+
label?: string;
|
|
79
|
+
/** Built-in theme whose CSS variables are inherited, then overlaid. */
|
|
80
|
+
extends?: BuiltinThemeId;
|
|
81
|
+
colorScheme?: "light" | "dark";
|
|
82
|
+
palette?: ThemePalette;
|
|
83
|
+
glass?: ThemeGlass;
|
|
84
|
+
shape?: ThemeShape;
|
|
85
|
+
typography?: ThemeTypography;
|
|
86
|
+
/** Escape hatch: raw CSS custom properties, e.g. `{ "--shadow-soft": "none" }`. */
|
|
87
|
+
vars?: Record<string, string>;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export type SpatikaTheme = {
|
|
91
|
+
id: string;
|
|
92
|
+
label: string;
|
|
93
|
+
extends: BuiltinThemeId;
|
|
94
|
+
colorScheme: "light" | "dark";
|
|
95
|
+
vars: Record<string, string>;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export const PALETTE_CSS_VARS = {
|
|
99
|
+
background: "--background",
|
|
100
|
+
foreground: "--foreground",
|
|
101
|
+
card: "--card",
|
|
102
|
+
cardForeground: "--card-foreground",
|
|
103
|
+
popover: "--popover",
|
|
104
|
+
popoverForeground: "--popover-foreground",
|
|
105
|
+
primary: "--primary",
|
|
106
|
+
primaryForeground: "--primary-foreground",
|
|
107
|
+
secondary: "--secondary",
|
|
108
|
+
secondaryForeground: "--secondary-foreground",
|
|
109
|
+
muted: "--muted",
|
|
110
|
+
mutedForeground: "--muted-foreground",
|
|
111
|
+
accent: "--accent",
|
|
112
|
+
accentForeground: "--accent-foreground",
|
|
113
|
+
destructive: "--destructive",
|
|
114
|
+
destructiveForeground: "--destructive-foreground",
|
|
115
|
+
border: "--border",
|
|
116
|
+
input: "--input",
|
|
117
|
+
inputBackground: "--input-background",
|
|
118
|
+
switchBackground: "--switch-background",
|
|
119
|
+
ring: "--ring",
|
|
120
|
+
sidebar: "--sidebar",
|
|
121
|
+
sidebarForeground: "--sidebar-foreground",
|
|
122
|
+
sidebarPrimary: "--sidebar-primary",
|
|
123
|
+
sidebarPrimaryForeground: "--sidebar-primary-foreground",
|
|
124
|
+
sidebarAccent: "--sidebar-accent",
|
|
125
|
+
sidebarAccentForeground: "--sidebar-accent-foreground",
|
|
126
|
+
sidebarBorder: "--sidebar-border",
|
|
127
|
+
sidebarRing: "--sidebar-ring",
|
|
128
|
+
accentInfo: "--accent-info",
|
|
129
|
+
accentInfoBg: "--accent-info-bg",
|
|
130
|
+
accentHighlight: "--accent-highlight",
|
|
131
|
+
accentHighlightBg: "--accent-highlight-bg",
|
|
132
|
+
accentWarm: "--accent-warm",
|
|
133
|
+
accentWarmBg: "--accent-warm-bg",
|
|
134
|
+
accentDangerBg: "--accent-danger-bg",
|
|
135
|
+
chart1: "--chart-1",
|
|
136
|
+
chart2: "--chart-2",
|
|
137
|
+
chart3: "--chart-3",
|
|
138
|
+
chart4: "--chart-4",
|
|
139
|
+
chart5: "--chart-5",
|
|
140
|
+
} as const satisfies Record<keyof ThemePalette, string>;
|
|
141
|
+
|
|
142
|
+
export const GLASS_CSS_VARS = {
|
|
143
|
+
bg: "--glass-bg",
|
|
144
|
+
panelBg: "--glass-panel-bg",
|
|
145
|
+
mutedBg: "--glass-muted-bg",
|
|
146
|
+
headerBg: "--glass-header-bg",
|
|
147
|
+
border: "--glass-border",
|
|
148
|
+
blur: "--glass-blur",
|
|
149
|
+
headerBlur: "--glass-header-blur",
|
|
150
|
+
menuBlur: "--glass-menu-blur",
|
|
151
|
+
saturate: "--glass-saturate",
|
|
152
|
+
shadow: "--glass-shadow",
|
|
153
|
+
highlight: "--glass-highlight",
|
|
154
|
+
overlayBg: "--modal-overlay-bg",
|
|
155
|
+
overlayBlur: "--modal-overlay-blur",
|
|
156
|
+
} as const satisfies Record<keyof ThemeGlass, string>;
|
|
157
|
+
|
|
158
|
+
export const SHAPE_CSS_VARS = {
|
|
159
|
+
radius: "--radius",
|
|
160
|
+
radiusCard: "--radius-card",
|
|
161
|
+
radiusControl: "--radius-control",
|
|
162
|
+
radiusPill: "--radius-pill",
|
|
163
|
+
} as const satisfies Record<keyof ThemeShape, string>;
|
|
164
|
+
|
|
165
|
+
const CUSTOM_THEME_STYLE_ID = "spk-custom-themes";
|
|
166
|
+
|
|
167
|
+
function assignMappedVars<T extends Record<string, string>>(
|
|
168
|
+
vars: Record<string, string>,
|
|
169
|
+
source: object | undefined,
|
|
170
|
+
map: T,
|
|
171
|
+
) {
|
|
172
|
+
if (!source) return;
|
|
173
|
+
for (const [key, cssVar] of Object.entries(map)) {
|
|
174
|
+
const value = (source as Record<string, string | undefined>)[key];
|
|
175
|
+
if (typeof value === "string" && value.length > 0) vars[cssVar] = value;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Build a custom theme that overlays CSS variables on a built-in Spatika theme. */
|
|
180
|
+
export function createTheme(options: CreateThemeOptions): SpatikaTheme {
|
|
181
|
+
const id = options.id.trim();
|
|
182
|
+
if (!id) {
|
|
183
|
+
throw new Error("createTheme: `id` is required.");
|
|
184
|
+
}
|
|
185
|
+
if (isThemeId(id)) {
|
|
186
|
+
throw new Error(`createTheme: "${id}" is a built-in theme. Choose a different id.`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const base: BuiltinThemeId = options.extends ?? "mukta";
|
|
190
|
+
const colorScheme = options.colorScheme ?? (isDarkTheme(base) ? "dark" : "light");
|
|
191
|
+
const vars: Record<string, string> = {};
|
|
192
|
+
|
|
193
|
+
assignMappedVars(vars, options.palette, PALETTE_CSS_VARS);
|
|
194
|
+
assignMappedVars(vars, options.glass, GLASS_CSS_VARS);
|
|
195
|
+
assignMappedVars(vars, options.shape, SHAPE_CSS_VARS);
|
|
196
|
+
|
|
197
|
+
if (options.typography?.fontFamily) {
|
|
198
|
+
vars["--font-sans"] = options.typography.fontFamily;
|
|
199
|
+
}
|
|
200
|
+
if (options.vars) {
|
|
201
|
+
for (const [key, value] of Object.entries(options.vars)) {
|
|
202
|
+
if (value) vars[key] = value;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
id,
|
|
208
|
+
label: options.label?.trim() || id,
|
|
209
|
+
extends: base,
|
|
210
|
+
colorScheme,
|
|
211
|
+
vars,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Serialize a custom theme as an attribute selector rule. */
|
|
216
|
+
export function themeToCss(theme: SpatikaTheme): string {
|
|
217
|
+
const entries = Object.entries(theme.vars);
|
|
218
|
+
if (!entries.length) {
|
|
219
|
+
return `[data-spk-theme="${theme.id}"] {\n}\n`;
|
|
220
|
+
}
|
|
221
|
+
const decls = entries.map(([key, value]) => ` ${key}: ${value};`).join("\n");
|
|
222
|
+
const font = theme.vars["--font-sans"]
|
|
223
|
+
? `\n font-family: var(--font-sans);`
|
|
224
|
+
: "";
|
|
225
|
+
return `[data-spk-theme="${theme.id}"] {\n${decls}${font}\n}\n`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Inject (or replace) a single stylesheet for every registered custom theme. */
|
|
229
|
+
export function injectCustomThemeStyles(themes: readonly SpatikaTheme[]): void {
|
|
230
|
+
if (typeof document === "undefined") return;
|
|
231
|
+
let el = document.getElementById(CUSTOM_THEME_STYLE_ID) as HTMLStyleElement | null;
|
|
232
|
+
if (!themes.length) {
|
|
233
|
+
el?.remove();
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (!el) {
|
|
237
|
+
el = document.createElement("style");
|
|
238
|
+
el.id = CUSTOM_THEME_STYLE_ID;
|
|
239
|
+
document.head.appendChild(el);
|
|
240
|
+
}
|
|
241
|
+
el.textContent = themes.map(themeToCss).join("\n");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function findCustomTheme(
|
|
245
|
+
theme: ThemeId,
|
|
246
|
+
customThemes: readonly SpatikaTheme[] | undefined,
|
|
247
|
+
): SpatikaTheme | undefined {
|
|
248
|
+
return customThemes?.find((item) => item.id === theme);
|
|
249
|
+
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import {
|
|
3
3
|
addDays,
|
|
4
|
+
calendarVisibleRange,
|
|
5
|
+
CALENDAR_AGENDA_DAYS,
|
|
6
|
+
eventColorStyle,
|
|
4
7
|
formatDateRange,
|
|
5
8
|
getMonthGrid,
|
|
6
9
|
getTimelineRange,
|
|
@@ -35,6 +38,20 @@ describe("scheduler dates", () => {
|
|
|
35
38
|
expect(grid[0].getMonth()).toBe(6);
|
|
36
39
|
});
|
|
37
40
|
|
|
41
|
+
it("reports the exclusive visible range for month, week, and agenda", () => {
|
|
42
|
+
const month = calendarVisibleRange("month", new Date(2026, 7, 15), 0);
|
|
43
|
+
expect(month.start.getDate()).toBe(26);
|
|
44
|
+
expect(month.start.getMonth()).toBe(6);
|
|
45
|
+
expect(month.end.getTime()).toBe(addDays(getMonthGrid(new Date(2026, 7, 15), 0)[41], 1).getTime());
|
|
46
|
+
|
|
47
|
+
const week = calendarVisibleRange("week", new Date(2026, 7, 15), 0);
|
|
48
|
+
expect(week.start.getDay()).toBe(0);
|
|
49
|
+
expect(week.end.getTime() - week.start.getTime()).toBe(7 * 86_400_000);
|
|
50
|
+
|
|
51
|
+
const agenda = calendarVisibleRange("agenda", new Date(2026, 7, 3), 0);
|
|
52
|
+
expect(agenda.end.getTime() - agenda.start.getTime()).toBe(CALENDAR_AGENDA_DAYS * 86_400_000);
|
|
53
|
+
});
|
|
54
|
+
|
|
38
55
|
it("returns Sunday-first weekday labels by default", () => {
|
|
39
56
|
const labels = weekdayLabels(0, "en-US");
|
|
40
57
|
expect(labels[0]).toMatch(/^Sun/);
|
|
@@ -197,3 +214,12 @@ describe("scheduler formatting", () => {
|
|
|
197
214
|
expect(lastInstant(event).getDate()).toBe(3);
|
|
198
215
|
});
|
|
199
216
|
});
|
|
217
|
+
|
|
218
|
+
describe("eventColorStyle", () => {
|
|
219
|
+
it("uses solid bar fills with light labels for month-spanning events", () => {
|
|
220
|
+
expect(eventColorStyle("rose", "bar")).toEqual({
|
|
221
|
+
backgroundColor: "var(--destructive)",
|
|
222
|
+
color: "#ffffff",
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
});
|
package/src/lib/scheduler.ts
CHANGED
|
@@ -187,6 +187,40 @@ export function getWeekDays(date: Date, weekStartsOn = 0): Date[] {
|
|
|
187
187
|
return Array.from({ length: 7 }, (_, i) => addDays(start, i));
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
+
/** Inclusive first instant of the agenda window. */
|
|
191
|
+
export const CALENDAR_AGENDA_DAYS = 14;
|
|
192
|
+
|
|
193
|
+
export type CalendarVisibleRange = {
|
|
194
|
+
start: Date;
|
|
195
|
+
/** Exclusive end (start of the day after the last visible day). */
|
|
196
|
+
end: Date;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Days the calendar actually paints for a view.
|
|
201
|
+
* Month includes leading/trailing grid days, not just the named month.
|
|
202
|
+
*/
|
|
203
|
+
export function calendarVisibleRange(
|
|
204
|
+
view: CalendarView,
|
|
205
|
+
date: Date,
|
|
206
|
+
weekStartsOn = 0,
|
|
207
|
+
): CalendarVisibleRange {
|
|
208
|
+
if (view === "month") {
|
|
209
|
+
const grid = getMonthGrid(date, weekStartsOn);
|
|
210
|
+
return { start: grid[0], end: addDays(grid[grid.length - 1], 1) };
|
|
211
|
+
}
|
|
212
|
+
if (view === "week") {
|
|
213
|
+
const days = getWeekDays(date, weekStartsOn);
|
|
214
|
+
return { start: days[0], end: addDays(days[6], 1) };
|
|
215
|
+
}
|
|
216
|
+
if (view === "agenda") {
|
|
217
|
+
const days = agendaDays(date, CALENDAR_AGENDA_DAYS);
|
|
218
|
+
return { start: days[0], end: addDays(days[days.length - 1], 1) };
|
|
219
|
+
}
|
|
220
|
+
const start = startOfDay(date);
|
|
221
|
+
return { start, end: addDays(start, 1) };
|
|
222
|
+
}
|
|
223
|
+
|
|
190
224
|
export function weekdayLabels(weekStartsOn = 0, locale?: string): string[] {
|
|
191
225
|
const sunday = new Date(2026, 7, 2);
|
|
192
226
|
return Array.from({ length: 7 }, (_, i) => {
|
|
@@ -669,8 +703,8 @@ export function eventColorStyle(
|
|
|
669
703
|
if (variant === "dot") return { backgroundColor: color };
|
|
670
704
|
if (variant === "bar") {
|
|
671
705
|
return {
|
|
672
|
-
backgroundColor:
|
|
673
|
-
color: "
|
|
706
|
+
backgroundColor: color,
|
|
707
|
+
color: "#ffffff",
|
|
674
708
|
};
|
|
675
709
|
}
|
|
676
710
|
if (variant === "wash") {
|
package/src/lib/themes.test.ts
CHANGED
|
@@ -63,6 +63,17 @@ describe("themes", () => {
|
|
|
63
63
|
expect(document.documentElement.classList.contains("sandhya")).toBe(true);
|
|
64
64
|
});
|
|
65
65
|
|
|
66
|
+
it("applies a custom theme id on top of its base", () => {
|
|
67
|
+
applyTheme("brand", undefined, {
|
|
68
|
+
id: "brand",
|
|
69
|
+
extends: "neelam",
|
|
70
|
+
colorScheme: "dark",
|
|
71
|
+
});
|
|
72
|
+
expect(document.documentElement.classList.contains("neelam")).toBe(true);
|
|
73
|
+
expect(document.documentElement.getAttribute("data-spk-theme")).toBe("brand");
|
|
74
|
+
expect(document.documentElement.style.colorScheme).toBe("dark");
|
|
75
|
+
});
|
|
76
|
+
|
|
66
77
|
it("reads and writes stored themes", () => {
|
|
67
78
|
expect(readStoredTheme(SPATIKA_THEME_STORAGE_KEY)).toBeNull();
|
|
68
79
|
writeStoredTheme(SPATIKA_THEME_STORAGE_KEY, "sandhya");
|
|
@@ -70,4 +81,10 @@ describe("themes", () => {
|
|
|
70
81
|
writeStoredTheme(false, "neelam");
|
|
71
82
|
expect(readStoredTheme(SPATIKA_THEME_STORAGE_KEY)).toBe("sandhya");
|
|
72
83
|
});
|
|
84
|
+
|
|
85
|
+
it("restores a custom stored theme when it is allowed", () => {
|
|
86
|
+
writeStoredTheme(SPATIKA_THEME_STORAGE_KEY, "brand");
|
|
87
|
+
expect(readStoredTheme(SPATIKA_THEME_STORAGE_KEY)).toBeNull();
|
|
88
|
+
expect(readStoredTheme(SPATIKA_THEME_STORAGE_KEY, ["brand"])).toBe("brand");
|
|
89
|
+
});
|
|
73
90
|
});
|
package/src/lib/themes.ts
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
|
-
/** Supported theme identifiers applied as classes on a root element. */
|
|
1
|
+
/** Supported built-in theme identifiers applied as classes on a root element. */
|
|
2
2
|
export const THEME_IDS = ["mukta", "neelam", "usha", "sandhya"] as const;
|
|
3
3
|
|
|
4
|
-
export type
|
|
4
|
+
export type BuiltinThemeId = (typeof THEME_IDS)[number];
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Built-in ids plus custom ids from `createTheme`.
|
|
8
|
+
* Custom strings keep autocomplete for Mukta / Neelam / Usha / Sandhya.
|
|
9
|
+
*/
|
|
10
|
+
export type ThemeId = BuiltinThemeId | (string & {});
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_THEME: BuiltinThemeId = "mukta";
|
|
7
13
|
|
|
8
14
|
/** Default `localStorage` key used by the docs site and `SpatikaThemeProvider`. */
|
|
9
15
|
export const SPATIKA_THEME_STORAGE_KEY = "spk-theme";
|
|
10
16
|
|
|
11
|
-
export const THEME_LABELS: Record<
|
|
17
|
+
export const THEME_LABELS: Record<BuiltinThemeId, string> = {
|
|
12
18
|
mukta: "Mukta",
|
|
13
19
|
neelam: "Neelam",
|
|
14
20
|
usha: "Usha",
|
|
@@ -17,7 +23,7 @@ export const THEME_LABELS: Record<ThemeId, string> = {
|
|
|
17
23
|
|
|
18
24
|
const THEME_CLASS_LIST = ["neelam", "usha", "sandhya"] as const;
|
|
19
25
|
|
|
20
|
-
export function isThemeId(value: unknown): value is
|
|
26
|
+
export function isThemeId(value: unknown): value is BuiltinThemeId {
|
|
21
27
|
return typeof value === "string" && (THEME_IDS as readonly string[]).includes(value);
|
|
22
28
|
}
|
|
23
29
|
|
|
@@ -25,12 +31,24 @@ export function isDarkTheme(theme: ThemeId): boolean {
|
|
|
25
31
|
return theme === "neelam" || theme === "sandhya";
|
|
26
32
|
}
|
|
27
33
|
|
|
34
|
+
export type ApplyThemeCustom = {
|
|
35
|
+
id: string;
|
|
36
|
+
extends: BuiltinThemeId;
|
|
37
|
+
colorScheme: "light" | "dark";
|
|
38
|
+
};
|
|
39
|
+
|
|
28
40
|
/** Read a persisted theme. Returns `null` when missing or invalid. */
|
|
29
|
-
export function readStoredTheme(
|
|
41
|
+
export function readStoredTheme(
|
|
42
|
+
storageKey?: string | false,
|
|
43
|
+
allowed?: readonly string[],
|
|
44
|
+
): ThemeId | null {
|
|
30
45
|
if (!storageKey || typeof window === "undefined") return null;
|
|
31
46
|
try {
|
|
32
47
|
const value = window.localStorage.getItem(storageKey);
|
|
33
|
-
|
|
48
|
+
if (!value) return null;
|
|
49
|
+
if (isThemeId(value)) return value;
|
|
50
|
+
if (allowed?.includes(value)) return value;
|
|
51
|
+
return null;
|
|
34
52
|
} catch {
|
|
35
53
|
return null;
|
|
36
54
|
}
|
|
@@ -46,14 +64,27 @@ export function writeStoredTheme(storageKey: string | false | undefined, theme:
|
|
|
46
64
|
}
|
|
47
65
|
}
|
|
48
66
|
|
|
67
|
+
function resolveBase(theme: ThemeId, custom?: ApplyThemeCustom | null): BuiltinThemeId {
|
|
68
|
+
if (custom) return custom.extends;
|
|
69
|
+
return isThemeId(theme) ? theme : DEFAULT_THEME;
|
|
70
|
+
}
|
|
71
|
+
|
|
49
72
|
/** Apply a theme class to an element (defaults to `<html>`). */
|
|
50
|
-
export function applyTheme(
|
|
73
|
+
export function applyTheme(
|
|
74
|
+
theme: ThemeId,
|
|
75
|
+
target?: HTMLElement | null,
|
|
76
|
+
custom?: ApplyThemeCustom | null,
|
|
77
|
+
) {
|
|
51
78
|
if (typeof document === "undefined") return;
|
|
52
79
|
const root = target ?? document.documentElement;
|
|
80
|
+
const base = resolveBase(theme, custom);
|
|
81
|
+
const id = custom?.id ?? theme;
|
|
82
|
+
|
|
53
83
|
root.classList.remove(...THEME_CLASS_LIST);
|
|
54
|
-
if (
|
|
55
|
-
root.classList.add(
|
|
84
|
+
if (base !== DEFAULT_THEME) {
|
|
85
|
+
root.classList.add(base);
|
|
56
86
|
}
|
|
57
|
-
root.setAttribute("data-spk-theme",
|
|
58
|
-
|
|
87
|
+
root.setAttribute("data-spk-theme", id);
|
|
88
|
+
const dark = custom ? custom.colorScheme === "dark" : isDarkTheme(base);
|
|
89
|
+
root.style.colorScheme = dark ? "dark" : "light";
|
|
59
90
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
breakpointDown,
|
|
5
|
+
breakpointUp,
|
|
6
|
+
useBreakpoint,
|
|
7
|
+
useBreakpointDown,
|
|
8
|
+
useBreakpointUp,
|
|
9
|
+
useIsMobile,
|
|
10
|
+
useMediaQuery,
|
|
11
|
+
} from "./use-media-query";
|
|
12
|
+
|
|
13
|
+
function Probe({ query }: { query: string }) {
|
|
14
|
+
const matches = useMediaQuery(query);
|
|
15
|
+
return <span data-testid="mq">{String(matches)}</span>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function BreakpointProbe() {
|
|
19
|
+
const current = useBreakpoint();
|
|
20
|
+
const mdUp = useBreakpointUp("md");
|
|
21
|
+
const mdDown = useBreakpointDown("md");
|
|
22
|
+
const mobile = useIsMobile();
|
|
23
|
+
return (
|
|
24
|
+
<div>
|
|
25
|
+
<span data-testid="bp">{current}</span>
|
|
26
|
+
<span data-testid="up">{String(mdUp)}</span>
|
|
27
|
+
<span data-testid="down">{String(mdDown)}</span>
|
|
28
|
+
<span data-testid="mobile">{String(mobile)}</span>
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function mockMatchMedia(matching: (query: string) => boolean) {
|
|
34
|
+
Object.defineProperty(window, "matchMedia", {
|
|
35
|
+
writable: true,
|
|
36
|
+
value: (query: string) => ({
|
|
37
|
+
matches: matching(query),
|
|
38
|
+
media: query,
|
|
39
|
+
addEventListener: vi.fn(),
|
|
40
|
+
removeEventListener: vi.fn(),
|
|
41
|
+
addListener: vi.fn(),
|
|
42
|
+
removeListener: vi.fn(),
|
|
43
|
+
}),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("useMediaQuery", () => {
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
cleanup();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("is false until mounted, then follows matchMedia", () => {
|
|
53
|
+
mockMatchMedia((query) => query.includes("900"));
|
|
54
|
+
render(<Probe query="(min-width: 900px)" />);
|
|
55
|
+
expect(screen.getByTestId("mq")).toHaveTextContent("true");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("reports the current breakpoint and aliases", () => {
|
|
59
|
+
mockMatchMedia((query) => {
|
|
60
|
+
if (query === breakpointUp("sm")) return true;
|
|
61
|
+
if (query === breakpointUp("md")) return true;
|
|
62
|
+
if (query === breakpointUp("lg")) return false;
|
|
63
|
+
if (query === breakpointUp("xl")) return false;
|
|
64
|
+
if (query === breakpointDown("md")) return false;
|
|
65
|
+
return false;
|
|
66
|
+
});
|
|
67
|
+
render(<BreakpointProbe />);
|
|
68
|
+
expect(screen.getByTestId("bp")).toHaveTextContent("md");
|
|
69
|
+
expect(screen.getByTestId("up")).toHaveTextContent("true");
|
|
70
|
+
expect(screen.getByTestId("down")).toHaveTextContent("false");
|
|
71
|
+
expect(screen.getByTestId("mobile")).toHaveTextContent("false");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("treats viewports below md as mobile", () => {
|
|
75
|
+
mockMatchMedia((query) => query === breakpointDown("md"));
|
|
76
|
+
render(<BreakpointProbe />);
|
|
77
|
+
expect(screen.getByTestId("bp")).toHaveTextContent("xs");
|
|
78
|
+
expect(screen.getByTestId("mobile")).toHaveTextContent("true");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import { useEffect, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
BREAKPOINT_KEYS,
|
|
4
|
+
BREAKPOINTS,
|
|
5
|
+
breakpointDown,
|
|
6
|
+
breakpointUp,
|
|
7
|
+
type Breakpoint,
|
|
8
|
+
} from "./breakpoints";
|
|
2
9
|
|
|
3
10
|
/** Subscribe to a CSS media query. Defaults to `false` until mounted. */
|
|
4
11
|
export function useMediaQuery(query: string): boolean {
|
|
@@ -18,5 +25,32 @@ export function useMediaQuery(query: string): boolean {
|
|
|
18
25
|
|
|
19
26
|
/** True when the viewport is below the `md` breakpoint (768px). */
|
|
20
27
|
export function useIsMobile(): boolean {
|
|
21
|
-
return useMediaQuery(
|
|
28
|
+
return useMediaQuery(breakpointDown("md"));
|
|
22
29
|
}
|
|
30
|
+
|
|
31
|
+
/** True from this breakpoint up (`xs` is always true). */
|
|
32
|
+
export function useBreakpointUp(key: Breakpoint): boolean {
|
|
33
|
+
const matches = useMediaQuery(breakpointUp(key));
|
|
34
|
+
return key === "xs" || matches;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** True strictly below this breakpoint. */
|
|
38
|
+
export function useBreakpointDown(key: Exclude<Breakpoint, "xs">): boolean {
|
|
39
|
+
return useMediaQuery(breakpointDown(key));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Largest matching breakpoint, starting at `xs`. */
|
|
43
|
+
export function useBreakpoint(): Breakpoint {
|
|
44
|
+
const sm = useMediaQuery(breakpointUp("sm"));
|
|
45
|
+
const md = useMediaQuery(breakpointUp("md"));
|
|
46
|
+
const lg = useMediaQuery(breakpointUp("lg"));
|
|
47
|
+
const xl = useMediaQuery(breakpointUp("xl"));
|
|
48
|
+
if (xl) return "xl";
|
|
49
|
+
if (lg) return "lg";
|
|
50
|
+
if (md) return "md";
|
|
51
|
+
if (sm) return "sm";
|
|
52
|
+
return "xs";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export { BREAKPOINTS, BREAKPOINT_KEYS, breakpointDown, breakpointUp };
|
|
56
|
+
export type { Breakpoint };
|