@spatika/react 1.3.0 → 1.4.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/README.md +6 -2
- package/dist/composites/EventCalendar.d.ts +13 -2
- package/dist/composites/EventCalendar.d.ts.map +1 -1
- package/dist/composites/EventCalendar.js +10 -24
- 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 +8 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -4
- 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/scheduler.d.ts +12 -0
- package/dist/lib/scheduler.d.ts.map +1 -1
- package/dist/lib/scheduler.js +22 -0
- 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/package.json +3 -3
- package/skills/spatika-ui/SKILL.md +4 -0
- package/src/composites/EventCalendar.tsx +35 -24
- package/src/composites/cartesian-charts.test.tsx +86 -2
- package/src/composites/cartesian-charts.tsx +84 -16
- package/src/composites/chart-composition.test.tsx +15 -0
- package/src/composites/chart-composition.tsx +229 -88
- package/src/composites/chart-interaction.ts +136 -0
- package/src/composites/chart-ui.tsx +35 -27
- package/src/composites/flow-charts.test.tsx +21 -2
- package/src/composites/flow-charts.tsx +59 -25
- package/src/composites/radial-charts.test.tsx +17 -2
- package/src/composites/radial-charts.tsx +38 -12
- package/src/composites/scheduler-ui.tsx +128 -26
- package/src/composites/scheduler.test.tsx +39 -0
- package/src/index.test.ts +4 -0
- package/src/index.ts +18 -1
- package/src/lib/chips.ts +16 -0
- package/src/lib/scheduler.test.ts +16 -0
- package/src/lib/scheduler.ts +34 -0
- package/src/primitives/NativeSelect.test.tsx +28 -0
- package/src/primitives/NativeSelect.tsx +14 -4
|
@@ -14,12 +14,12 @@ import {
|
|
|
14
14
|
type ChartAxisConfig,
|
|
15
15
|
type ChartMargin,
|
|
16
16
|
} from "../lib/charts";
|
|
17
|
+
import {
|
|
18
|
+
type ChartHover,
|
|
19
|
+
type ChartTooltipRenderer,
|
|
20
|
+
} from "./chart-interaction";
|
|
17
21
|
|
|
18
|
-
export type ChartTooltipItem
|
|
19
|
-
color: string;
|
|
20
|
-
label: string;
|
|
21
|
-
value: string;
|
|
22
|
-
};
|
|
22
|
+
export type { ChartHover, ChartTooltipItem } from "./chart-interaction";
|
|
23
23
|
|
|
24
24
|
export type ChartLegendItem = {
|
|
25
25
|
id: string;
|
|
@@ -27,23 +27,19 @@ export type ChartLegendItem = {
|
|
|
27
27
|
color: string;
|
|
28
28
|
};
|
|
29
29
|
|
|
30
|
-
export type ChartHover = {
|
|
31
|
-
x: number;
|
|
32
|
-
y: number;
|
|
33
|
-
title?: string;
|
|
34
|
-
items: ChartTooltipItem[];
|
|
35
|
-
} | null;
|
|
36
|
-
|
|
37
30
|
export type ChartFrameProps = {
|
|
38
31
|
slot: string;
|
|
39
32
|
className?: string;
|
|
40
33
|
width?: number;
|
|
41
34
|
height?: number;
|
|
35
|
+
/** When true, plot height follows the parent instead of the numeric `height`. */
|
|
36
|
+
fillHeight?: boolean;
|
|
42
37
|
margin?: ChartMargin;
|
|
43
38
|
legend?: ChartLegendItem[];
|
|
44
39
|
hiddenIds?: Set<string>;
|
|
45
40
|
onToggleSeries?: (id: string) => void;
|
|
46
41
|
hover?: ChartHover;
|
|
42
|
+
renderTooltip?: ChartTooltipRenderer;
|
|
47
43
|
"aria-label"?: string;
|
|
48
44
|
children: (plot: { width: number; height: number; m: Required<ChartMargin> }) => ReactNode;
|
|
49
45
|
};
|
|
@@ -88,24 +84,26 @@ export function ChartFrame({
|
|
|
88
84
|
className,
|
|
89
85
|
width,
|
|
90
86
|
height = 280,
|
|
87
|
+
fillHeight = false,
|
|
91
88
|
margin,
|
|
92
89
|
legend,
|
|
93
90
|
hiddenIds,
|
|
94
91
|
onToggleSeries,
|
|
95
92
|
hover,
|
|
93
|
+
renderTooltip,
|
|
96
94
|
"aria-label": ariaLabel,
|
|
97
95
|
children,
|
|
98
96
|
}: ChartFrameProps) {
|
|
99
97
|
const surfaceRef = useRef<HTMLDivElement>(null);
|
|
100
98
|
const measured = useElementSize(surfaceRef, width ?? 320, height);
|
|
101
99
|
const plotWidth = width ?? measured.width;
|
|
102
|
-
const plotHeight = height;
|
|
100
|
+
const plotHeight = fillHeight ? measured.height : height;
|
|
103
101
|
const m = resolveMargin(margin);
|
|
104
102
|
|
|
105
103
|
return (
|
|
106
104
|
<div
|
|
107
105
|
data-slot={slot}
|
|
108
|
-
className={cn("spk-chart", className)}
|
|
106
|
+
className={cn("spk-chart", fillHeight && "spk-chart--fill", className)}
|
|
109
107
|
role="img"
|
|
110
108
|
aria-label={ariaLabel}
|
|
111
109
|
>
|
|
@@ -115,10 +113,10 @@ export function ChartFrame({
|
|
|
115
113
|
<div
|
|
116
114
|
ref={surfaceRef}
|
|
117
115
|
className="spk-chart-surface"
|
|
118
|
-
style={{ height: plotHeight }}
|
|
116
|
+
style={fillHeight ? undefined : { height: plotHeight }}
|
|
119
117
|
>
|
|
120
|
-
{plotWidth > 0 ? children({ width: plotWidth, height: plotHeight, m }) : null}
|
|
121
|
-
<ChartTooltip hover={hover ?? null} boundsWidth={plotWidth} />
|
|
118
|
+
{plotWidth > 0 && plotHeight > 0 ? children({ width: plotWidth, height: plotHeight, m }) : null}
|
|
119
|
+
<ChartTooltip hover={hover ?? null} boundsWidth={plotWidth} render={renderTooltip} />
|
|
122
120
|
</div>
|
|
123
121
|
</div>
|
|
124
122
|
);
|
|
@@ -154,24 +152,34 @@ export function ChartLegend({
|
|
|
154
152
|
export function ChartTooltip({
|
|
155
153
|
hover,
|
|
156
154
|
boundsWidth,
|
|
155
|
+
render,
|
|
157
156
|
}: {
|
|
158
157
|
hover: ChartHover;
|
|
159
158
|
boundsWidth: number;
|
|
159
|
+
render?: ChartTooltipRenderer;
|
|
160
160
|
}) {
|
|
161
161
|
if (!hover || !hover.items.length) return null;
|
|
162
|
+
const custom = render?.(hover);
|
|
163
|
+
if (render && custom == null) return null;
|
|
162
164
|
const left = clampTooltip(hover.x, boundsWidth);
|
|
163
165
|
return (
|
|
164
166
|
<div className="spk-chart-tooltip" style={{ left, top: hover.y }} role="tooltip">
|
|
165
|
-
{
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
167
|
+
{render ? (
|
|
168
|
+
custom
|
|
169
|
+
) : (
|
|
170
|
+
<>
|
|
171
|
+
{hover.title ? <p className="spk-chart-tooltip-title">{hover.title}</p> : null}
|
|
172
|
+
<ul>
|
|
173
|
+
{hover.items.map((item) => (
|
|
174
|
+
<li key={item.label}>
|
|
175
|
+
<span className="spk-chart-swatch" style={{ background: item.color }} />
|
|
176
|
+
<span>{item.label}</span>
|
|
177
|
+
<strong>{item.value}</strong>
|
|
178
|
+
</li>
|
|
179
|
+
))}
|
|
180
|
+
</ul>
|
|
181
|
+
</>
|
|
182
|
+
)}
|
|
175
183
|
</div>
|
|
176
184
|
);
|
|
177
185
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { render, screen } from "@testing-library/react";
|
|
2
|
-
import { describe, expect, it } from "vitest";
|
|
1
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
3
|
import { FunnelChart, Heatmap } from "./flow-charts";
|
|
4
4
|
|
|
5
5
|
describe("FunnelChart", () => {
|
|
@@ -76,4 +76,23 @@ describe("Heatmap", () => {
|
|
|
76
76
|
);
|
|
77
77
|
expect(container.querySelector('[data-slot="heatmap-color-scale"]')).toBeNull();
|
|
78
78
|
});
|
|
79
|
+
|
|
80
|
+
it("fires onItemClick and can label cells", () => {
|
|
81
|
+
const onItemClick = vi.fn();
|
|
82
|
+
const { container } = render(
|
|
83
|
+
<Heatmap
|
|
84
|
+
width={240}
|
|
85
|
+
height={160}
|
|
86
|
+
hideLegend
|
|
87
|
+
showCellLabels
|
|
88
|
+
xAxis={[{ data: ["A"] }]}
|
|
89
|
+
yAxis={[{ data: ["1"] }]}
|
|
90
|
+
series={[{ data: [{ x: 0, y: 0, value: 4 }] }]}
|
|
91
|
+
onItemClick={onItemClick}
|
|
92
|
+
/>,
|
|
93
|
+
);
|
|
94
|
+
expect(screen.getByText("4")).toBeInTheDocument();
|
|
95
|
+
fireEvent.click(container.querySelector(".spk-chart-mark--interactive")!);
|
|
96
|
+
expect(onItemClick).toHaveBeenCalledWith(expect.objectContaining({ value: 4, category: "A · 1" }));
|
|
97
|
+
});
|
|
79
98
|
});
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
useChartHover,
|
|
23
23
|
useHiddenSeries,
|
|
24
24
|
} from "./chart-ui";
|
|
25
|
+
import { bindChartMark, type ChartItemEvent, type ChartTooltipRenderer } from "./chart-interaction";
|
|
25
26
|
|
|
26
27
|
export type HeatDatum = { x: number; y: number; value: number };
|
|
27
28
|
|
|
@@ -35,6 +36,10 @@ export type HeatmapProps = {
|
|
|
35
36
|
showColorScale?: boolean;
|
|
36
37
|
margin?: ChartMargin;
|
|
37
38
|
className?: string;
|
|
39
|
+
fillHeight?: boolean;
|
|
40
|
+
onItemClick?: (event: ChartItemEvent) => void;
|
|
41
|
+
renderTooltip?: ChartTooltipRenderer;
|
|
42
|
+
showCellLabels?: boolean | ((cell: HeatDatum) => string);
|
|
38
43
|
};
|
|
39
44
|
|
|
40
45
|
export function Heatmap({
|
|
@@ -47,6 +52,10 @@ export function Heatmap({
|
|
|
47
52
|
showColorScale,
|
|
48
53
|
margin,
|
|
49
54
|
className,
|
|
55
|
+
fillHeight,
|
|
56
|
+
onItemClick,
|
|
57
|
+
renderTooltip,
|
|
58
|
+
showCellLabels = false,
|
|
50
59
|
}: HeatmapProps) {
|
|
51
60
|
const cells = series[0]?.data ?? [];
|
|
52
61
|
const xs = categoryLabels(xAxis?.[0], Math.max(...cells.map((c) => c.x), 0) + 1);
|
|
@@ -65,8 +74,10 @@ export function Heatmap({
|
|
|
65
74
|
className={className}
|
|
66
75
|
width={width}
|
|
67
76
|
height={height}
|
|
77
|
+
fillHeight={fillHeight}
|
|
68
78
|
margin={margin ?? { top: colorScale ? 32 : 12, right: 12, bottom: 36, left: 72 }}
|
|
69
79
|
hover={hover}
|
|
80
|
+
renderTooltip={renderTooltip}
|
|
70
81
|
aria-label="Heatmap"
|
|
71
82
|
>
|
|
72
83
|
{({ width: w, height: h, m }) => {
|
|
@@ -131,32 +142,55 @@ export function Heatmap({
|
|
|
131
142
|
))}
|
|
132
143
|
{cells.map((cell, i) => {
|
|
133
144
|
const t = (cell.value - min) / (max - min || 1);
|
|
145
|
+
const color = heatColor(t);
|
|
146
|
+
const category = `${xs[cell.x]} · ${ys[cell.y]}`;
|
|
147
|
+
const event: ChartItemEvent = {
|
|
148
|
+
seriesId: "heat",
|
|
149
|
+
seriesLabel: "value",
|
|
150
|
+
dataIndex: i,
|
|
151
|
+
category,
|
|
152
|
+
value: cell.value,
|
|
153
|
+
color,
|
|
154
|
+
};
|
|
155
|
+
const cellLabel =
|
|
156
|
+
typeof showCellLabels === "function"
|
|
157
|
+
? showCellLabels(cell)
|
|
158
|
+
: showCellLabels
|
|
159
|
+
? formatChartNumber(cell.value)
|
|
160
|
+
: null;
|
|
134
161
|
return (
|
|
135
|
-
<
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
162
|
+
<g key={i}>
|
|
163
|
+
<rect
|
|
164
|
+
x={m.left + cell.x * cw + 1.5}
|
|
165
|
+
y={m.top + cell.y * ch + 1.5}
|
|
166
|
+
width={Math.max(1, cw - 3)}
|
|
167
|
+
height={Math.max(1, ch - 3)}
|
|
168
|
+
rx={4}
|
|
169
|
+
fill={color}
|
|
170
|
+
{...bindChartMark({
|
|
171
|
+
event,
|
|
172
|
+
hover: {
|
|
173
|
+
title: category,
|
|
174
|
+
items: [{ color, label: "value", value: formatChartNumber(cell.value) }],
|
|
175
|
+
},
|
|
176
|
+
onItemClick,
|
|
177
|
+
setHover,
|
|
178
|
+
clearHover: clear,
|
|
179
|
+
})}
|
|
180
|
+
/>
|
|
181
|
+
{cellLabel ? (
|
|
182
|
+
<text
|
|
183
|
+
className="spk-chart-label"
|
|
184
|
+
x={m.left + cell.x * cw + cw / 2}
|
|
185
|
+
y={m.top + cell.y * ch + ch / 2}
|
|
186
|
+
textAnchor="middle"
|
|
187
|
+
dominantBaseline="middle"
|
|
188
|
+
pointerEvents="none"
|
|
189
|
+
>
|
|
190
|
+
{cellLabel}
|
|
191
|
+
</text>
|
|
192
|
+
) : null}
|
|
193
|
+
</g>
|
|
160
194
|
);
|
|
161
195
|
})}
|
|
162
196
|
</svg>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { render, screen } from "@testing-library/react";
|
|
2
|
-
import { describe, expect, it } from "vitest";
|
|
1
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
3
|
import { Gauge, LinearGauge, PieChart, RadarChart } from "./radial-charts";
|
|
4
4
|
|
|
5
5
|
describe("radial-charts", () => {
|
|
@@ -15,6 +15,21 @@ describe("radial-charts", () => {
|
|
|
15
15
|
expect(screen.getByRole("button", { name: "A" })).toBeInTheDocument();
|
|
16
16
|
});
|
|
17
17
|
|
|
18
|
+
it("fires onItemClick from a pie slice", () => {
|
|
19
|
+
const onItemClick = vi.fn();
|
|
20
|
+
const { container } = render(
|
|
21
|
+
<PieChart
|
|
22
|
+
width={200}
|
|
23
|
+
height={200}
|
|
24
|
+
hideLegend
|
|
25
|
+
series={[{ data: [{ value: 10, label: "A" }, { value: 20, label: "B" }] }]}
|
|
26
|
+
onItemClick={onItemClick}
|
|
27
|
+
/>,
|
|
28
|
+
);
|
|
29
|
+
fireEvent.click(container.querySelector(".spk-chart-mark--interactive")!);
|
|
30
|
+
expect(onItemClick).toHaveBeenCalledWith(expect.objectContaining({ seriesLabel: "A", value: 10 }));
|
|
31
|
+
});
|
|
32
|
+
|
|
18
33
|
it("renders a single-value gauge with a percent label", () => {
|
|
19
34
|
render(<Gauge value={40} width={160} height={120} />);
|
|
20
35
|
expect(screen.getByRole("img", { name: "Gauge 40" })).toBeInTheDocument();
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
useChartHover,
|
|
14
14
|
useHiddenSeries,
|
|
15
15
|
} from "./chart-ui";
|
|
16
|
+
import { bindChartMark, type ChartItemEvent, type ChartTooltipRenderer } from "./chart-interaction";
|
|
16
17
|
|
|
17
18
|
export type PieDatum = {
|
|
18
19
|
id?: string | number;
|
|
@@ -42,6 +43,11 @@ export type PieChartProps = {
|
|
|
42
43
|
margin?: ChartMargin;
|
|
43
44
|
className?: string;
|
|
44
45
|
slot?: string;
|
|
46
|
+
fillHeight?: boolean;
|
|
47
|
+
onItemClick?: (event: ChartItemEvent) => void;
|
|
48
|
+
renderTooltip?: ChartTooltipRenderer;
|
|
49
|
+
/** Return slice label text, or null to hide. Defaults to percent when the slice is large enough. */
|
|
50
|
+
labelFormatter?: (item: { label: string; value: number; percent: number }) => string | null;
|
|
45
51
|
};
|
|
46
52
|
|
|
47
53
|
export function PieChart({
|
|
@@ -53,6 +59,10 @@ export function PieChart({
|
|
|
53
59
|
margin,
|
|
54
60
|
className,
|
|
55
61
|
slot = "pie-chart",
|
|
62
|
+
fillHeight,
|
|
63
|
+
onItemClick,
|
|
64
|
+
renderTooltip,
|
|
65
|
+
labelFormatter,
|
|
56
66
|
}: PieChartProps) {
|
|
57
67
|
const first = series[0] ?? { data: [] };
|
|
58
68
|
const meta = seriesMeta(
|
|
@@ -73,11 +83,13 @@ export function PieChart({
|
|
|
73
83
|
className={className}
|
|
74
84
|
width={width}
|
|
75
85
|
height={height}
|
|
86
|
+
fillHeight={fillHeight}
|
|
76
87
|
margin={margin ?? { top: 12, right: 12, bottom: 12, left: 12 }}
|
|
77
88
|
legend={hideLegend ? undefined : meta}
|
|
78
89
|
hiddenIds={hidden}
|
|
79
90
|
onToggleSeries={toggle}
|
|
80
91
|
hover={hover}
|
|
92
|
+
renderTooltip={renderTooltip}
|
|
81
93
|
aria-label="Pie chart"
|
|
82
94
|
>
|
|
83
95
|
{({ width: w, height: h, m }) => {
|
|
@@ -102,29 +114,43 @@ export function PieChart({
|
|
|
102
114
|
const info = meta[i]!;
|
|
103
115
|
if (hidden.has(info.id) || !slice.path) return null;
|
|
104
116
|
const item = first.data[i]!;
|
|
117
|
+
const color = info.color;
|
|
118
|
+
const event: ChartItemEvent = {
|
|
119
|
+
seriesId: info.id,
|
|
120
|
+
seriesLabel: info.label,
|
|
121
|
+
dataIndex: i,
|
|
122
|
+
category: info.label,
|
|
123
|
+
value: item.value,
|
|
124
|
+
color,
|
|
125
|
+
};
|
|
126
|
+
const labelText = labelFormatter
|
|
127
|
+
? labelFormatter({ label: info.label, value: item.value, percent: slice.percent })
|
|
128
|
+
: slice.percent > 0.08
|
|
129
|
+
? `${Math.round(slice.percent * 100)}%`
|
|
130
|
+
: null;
|
|
105
131
|
return (
|
|
106
132
|
<g key={info.id}>
|
|
107
133
|
<path
|
|
108
134
|
d={slice.path}
|
|
109
|
-
fill={
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
x: event.nativeEvent.offsetX,
|
|
114
|
-
y: event.nativeEvent.offsetY,
|
|
135
|
+
fill={color}
|
|
136
|
+
{...bindChartMark({
|
|
137
|
+
event,
|
|
138
|
+
hover: {
|
|
115
139
|
title: info.label,
|
|
116
140
|
items: [
|
|
117
141
|
{
|
|
118
|
-
color
|
|
142
|
+
color,
|
|
119
143
|
label: info.label,
|
|
120
144
|
value: `${formatChartNumber(item.value)} (${Math.round(slice.percent * 100)}%)`,
|
|
121
145
|
},
|
|
122
146
|
],
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
|
|
147
|
+
},
|
|
148
|
+
onItemClick,
|
|
149
|
+
setHover,
|
|
150
|
+
clearHover: clear,
|
|
151
|
+
})}
|
|
126
152
|
/>
|
|
127
|
-
{
|
|
153
|
+
{labelText ? (
|
|
128
154
|
<text
|
|
129
155
|
className="spk-chart-label"
|
|
130
156
|
fill="var(--primary-foreground)"
|
|
@@ -133,7 +159,7 @@ export function PieChart({
|
|
|
133
159
|
textAnchor="middle"
|
|
134
160
|
dominantBaseline="middle"
|
|
135
161
|
>
|
|
136
|
-
{
|
|
162
|
+
{labelText}
|
|
137
163
|
</text>
|
|
138
164
|
) : null}
|
|
139
165
|
</g>
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type SchedulerResource,
|
|
13
13
|
type TimelineScale,
|
|
14
14
|
} from "../lib/scheduler";
|
|
15
|
+
import { NativeSelect } from "../primitives/NativeSelect";
|
|
15
16
|
import { Button } from "../primitives/Button";
|
|
16
17
|
import {
|
|
17
18
|
DropdownMenu,
|
|
@@ -36,6 +37,8 @@ export const TIMELINE_SCALE_LABEL: Record<TimelineScale, string> = {
|
|
|
36
37
|
years: "Years",
|
|
37
38
|
};
|
|
38
39
|
|
|
40
|
+
export type SchedulerToolbarDensity = "default" | "compact";
|
|
41
|
+
|
|
39
42
|
export type SchedulerToolbarProps = {
|
|
40
43
|
title: ReactNode;
|
|
41
44
|
onPrev: () => void;
|
|
@@ -44,6 +47,10 @@ export type SchedulerToolbarProps = {
|
|
|
44
47
|
menuLabel: string;
|
|
45
48
|
menu: ReactNode;
|
|
46
49
|
trailing?: ReactNode;
|
|
50
|
+
/** Compact is a single desktop row. Defaults keep the stacked marketing toolbar. */
|
|
51
|
+
density?: SchedulerToolbarDensity;
|
|
52
|
+
/** Month/year jump. Sits beside the view menu. */
|
|
53
|
+
dateJump?: ReactNode;
|
|
47
54
|
};
|
|
48
55
|
|
|
49
56
|
export function SchedulerToolbar({
|
|
@@ -54,48 +61,143 @@ export function SchedulerToolbar({
|
|
|
54
61
|
menuLabel,
|
|
55
62
|
menu,
|
|
56
63
|
trailing,
|
|
64
|
+
density = "default",
|
|
65
|
+
dateJump,
|
|
57
66
|
}: SchedulerToolbarProps) {
|
|
67
|
+
const nav = (
|
|
68
|
+
<div className="flex items-center gap-1">
|
|
69
|
+
<Button
|
|
70
|
+
type="button"
|
|
71
|
+
variant="ghost"
|
|
72
|
+
size="icon"
|
|
73
|
+
aria-label="Previous"
|
|
74
|
+
onClick={onPrev}
|
|
75
|
+
>
|
|
76
|
+
<ChevronLeft className="size-4" />
|
|
77
|
+
</Button>
|
|
78
|
+
<Button type="button" variant="outline" size="sm" className="uppercase tracking-wide" onClick={onToday}>
|
|
79
|
+
Today
|
|
80
|
+
</Button>
|
|
81
|
+
<Button type="button" variant="ghost" size="icon" aria-label="Next" onClick={onNext}>
|
|
82
|
+
<ChevronRight className="size-4" />
|
|
83
|
+
</Button>
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const viewMenu = (
|
|
88
|
+
<DropdownMenu>
|
|
89
|
+
<DropdownMenuTrigger asChild>
|
|
90
|
+
<Button type="button" variant="outline" size="sm" className="min-w-[6.5rem] justify-between gap-2 uppercase tracking-wide">
|
|
91
|
+
{menuLabel}
|
|
92
|
+
<ChevronDown className="size-3.5 opacity-70" />
|
|
93
|
+
</Button>
|
|
94
|
+
</DropdownMenuTrigger>
|
|
95
|
+
<DropdownMenuContent align="end">{menu}</DropdownMenuContent>
|
|
96
|
+
</DropdownMenu>
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
if (density === "compact") {
|
|
100
|
+
return (
|
|
101
|
+
<div
|
|
102
|
+
data-slot="scheduler-toolbar"
|
|
103
|
+
data-density="compact"
|
|
104
|
+
className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 border-b border-border/50 px-3 py-2 sm:px-4 sm:py-3"
|
|
105
|
+
>
|
|
106
|
+
{nav}
|
|
107
|
+
<h2 className="min-w-0 truncate text-center text-sm font-black uppercase tracking-widest text-foreground">
|
|
108
|
+
{title}
|
|
109
|
+
</h2>
|
|
110
|
+
<div className="flex shrink-0 flex-nowrap items-center justify-end gap-2">
|
|
111
|
+
{viewMenu}
|
|
112
|
+
{dateJump}
|
|
113
|
+
{trailing}
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
58
119
|
return (
|
|
59
120
|
<div
|
|
60
121
|
data-slot="scheduler-toolbar"
|
|
122
|
+
data-density="default"
|
|
61
123
|
className="flex flex-col gap-3 border-b border-border/50 px-3 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-4"
|
|
62
124
|
>
|
|
63
125
|
<h2 className="min-w-0 truncate text-xl font-black tracking-tight text-foreground sm:text-2xl">
|
|
64
126
|
{title}
|
|
65
127
|
</h2>
|
|
66
128
|
<div className="flex flex-wrap items-center gap-2">
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
variant="ghost"
|
|
71
|
-
size="icon"
|
|
72
|
-
aria-label="Previous"
|
|
73
|
-
onClick={onPrev}
|
|
74
|
-
>
|
|
75
|
-
<ChevronLeft className="size-4" />
|
|
76
|
-
</Button>
|
|
77
|
-
<Button type="button" variant="outline" size="sm" className="uppercase tracking-wide" onClick={onToday}>
|
|
78
|
-
Today
|
|
79
|
-
</Button>
|
|
80
|
-
<Button type="button" variant="ghost" size="icon" aria-label="Next" onClick={onNext}>
|
|
81
|
-
<ChevronRight className="size-4" />
|
|
82
|
-
</Button>
|
|
83
|
-
</div>
|
|
84
|
-
<DropdownMenu>
|
|
85
|
-
<DropdownMenuTrigger asChild>
|
|
86
|
-
<Button type="button" variant="outline" size="sm" className="min-w-[6.5rem] justify-between gap-2 uppercase tracking-wide">
|
|
87
|
-
{menuLabel}
|
|
88
|
-
<ChevronDown className="size-3.5 opacity-70" />
|
|
89
|
-
</Button>
|
|
90
|
-
</DropdownMenuTrigger>
|
|
91
|
-
<DropdownMenuContent align="end">{menu}</DropdownMenuContent>
|
|
92
|
-
</DropdownMenu>
|
|
129
|
+
{nav}
|
|
130
|
+
{viewMenu}
|
|
131
|
+
{dateJump}
|
|
93
132
|
{trailing}
|
|
94
133
|
</div>
|
|
95
134
|
</div>
|
|
96
135
|
);
|
|
97
136
|
}
|
|
98
137
|
|
|
138
|
+
const DATE_JUMP_SELECT =
|
|
139
|
+
"w-[5.25rem] min-w-[5.25rem] text-center [text-align-last:center]";
|
|
140
|
+
|
|
141
|
+
export type SchedulerDateJumpProps = {
|
|
142
|
+
date: Date;
|
|
143
|
+
locale?: string;
|
|
144
|
+
yearOptions?: number[];
|
|
145
|
+
onDateChange: (date: Date) => void;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
function resolveYearOptions(year: number, yearOptions?: number[]) {
|
|
149
|
+
if (yearOptions && yearOptions.length > 0) {
|
|
150
|
+
return yearOptions.includes(year) ? yearOptions : [...yearOptions, year].sort((a, b) => a - b);
|
|
151
|
+
}
|
|
152
|
+
return Array.from({ length: 13 }, (_, index) => year - 8 + index);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Compact month/year native selects for calendar toolbars. */
|
|
156
|
+
export function SchedulerDateJump({
|
|
157
|
+
date,
|
|
158
|
+
locale,
|
|
159
|
+
yearOptions,
|
|
160
|
+
onDateChange,
|
|
161
|
+
}: SchedulerDateJumpProps) {
|
|
162
|
+
const month = date.getMonth();
|
|
163
|
+
const year = date.getFullYear();
|
|
164
|
+
const years = resolveYearOptions(year, yearOptions);
|
|
165
|
+
const months = Array.from({ length: 12 }, (_, index) =>
|
|
166
|
+
new Intl.DateTimeFormat(locale, { month: "short" }).format(new Date(2020, index, 1)).toUpperCase(),
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<div className="flex shrink-0 items-center gap-1.5">
|
|
171
|
+
<NativeSelect
|
|
172
|
+
size="sm"
|
|
173
|
+
className={DATE_JUMP_SELECT}
|
|
174
|
+
aria-label="Month"
|
|
175
|
+
value={month}
|
|
176
|
+
onChange={(event) => onDateChange(new Date(year, Number(event.target.value), 1))}
|
|
177
|
+
>
|
|
178
|
+
{months.map((label, index) => (
|
|
179
|
+
<option key={label} value={index}>
|
|
180
|
+
{label}
|
|
181
|
+
</option>
|
|
182
|
+
))}
|
|
183
|
+
</NativeSelect>
|
|
184
|
+
<NativeSelect
|
|
185
|
+
size="sm"
|
|
186
|
+
className={cn(DATE_JUMP_SELECT, "tabular-nums")}
|
|
187
|
+
aria-label="Year"
|
|
188
|
+
value={year}
|
|
189
|
+
onChange={(event) => onDateChange(new Date(Number(event.target.value), month, 1))}
|
|
190
|
+
>
|
|
191
|
+
{years.map((yearOption) => (
|
|
192
|
+
<option key={yearOption} value={yearOption}>
|
|
193
|
+
{yearOption}
|
|
194
|
+
</option>
|
|
195
|
+
))}
|
|
196
|
+
</NativeSelect>
|
|
197
|
+
</div>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
99
201
|
export function SchedulerPreferencesMenu({
|
|
100
202
|
value,
|
|
101
203
|
onChange,
|
|
@@ -221,6 +221,45 @@ describe("EventCalendar", () => {
|
|
|
221
221
|
expect(screen.getByRole("button", { name: "Jump year" })).toBeInTheDocument();
|
|
222
222
|
expect(screen.getByRole("heading", { name: /August 2026/i })).toBeInTheDocument();
|
|
223
223
|
});
|
|
224
|
+
|
|
225
|
+
it("renders a compact toolbar with equal month and year jumps", () => {
|
|
226
|
+
render(
|
|
227
|
+
<EventCalendar
|
|
228
|
+
defaultDate={new Date(2026, 7, 15)}
|
|
229
|
+
events={events}
|
|
230
|
+
locale="en-US"
|
|
231
|
+
showPreferences={false}
|
|
232
|
+
toolbarDensity="compact"
|
|
233
|
+
showDateJump
|
|
234
|
+
/>,
|
|
235
|
+
);
|
|
236
|
+
expect(document.querySelector('[data-density="compact"]')).toBeTruthy();
|
|
237
|
+
expect(screen.getByLabelText("Month")).toHaveAttribute("data-size", "sm");
|
|
238
|
+
expect(screen.getByLabelText("Year")).toHaveAttribute("data-size", "sm");
|
|
239
|
+
expect(screen.getByLabelText("Month")).toHaveValue("7");
|
|
240
|
+
expect(screen.getByLabelText("Year")).toHaveValue("2026");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("reports the visible range for week view", () => {
|
|
244
|
+
const onVisibleRangeChange = vi.fn();
|
|
245
|
+
render(
|
|
246
|
+
<EventCalendar
|
|
247
|
+
defaultDate={new Date(2026, 7, 15)}
|
|
248
|
+
defaultView="week"
|
|
249
|
+
locale="en-US"
|
|
250
|
+
onVisibleRangeChange={onVisibleRangeChange}
|
|
251
|
+
/>,
|
|
252
|
+
);
|
|
253
|
+
expect(onVisibleRangeChange).toHaveBeenCalled();
|
|
254
|
+
const range = onVisibleRangeChange.mock.calls[0][0] as {
|
|
255
|
+
view: string;
|
|
256
|
+
start: Date;
|
|
257
|
+
end: Date;
|
|
258
|
+
};
|
|
259
|
+
expect(range.view).toBe("week");
|
|
260
|
+
expect(range.start.getDay()).toBe(0);
|
|
261
|
+
expect(range.end.getTime() - range.start.getTime()).toBe(7 * 86_400_000);
|
|
262
|
+
});
|
|
224
263
|
});
|
|
225
264
|
|
|
226
265
|
describe("EventTimeline", () => {
|
package/src/index.test.ts
CHANGED
|
@@ -7,7 +7,9 @@ import {
|
|
|
7
7
|
ChartDataGrid,
|
|
8
8
|
EventCalendar,
|
|
9
9
|
MapChart,
|
|
10
|
+
SchedulerDateJump,
|
|
10
11
|
SchedulerToolbar,
|
|
12
|
+
calendarVisibleRange,
|
|
11
13
|
mercator,
|
|
12
14
|
} from "./index";
|
|
13
15
|
|
|
@@ -21,6 +23,8 @@ describe("@spatika/react public exports", () => {
|
|
|
21
23
|
expect(BarChart3D).toBeTypeOf("function");
|
|
22
24
|
expect(EventCalendar).toBeTypeOf("function");
|
|
23
25
|
expect(SchedulerToolbar).toBeTypeOf("function");
|
|
26
|
+
expect(SchedulerDateJump).toBeTypeOf("function");
|
|
27
|
+
expect(calendarVisibleRange).toBeTypeOf("function");
|
|
24
28
|
expect(mercator([0, 0])[0]).toBeCloseTo(0.5);
|
|
25
29
|
});
|
|
26
30
|
});
|