@spatika/react 1.4.0 → 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.js +3 -3
- 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/index.d.ts +8 -22
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -11
- 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/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.js +2 -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/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 +25 -6
- package/src/composites/EventCalendar.test.tsx +293 -0
- package/src/composites/EventCalendar.tsx +3 -3
- 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.test.tsx +0 -258
- package/src/index.test.ts +6 -0
- package/src/index.ts +138 -72
- package/src/lib/breakpoints.test.ts +29 -0
- package/src/lib/breakpoints.ts +28 -0
- package/src/lib/create-theme.test.ts +79 -0
- package/src/lib/create-theme.ts +249 -0
- package/src/lib/scheduler.test.ts +10 -0
- package/src/lib/scheduler.ts +2 -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/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 -136
- package/src/composites/cartesian-charts.tsx +0 -1355
- package/src/composites/chart-composition.test.tsx +0 -68
- package/src/composites/chart-composition.tsx +0 -1039
- package/src/composites/chart-interaction.ts +0 -136
- package/src/composites/chart-ui.tsx +0 -336
- 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 -98
- package/src/composites/flow-charts.tsx +0 -641
- package/src/composites/radial-charts.test.tsx +0 -89
- package/src/composites/radial-charts.tsx +0 -643
- 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
|
+
}
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
addDays,
|
|
4
4
|
calendarVisibleRange,
|
|
5
5
|
CALENDAR_AGENDA_DAYS,
|
|
6
|
+
eventColorStyle,
|
|
6
7
|
formatDateRange,
|
|
7
8
|
getMonthGrid,
|
|
8
9
|
getTimelineRange,
|
|
@@ -213,3 +214,12 @@ describe("scheduler formatting", () => {
|
|
|
213
214
|
expect(lastInstant(event).getDate()).toBe(3);
|
|
214
215
|
});
|
|
215
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
|
@@ -703,8 +703,8 @@ export function eventColorStyle(
|
|
|
703
703
|
if (variant === "dot") return { backgroundColor: color };
|
|
704
704
|
if (variant === "bar") {
|
|
705
705
|
return {
|
|
706
|
-
backgroundColor:
|
|
707
|
-
color: "
|
|
706
|
+
backgroundColor: color,
|
|
707
|
+
color: "#ffffff",
|
|
708
708
|
};
|
|
709
709
|
}
|
|
710
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 };
|
|
@@ -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
|
+
});
|