@spatika/react 1.3.1 → 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.
Files changed (58) hide show
  1. package/dist/composites/EventCalendar.d.ts +13 -2
  2. package/dist/composites/EventCalendar.d.ts.map +1 -1
  3. package/dist/composites/EventCalendar.js +10 -24
  4. package/dist/composites/cartesian-charts.d.ts +12 -2
  5. package/dist/composites/cartesian-charts.d.ts.map +1 -1
  6. package/dist/composites/cartesian-charts.js +27 -13
  7. package/dist/composites/chart-composition.d.ts +19 -1
  8. package/dist/composites/chart-composition.d.ts.map +1 -1
  9. package/dist/composites/chart-composition.js +124 -62
  10. package/dist/composites/chart-interaction.d.ts +81 -0
  11. package/dist/composites/chart-interaction.d.ts.map +1 -0
  12. package/dist/composites/chart-interaction.js +56 -0
  13. package/dist/composites/chart-ui.d.ts +8 -13
  14. package/dist/composites/chart-ui.d.ts.map +1 -1
  15. package/dist/composites/chart-ui.js +9 -6
  16. package/dist/composites/flow-charts.d.ts +6 -1
  17. package/dist/composites/flow-charts.d.ts.map +1 -1
  18. package/dist/composites/flow-charts.js +28 -14
  19. package/dist/composites/radial-charts.d.ts +11 -1
  20. package/dist/composites/radial-charts.d.ts.map +1 -1
  21. package/dist/composites/radial-charts.js +33 -14
  22. package/dist/composites/scheduler-ui.d.ts +14 -1
  23. package/dist/composites/scheduler-ui.d.ts.map +1 -1
  24. package/dist/composites/scheduler-ui.js +23 -2
  25. package/dist/index.d.ts +8 -6
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +4 -4
  28. package/dist/lib/chips.d.ts +3 -0
  29. package/dist/lib/chips.d.ts.map +1 -1
  30. package/dist/lib/chips.js +10 -0
  31. package/dist/lib/scheduler.d.ts +12 -0
  32. package/dist/lib/scheduler.d.ts.map +1 -1
  33. package/dist/lib/scheduler.js +22 -0
  34. package/dist/primitives/NativeSelect.d.ts +10 -1
  35. package/dist/primitives/NativeSelect.d.ts.map +1 -1
  36. package/dist/primitives/NativeSelect.js +2 -2
  37. package/package.json +2 -2
  38. package/skills/spatika-ui/SKILL.md +4 -0
  39. package/src/composites/EventCalendar.tsx +35 -24
  40. package/src/composites/cartesian-charts.test.tsx +86 -2
  41. package/src/composites/cartesian-charts.tsx +84 -16
  42. package/src/composites/chart-composition.test.tsx +15 -0
  43. package/src/composites/chart-composition.tsx +229 -88
  44. package/src/composites/chart-interaction.ts +136 -0
  45. package/src/composites/chart-ui.tsx +35 -27
  46. package/src/composites/flow-charts.test.tsx +21 -2
  47. package/src/composites/flow-charts.tsx +59 -25
  48. package/src/composites/radial-charts.test.tsx +17 -2
  49. package/src/composites/radial-charts.tsx +38 -12
  50. package/src/composites/scheduler-ui.tsx +128 -26
  51. package/src/composites/scheduler.test.tsx +39 -0
  52. package/src/index.test.ts +4 -0
  53. package/src/index.ts +18 -1
  54. package/src/lib/chips.ts +16 -0
  55. package/src/lib/scheduler.test.ts +16 -0
  56. package/src/lib/scheduler.ts +34 -0
  57. package/src/primitives/NativeSelect.test.tsx +28 -0
  58. 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
- {hover.title ? <p className="spk-chart-tooltip-title">{hover.title}</p> : null}
166
- <ul>
167
- {hover.items.map((item) => (
168
- <li key={item.label}>
169
- <span className="spk-chart-swatch" style={{ background: item.color }} />
170
- <span>{item.label}</span>
171
- <strong>{item.value}</strong>
172
- </li>
173
- ))}
174
- </ul>
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
- <rect
136
- key={i}
137
- x={m.left + cell.x * cw + 1.5}
138
- y={m.top + cell.y * ch + 1.5}
139
- width={Math.max(1, cw - 3)}
140
- height={Math.max(1, ch - 3)}
141
- rx={4}
142
- fill={heatColor(t)}
143
- className="spk-chart-mark"
144
- onMouseEnter={(event) =>
145
- setHover({
146
- x: event.nativeEvent.offsetX,
147
- y: event.nativeEvent.offsetY,
148
- title: `${xs[cell.x]} · ${ys[cell.y]}`,
149
- items: [
150
- {
151
- color: heatColor(t),
152
- label: "value",
153
- value: formatChartNumber(cell.value),
154
- },
155
- ],
156
- })
157
- }
158
- onMouseLeave={clear}
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={info.color}
110
- className="spk-chart-mark"
111
- onMouseEnter={(event) =>
112
- setHover({
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: info.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
- onMouseLeave={clear}
147
+ },
148
+ onItemClick,
149
+ setHover,
150
+ clearHover: clear,
151
+ })}
126
152
  />
127
- {slice.percent > 0.08 ? (
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
- {Math.round(slice.percent * 100)}%
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
- <div className="flex items-center gap-1">
68
- <Button
69
- type="button"
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
  });