@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
@@ -1,4 +1,5 @@
1
1
  import {
2
+ useEffect,
2
3
  useMemo,
3
4
  useRef,
4
5
  useState,
@@ -13,6 +14,8 @@ import {
13
14
  addDays,
14
15
  addMonths,
15
16
  agendaDays,
17
+ calendarVisibleRange,
18
+ CALENDAR_AGENDA_DAYS,
16
19
  DEFAULT_SCHEDULER_PREFERENCES,
17
20
  eventCanDrag,
18
21
  eventCanResize,
@@ -44,6 +47,7 @@ import {
44
47
  weekNumber,
45
48
  weekSpanLanesForDays,
46
49
  type CalendarView,
50
+ type CalendarVisibleRange,
47
51
  type NormalizedEvent,
48
52
  type SchedulerEvent,
49
53
  type SchedulerPreferences,
@@ -54,9 +58,11 @@ import {
54
58
  CALENDAR_VIEW_LABEL,
55
59
  EventResizeHandle,
56
60
  ResourceLegend,
61
+ SchedulerDateJump,
57
62
  SchedulerPreferencesMenu,
58
63
  SchedulerShell,
59
64
  SchedulerToolbar,
65
+ type SchedulerToolbarDensity,
60
66
  } from "./scheduler-ui";
61
67
  import { DropdownMenuItem } from "../primitives/DropdownMenu";
62
68
 
@@ -125,6 +131,14 @@ export type EventCalendarProps = {
125
131
  showPreferences?: boolean;
126
132
  /** Extra controls after the view menu. Ignored when `renderToolbar` is set. */
127
133
  toolbarTrailing?: ReactNode;
134
+ /** Default toolbar layout. Compact is a single desktop row. Ignored when `renderToolbar` is set. */
135
+ toolbarDensity?: SchedulerToolbarDensity;
136
+ /** Month/year native selects on the default toolbar. Ignored when `renderToolbar` is set. */
137
+ showDateJump?: boolean;
138
+ /** Years in the date-jump select. Defaults to a window around the current year. */
139
+ yearOptions?: number[];
140
+ /** Fires when the painted date window changes. `end` is exclusive. */
141
+ onVisibleRangeChange?: (range: CalendarVisibleRange & { view: CalendarView }) => void;
128
142
  /** Replace event chip contents. The kit still wraps with the click/drag trigger. */
129
143
  renderEvent?: (
130
144
  event: NormalizedEvent,
@@ -136,7 +150,6 @@ export type EventCalendarProps = {
136
150
  };
137
151
 
138
152
  const MAX_TIMED_IN_CELL = 3;
139
- const AGENDA_LENGTH = 14;
140
153
 
141
154
  function chunkWeeks<T>(items: T[]): T[][] {
142
155
  const weeks: T[][] = [];
@@ -153,27 +166,6 @@ function startOfToday(): Date {
153
166
  return new Date(now.getFullYear(), now.getMonth(), now.getDate());
154
167
  }
155
168
 
156
- function visibleRange(
157
- view: CalendarView,
158
- date: Date,
159
- weekStartsOn: number,
160
- ): { start: Date; end: Date } {
161
- if (view === "month") {
162
- const grid = getMonthGrid(date, weekStartsOn);
163
- return { start: grid[0], end: addDays(grid[grid.length - 1], 1) };
164
- }
165
- if (view === "week") {
166
- const days = getWeekDays(date, weekStartsOn);
167
- return { start: days[0], end: addDays(days[6], 1) };
168
- }
169
- if (view === "agenda") {
170
- const days = agendaDays(date, AGENDA_LENGTH);
171
- return { start: days[0], end: addDays(days[days.length - 1], 1) };
172
- }
173
- const start = startOfDay(date);
174
- return { start, end: addDays(start, 1) };
175
- }
176
-
177
169
  function draftFromSource(source: SchedulerEvent): EventEditorDraft {
178
170
  return {
179
171
  id: source.id,
@@ -217,6 +209,10 @@ export function EventCalendar({
217
209
  showEventEditor = true,
218
210
  showPreferences = true,
219
211
  toolbarTrailing,
212
+ toolbarDensity = "default",
213
+ showDateJump = false,
214
+ yearOptions,
215
+ onVisibleRangeChange,
220
216
  renderEvent,
221
217
  renderToolbar,
222
218
  className,
@@ -253,10 +249,14 @@ export function EventCalendar({
253
249
  const editorLocked = readOnly || Boolean(draft?.id && sourceEvent(events, draft.id)?.readOnly);
254
250
 
255
251
  const range = useMemo(
256
- () => visibleRange(resolvedView, resolvedDate, startOn),
252
+ () => calendarVisibleRange(resolvedView, resolvedDate, startOn),
257
253
  [resolvedView, resolvedDate, startOn],
258
254
  );
259
255
 
256
+ useEffect(() => {
257
+ onVisibleRangeChange?.({ start: range.start, end: range.end, view: resolvedView });
258
+ }, [range.start, range.end, resolvedView, onVisibleRangeChange]);
259
+
260
260
  const normalized = useMemo(() => {
261
261
  const visible = visibleEvents(normalizeEvents(events, resources), hiddenIds);
262
262
  return expandVisibleEvents(visible, range.start, range.end, events);
@@ -473,6 +473,7 @@ export function EventCalendar({
473
473
  ) : (
474
474
  <SchedulerToolbar
475
475
  title={title}
476
+ density={toolbarDensity}
476
477
  onPrev={() => go(-1)}
477
478
  onNext={() => go(1)}
478
479
  onToday={() => setDate(new Date())}
@@ -482,6 +483,16 @@ export function EventCalendar({
482
483
  {CALENDAR_VIEW_LABEL[item]}
483
484
  </DropdownMenuItem>
484
485
  ))}
486
+ dateJump={
487
+ showDateJump ? (
488
+ <SchedulerDateJump
489
+ date={resolvedDate}
490
+ locale={locale}
491
+ yearOptions={yearOptions}
492
+ onDateChange={(next) => setDate(next)}
493
+ />
494
+ ) : null
495
+ }
485
496
  trailing={
486
497
  <>
487
498
  {toolbarTrailing}
@@ -964,7 +975,7 @@ function AgendaList({
964
975
  options?: { resize?: "ns"; variant: EventCalendarEventVariant },
965
976
  ) => ReactNode;
966
977
  }) {
967
- const days = filterWeekendDays(agendaDays(date, AGENDA_LENGTH), showWeekends);
978
+ const days = filterWeekendDays(agendaDays(date, CALENDAR_AGENDA_DAYS), showWeekends);
968
979
 
969
980
  return (
970
981
  <div className="divide-y divide-border/50">
@@ -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 { AreaChart, BarChart, LineChart } from "./cartesian-charts";
4
4
 
5
5
  const months = ["Jan", "Feb", "Mar"];
@@ -49,4 +49,88 @@ describe("cartesian-charts", () => {
49
49
  expect(container.querySelector('[data-slot="area-plot"]')).toBeTruthy();
50
50
  expect(container.querySelectorAll('[data-slot="area-plot"] path').length).toBeGreaterThanOrEqual(2);
51
51
  });
52
+
53
+ it("fires onItemClick with the bar category and value", () => {
54
+ const onItemClick = vi.fn();
55
+ const { container } = render(
56
+ <BarChart
57
+ width={360}
58
+ height={220}
59
+ hideLegend
60
+ xAxis={[{ data: months }]}
61
+ series={[{ label: "Views", data: [4, 3, 5] }]}
62
+ onItemClick={onItemClick}
63
+ />,
64
+ );
65
+ const mark = container.querySelector(".spk-chart-mark--interactive");
66
+ expect(mark).toBeTruthy();
67
+ fireEvent.click(mark!);
68
+ expect(onItemClick).toHaveBeenCalledWith(
69
+ expect.objectContaining({ seriesLabel: "Views", dataIndex: 0, category: "Jan", value: 4 }),
70
+ );
71
+ });
72
+
73
+ it("colors individual bars from itemColors", () => {
74
+ const { container } = render(
75
+ <BarChart
76
+ width={360}
77
+ height={220}
78
+ hideLegend
79
+ xAxis={[{ data: months }]}
80
+ series={[{ label: "Growth", data: [2, -1, 3], itemColors: ["var(--chart-3)", "var(--chart-4)"] }]}
81
+ />,
82
+ );
83
+ const marks = container.querySelectorAll('[data-slot="bar-plot"] .spk-chart-mark');
84
+ expect(marks[0]?.getAttribute("fill")).toBe("var(--chart-3)");
85
+ expect(marks[1]?.getAttribute("fill")).toBe("var(--chart-4)");
86
+ });
87
+
88
+ it("renders a reference line and end labels on bars", () => {
89
+ const { container } = render(
90
+ <BarChart
91
+ width={360}
92
+ height={220}
93
+ hideLegend
94
+ showLabels
95
+ xAxis={[{ data: months }]}
96
+ series={[{ label: "Views", data: [4, 3, 5] }]}
97
+ referenceLines={[{ y: 4, label: "Target" }]}
98
+ />,
99
+ );
100
+ expect(container.querySelector('[data-slot="chart-reference-lines"]')).toBeTruthy();
101
+ expect(screen.getByText("Target")).toBeInTheDocument();
102
+ expect(container.querySelector('[data-slot="bar-labels"]')).toBeTruthy();
103
+ });
104
+
105
+ it("fires onItemClick from horizontal bars", () => {
106
+ const onItemClick = vi.fn();
107
+ const { container } = render(
108
+ <BarChart
109
+ width={360}
110
+ height={220}
111
+ hideLegend
112
+ layout="horizontal"
113
+ xAxis={[{ data: months }]}
114
+ series={[{ label: "Views", data: [4, 3, 5] }]}
115
+ onItemClick={onItemClick}
116
+ />,
117
+ );
118
+ fireEvent.click(container.querySelector(".spk-chart-mark--interactive")!);
119
+ expect(onItemClick).toHaveBeenCalledWith(expect.objectContaining({ category: "Jan", value: 4 }));
120
+ });
121
+
122
+ it("renders a custom tooltip from hover", () => {
123
+ const { container } = render(
124
+ <BarChart
125
+ width={360}
126
+ height={220}
127
+ hideLegend
128
+ xAxis={[{ data: months }]}
129
+ series={[{ label: "Views", data: [4, 3, 5] }]}
130
+ renderTooltip={(hover) => <p>{hover.category} custom</p>}
131
+ />,
132
+ );
133
+ fireEvent.mouseEnter(container.querySelector(".spk-chart-mark")!);
134
+ expect(screen.getByText("Jan custom")).toBeInTheDocument();
135
+ });
52
136
  });
@@ -33,6 +33,14 @@ import {
33
33
  type ChartHover,
34
34
  } from "./chart-ui";
35
35
  import { ChartContainer } from "./chart-composition";
36
+ import {
37
+ bindChartMark,
38
+ seriesItemColor,
39
+ type ChartItemEvent,
40
+ type ChartMarkRenderContext,
41
+ type ChartReferenceLine,
42
+ type ChartTooltipRenderer,
43
+ } from "./chart-interaction";
36
44
  import { ScatterWebGLLayer } from "./ScatterWebGL";
37
45
  import {
38
46
  nearestScatterHit,
@@ -49,6 +57,8 @@ export type NumericSeries = {
49
57
  data?: Array<number | null>;
50
58
  dataKey?: string;
51
59
  color?: string;
60
+ /** Per-category colors; each entry falls back to `color`. */
61
+ itemColors?: Array<string | null | undefined>;
52
62
  stack?: string;
53
63
  area?: boolean;
54
64
  curve?: ChartCurve;
@@ -75,6 +85,12 @@ export type CartesianChartProps = {
75
85
  highlightScope?: import("../lib/charts").HighlightScope;
76
86
  animated?: boolean;
77
87
  locale?: string;
88
+ fillHeight?: boolean;
89
+ onItemClick?: (event: ChartItemEvent) => void;
90
+ renderTooltip?: ChartTooltipRenderer;
91
+ renderMark?: (ctx: ChartMarkRenderContext) => ReactNode;
92
+ referenceLines?: ChartReferenceLine[];
93
+ showLabels?: boolean;
78
94
  };
79
95
 
80
96
  export type BarChartProps = CartesianChartProps & {
@@ -268,6 +284,12 @@ export function BarChart({
268
284
  highlightScope,
269
285
  animated,
270
286
  locale,
287
+ fillHeight,
288
+ onItemClick,
289
+ renderTooltip,
290
+ renderMark,
291
+ referenceLines,
292
+ showLabels,
271
293
  }: BarChartProps) {
272
294
  const meta = seriesMeta(series, colors);
273
295
 
@@ -287,6 +309,10 @@ export function BarChart({
287
309
  margin={margin}
288
310
  className={className}
289
311
  barGapRatio={barGapRatio}
312
+ fillHeight={fillHeight}
313
+ onItemClick={onItemClick}
314
+ renderTooltip={renderTooltip}
315
+ showLabels={showLabels}
290
316
  />
291
317
  );
292
318
  }
@@ -315,6 +341,12 @@ export function BarChart({
315
341
  highlightScope={highlightScope}
316
342
  animated={animated}
317
343
  locale={locale}
344
+ fillHeight={fillHeight}
345
+ onItemClick={onItemClick}
346
+ renderTooltip={renderTooltip}
347
+ renderMark={renderMark}
348
+ referenceLines={referenceLines}
349
+ showLabels={showLabels}
318
350
  />
319
351
  );
320
352
  }
@@ -333,6 +365,10 @@ function HorizontalBarChart({
333
365
  margin,
334
366
  className,
335
367
  barGapRatio = 0.15,
368
+ fillHeight,
369
+ onItemClick,
370
+ renderTooltip,
371
+ showLabels,
336
372
  }: BarChartProps) {
337
373
  const categories = categoryLabels(xAxis?.[0], series[0]?.data?.length ?? 0);
338
374
  const domain = numericDomain(series, Boolean(stacked), yAxis?.[0]);
@@ -349,11 +385,13 @@ function HorizontalBarChart({
349
385
  className={className}
350
386
  width={width}
351
387
  height={height}
352
- margin={margin ?? { top: 16, right: 16, bottom: 28, left: 72 }}
388
+ fillHeight={fillHeight}
389
+ margin={margin ?? { top: 16, right: showLabels ? 48 : 16, bottom: 28, left: 72 }}
353
390
  legend={hideLegend ? undefined : meta}
354
391
  hiddenIds={hidden}
355
392
  onToggleSeries={toggle}
356
393
  hover={hover}
394
+ renderTooltip={renderTooltip}
357
395
  aria-label={`Bar chart of ${meta.map((s) => s.label).join(", ")}`}
358
396
  >
359
397
  {({ width: w, height: h, m }) => {
@@ -423,22 +461,42 @@ function HorizontalBarChart({
423
461
  }
424
462
  const x = Math.min(x1, x2);
425
463
  const barW = Math.max(1, Math.abs(x2 - x1));
464
+ const color = seriesItemColor(info.color, item.itemColors, i);
465
+ const event: ChartItemEvent = {
466
+ seriesId: info.id,
467
+ seriesLabel: info.label,
468
+ dataIndex: i,
469
+ category: categories[i] ?? i,
470
+ value,
471
+ color,
472
+ };
426
473
  return (
427
- <path
428
- key={`${info.id}-${i}`}
429
- className="spk-chart-mark"
430
- d={roundedRectPath(x, y, barW, stacked ? yScale.bandwidth : barH, borderRadius)}
431
- fill={info.color}
432
- onMouseEnter={(event) =>
433
- setHover({
434
- x: event.nativeEvent.offsetX,
435
- y: event.nativeEvent.offsetY,
436
- title: String(categories[i]),
437
- items: [{ color: info.color, label: info.label, value: formatSeriesValue(value) }],
438
- })
439
- }
440
- onMouseLeave={clear}
441
- />
474
+ <g key={`${info.id}-${i}`}>
475
+ <path
476
+ d={roundedRectPath(x, y, barW, stacked ? yScale.bandwidth : barH, borderRadius)}
477
+ fill={color}
478
+ {...bindChartMark({
479
+ event,
480
+ hover: {
481
+ title: String(categories[i]),
482
+ items: [{ color, label: info.label, value: formatSeriesValue(value) }],
483
+ },
484
+ onItemClick,
485
+ setHover,
486
+ clearHover: clear,
487
+ })}
488
+ />
489
+ {showLabels ? (
490
+ <text
491
+ className="spk-chart-label"
492
+ x={Math.max(x1, x2) + 6}
493
+ y={y + (stacked ? yScale.bandwidth : barH) / 2}
494
+ dominantBaseline="middle"
495
+ >
496
+ {formatSeriesValue(value)}
497
+ </text>
498
+ ) : null}
499
+ </g>
442
500
  );
443
501
  });
444
502
  })}
@@ -471,6 +529,11 @@ export function LineChart({
471
529
  highlightScope,
472
530
  animated,
473
531
  locale,
532
+ fillHeight,
533
+ onItemClick,
534
+ renderTooltip,
535
+ renderMark,
536
+ referenceLines,
474
537
  }: LineChartProps) {
475
538
  const meta = seriesMeta(series, colors);
476
539
  const stackedAreas = stacked && (area || series.some((item) => item.area || item.type === "area"));
@@ -501,6 +564,11 @@ export function LineChart({
501
564
  highlightScope={highlightScope}
502
565
  animated={animated}
503
566
  locale={locale}
567
+ fillHeight={fillHeight}
568
+ onItemClick={onItemClick}
569
+ renderTooltip={renderTooltip}
570
+ renderMark={renderMark}
571
+ referenceLines={referenceLines}
504
572
  />
505
573
  );
506
574
  }
@@ -50,4 +50,19 @@ describe("chart-composition", () => {
50
50
  expect(container.querySelector('[data-slot="area-plot"]')).toBeTruthy();
51
51
  expect(container.querySelectorAll('[data-slot="area-plot"] path').length).toBeGreaterThanOrEqual(2);
52
52
  });
53
+
54
+ it("draws reference lines from ChartContainer", () => {
55
+ const { container } = render(
56
+ <ChartContainer
57
+ width={360}
58
+ height={220}
59
+ hideLegend
60
+ xAxis={[{ data: ["Jan", "Feb"] }]}
61
+ series={[{ type: "line", label: "Gap", data: [3, 5] }]}
62
+ referenceLines={[{ y: 4, label: "Capacity" }]}
63
+ />,
64
+ );
65
+ expect(container.querySelector('[data-slot="chart-reference-lines"]')).toBeTruthy();
66
+ expect(screen.getByText("Capacity")).toBeInTheDocument();
67
+ });
53
68
  });