@spatika/react 1.3.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/AGENTS.md +6 -3
  2. package/README.md +1 -1
  3. package/dist/composites/EventCalendar.d.ts +13 -2
  4. package/dist/composites/EventCalendar.d.ts.map +1 -1
  5. package/dist/composites/EventCalendar.js +13 -27
  6. package/dist/composites/GlassCard.d.ts +1 -1
  7. package/dist/composites/RichTextEditor.d.ts +1 -4
  8. package/dist/composites/RichTextEditor.d.ts.map +1 -1
  9. package/dist/composites/RichTextEditor.js +12 -19
  10. package/dist/composites/SurfaceCard.d.ts +1 -1
  11. package/dist/composites/Tag.d.ts +2 -2
  12. package/dist/composites/cartesian-charts.d.ts +12 -2
  13. package/dist/composites/cartesian-charts.d.ts.map +1 -1
  14. package/dist/composites/cartesian-charts.js +27 -13
  15. package/dist/composites/chart-composition.d.ts +19 -1
  16. package/dist/composites/chart-composition.d.ts.map +1 -1
  17. package/dist/composites/chart-composition.js +124 -62
  18. package/dist/composites/chart-interaction.d.ts +81 -0
  19. package/dist/composites/chart-interaction.d.ts.map +1 -0
  20. package/dist/composites/chart-interaction.js +56 -0
  21. package/dist/composites/chart-ui.d.ts +8 -13
  22. package/dist/composites/chart-ui.d.ts.map +1 -1
  23. package/dist/composites/chart-ui.js +9 -6
  24. package/dist/composites/flow-charts.d.ts +6 -1
  25. package/dist/composites/flow-charts.d.ts.map +1 -1
  26. package/dist/composites/flow-charts.js +28 -14
  27. package/dist/composites/radial-charts.d.ts +11 -1
  28. package/dist/composites/radial-charts.d.ts.map +1 -1
  29. package/dist/composites/radial-charts.js +33 -14
  30. package/dist/composites/scheduler-ui.d.ts +14 -1
  31. package/dist/composites/scheduler-ui.d.ts.map +1 -1
  32. package/dist/composites/scheduler-ui.js +23 -2
  33. package/dist/index.d.ts +14 -26
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +8 -14
  36. package/dist/lib/breakpoints.d.ts +17 -0
  37. package/dist/lib/breakpoints.d.ts.map +1 -0
  38. package/dist/lib/breakpoints.js +23 -0
  39. package/dist/lib/chips.d.ts +3 -0
  40. package/dist/lib/chips.d.ts.map +1 -1
  41. package/dist/lib/chips.js +10 -0
  42. package/dist/lib/create-theme.d.ts +162 -0
  43. package/dist/lib/create-theme.d.ts.map +1 -0
  44. package/dist/lib/create-theme.js +138 -0
  45. package/dist/lib/scheduler.d.ts +12 -0
  46. package/dist/lib/scheduler.d.ts.map +1 -1
  47. package/dist/lib/scheduler.js +24 -2
  48. package/dist/lib/themes.d.ts +17 -7
  49. package/dist/lib/themes.d.ts.map +1 -1
  50. package/dist/lib/themes.js +22 -8
  51. package/dist/lib/use-media-query.d.ts +9 -0
  52. package/dist/lib/use-media-query.d.ts.map +1 -1
  53. package/dist/lib/use-media-query.js +28 -1
  54. package/dist/primitives/Alert.d.ts +1 -1
  55. package/dist/primitives/Badge.d.ts +2 -2
  56. package/dist/primitives/Button.d.ts +2 -2
  57. package/dist/primitives/Container.d.ts +2 -2
  58. package/dist/primitives/Fab.d.ts +4 -4
  59. package/dist/primitives/Grid.d.ts +2 -1
  60. package/dist/primitives/Grid.d.ts.map +1 -1
  61. package/dist/primitives/Grid.js +10 -2
  62. package/dist/primitives/IconButton.d.ts +2 -2
  63. package/dist/primitives/NativeSelect.d.ts +10 -1
  64. package/dist/primitives/NativeSelect.d.ts.map +1 -1
  65. package/dist/primitives/NativeSelect.js +2 -2
  66. package/dist/primitives/ToggleButton.d.ts +1 -1
  67. package/dist/theme/SpatikaThemeProvider.d.ts +4 -5
  68. package/dist/theme/SpatikaThemeProvider.d.ts.map +1 -1
  69. package/dist/theme/SpatikaThemeProvider.js +28 -13
  70. package/package.json +4 -2
  71. package/skills/spatika-ui/SKILL.md +28 -5
  72. package/src/composites/EventCalendar.test.tsx +293 -0
  73. package/src/composites/EventCalendar.tsx +38 -27
  74. package/src/composites/RichTextEditor.test.tsx +51 -0
  75. package/src/composites/RichTextEditor.tsx +20 -84
  76. package/src/composites/chart-ui.test.tsx +21 -0
  77. package/src/composites/scheduler-ui.tsx +128 -26
  78. package/src/composites/scheduler.test.tsx +0 -219
  79. package/src/index.test.ts +10 -0
  80. package/src/index.ts +148 -65
  81. package/src/lib/breakpoints.test.ts +29 -0
  82. package/src/lib/breakpoints.ts +28 -0
  83. package/src/lib/chips.ts +16 -0
  84. package/src/lib/create-theme.test.ts +79 -0
  85. package/src/lib/create-theme.ts +249 -0
  86. package/src/lib/scheduler.test.ts +26 -0
  87. package/src/lib/scheduler.ts +36 -2
  88. package/src/lib/themes.test.ts +17 -0
  89. package/src/lib/themes.ts +43 -12
  90. package/src/lib/use-media-query.test.tsx +80 -0
  91. package/src/lib/use-media-query.ts +35 -1
  92. package/src/primitives/Grid.test.tsx +55 -0
  93. package/src/primitives/Grid.tsx +13 -3
  94. package/src/primitives/NativeSelect.test.tsx +28 -0
  95. package/src/primitives/NativeSelect.tsx +14 -4
  96. package/src/primitives/mui-more.test.tsx +17 -0
  97. package/src/theme/SpatikaThemeProvider.test.tsx +38 -0
  98. package/src/theme/SpatikaThemeProvider.tsx +43 -15
  99. package/src/composites/ChartDataGrid.test.tsx +0 -57
  100. package/src/composites/ChartDataGrid.tsx +0 -196
  101. package/src/composites/MapChart.test.tsx +0 -39
  102. package/src/composites/MapChart.tsx +0 -130
  103. package/src/composites/ScatterWebGL.test.tsx +0 -25
  104. package/src/composites/ScatterWebGL.tsx +0 -79
  105. package/src/composites/cartesian-charts.test.tsx +0 -52
  106. package/src/composites/cartesian-charts.tsx +0 -1287
  107. package/src/composites/chart-composition.test.tsx +0 -53
  108. package/src/composites/chart-composition.tsx +0 -898
  109. package/src/composites/chart-ui.tsx +0 -328
  110. package/src/composites/charts-3d.test.tsx +0 -25
  111. package/src/composites/charts-3d.tsx +0 -318
  112. package/src/composites/charts.test.tsx +0 -254
  113. package/src/composites/flow-charts.test.tsx +0 -79
  114. package/src/composites/flow-charts.tsx +0 -607
  115. package/src/composites/radial-charts.test.tsx +0 -74
  116. package/src/composites/radial-charts.tsx +0 -617
  117. package/src/lib/chart-export.test.ts +0 -20
  118. package/src/lib/chart-export.ts +0 -99
  119. package/src/lib/charts-platform.test.ts +0 -93
  120. package/src/lib/charts.test.ts +0 -132
  121. package/src/lib/charts.ts +0 -1021
  122. package/src/lib/geo.test.ts +0 -42
  123. package/src/lib/geo.ts +0 -138
  124. package/src/lib/iso.test.ts +0 -22
  125. package/src/lib/iso.ts +0 -102
  126. package/src/lib/webgl-scatter.test.ts +0 -36
  127. package/src/lib/webgl-scatter.ts +0 -230
@@ -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}
@@ -655,7 +666,7 @@ function MonthGrid({
655
666
  "flex size-7 items-center justify-center rounded-full text-xs font-bold",
656
667
  isToday && "bg-primary text-primary-foreground",
657
668
  !isToday && inMonth && "text-foreground hover:bg-muted/50",
658
- !isToday && !inMonth && "text-muted-foreground/70",
669
+ !isToday && !inMonth && "text-muted-foreground",
659
670
  )}
660
671
  >
661
672
  {day.getDate()}
@@ -677,7 +688,7 @@ function MonthGrid({
677
688
  >
678
689
  {renderEventTrigger(
679
690
  slot.event,
680
- "flex h-5 w-full items-center truncate rounded-md px-1.5 text-[11px] font-semibold leading-none",
691
+ "flex min-h-6 w-full items-center truncate rounded-md px-1.5 text-[11px] font-semibold leading-none",
681
692
  slot.event.title,
682
693
  { variant: "month-bar" },
683
694
  )}
@@ -712,7 +723,7 @@ function MonthGrid({
712
723
  <div key={occurrenceKey(event)} className="min-w-0">
713
724
  {renderEventTrigger(
714
725
  event,
715
- "flex w-full min-w-0 items-center gap-1 rounded-sm px-0.5 py-0.5 text-left text-[11px] leading-tight text-foreground hover:bg-muted/40",
726
+ "flex min-h-6 w-full min-w-0 items-center gap-1 rounded-sm px-0.5 py-0.5 text-left text-[11px] leading-tight text-foreground hover:bg-muted/40",
716
727
  <>
717
728
  <span
718
729
  className="size-1.5 shrink-0 rounded-full"
@@ -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">
@@ -0,0 +1,51 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { RichTextEditor } from "./RichTextEditor";
4
+
5
+ vi.mock("@spatika/editor", () => ({
6
+ SpatikaEditor: ({
7
+ value,
8
+ placeholder,
9
+ heightClassName,
10
+ toolbar,
11
+ }: {
12
+ value: string;
13
+ placeholder?: string;
14
+ heightClassName?: string;
15
+ toolbar?: Record<string, unknown>;
16
+ }) => (
17
+ <div
18
+ data-testid="spatika-editor-shim"
19
+ data-value={value}
20
+ data-placeholder={placeholder}
21
+ data-height={heightClassName}
22
+ data-toolbar={JSON.stringify(toolbar)}
23
+ />
24
+ ),
25
+ }));
26
+
27
+ describe("RichTextEditor", () => {
28
+ it("delegates to SpatikaEditor with a compact legacy toolbar", () => {
29
+ render(
30
+ <RichTextEditor
31
+ value="<p>Note</p>"
32
+ placeholder="Write something…"
33
+ minHeightClassName="min-h-40"
34
+ />,
35
+ );
36
+ const shim = screen.getByTestId("spatika-editor-shim");
37
+ expect(shim).toHaveAttribute("data-value", "<p>Note</p>");
38
+ expect(shim).toHaveAttribute("data-placeholder", "Write something…");
39
+ expect(shim).toHaveAttribute("data-height", "min-h-40");
40
+ expect(shim).toHaveAttribute(
41
+ "data-toolbar",
42
+ JSON.stringify({
43
+ layout: "compact",
44
+ textStyle: false,
45
+ alignment: false,
46
+ insert: false,
47
+ ai: false,
48
+ }),
49
+ );
50
+ });
51
+ });
@@ -1,13 +1,8 @@
1
- import { useEffect, useRef } from "react";
2
- import {
3
- Bold,
4
- Italic,
5
- List,
6
- ListOrdered,
7
- Underline as UnderlineIcon,
8
- } from "lucide-react";
9
- import { cn } from "../lib/cn";
10
- import { Button } from "../primitives/Button";
1
+ /**
2
+ * @deprecated Use `SpatikaEditor` from `@spatika/react` instead.
3
+ * Import `@spatika/editor/styles.css` alongside `@spatika/tokens/styles.css`.
4
+ */
5
+ import { SpatikaEditor } from "@spatika/editor";
11
6
 
12
7
  export type RichTextEditorProps = {
13
8
  value?: string;
@@ -17,10 +12,7 @@ export type RichTextEditorProps = {
17
12
  minHeightClassName?: string;
18
13
  };
19
14
 
20
- /**
21
- * Lightweight rich-text surface (contenteditable) for notes and captions.
22
- * Pair with TipTap or another full editor in your app when you need tables or advanced media.
23
- */
15
+ /** @deprecated Use `SpatikaEditor` for full rich text editing with Tiptap. */
24
16
  export function RichTextEditor({
25
17
  value = "",
26
18
  onChange,
@@ -28,76 +20,20 @@ export function RichTextEditor({
28
20
  className,
29
21
  minHeightClassName = "min-h-40",
30
22
  }: RichTextEditorProps) {
31
- const ref = useRef<HTMLDivElement>(null);
32
-
33
- useEffect(() => {
34
- if (ref.current && value !== ref.current.innerHTML) {
35
- ref.current.innerHTML = value;
36
- }
37
- }, [value]);
38
-
39
- const exec = (command: string) => {
40
- document.execCommand(command);
41
- onChange?.(ref.current?.innerHTML ?? "");
42
- ref.current?.focus();
43
- };
44
-
45
23
  return (
46
- <div
47
- data-slot="rich-text-editor"
48
- className={cn(
49
- "overflow-hidden rounded-[var(--radius-card)] border border-border/50 bg-input-background",
50
- className,
51
- )}
52
- >
53
- <div className="flex flex-wrap gap-1 border-b border-border/40 bg-muted/30 p-1.5">
54
- <Button type="button" variant="ghost" size="icon" aria-label="Bold" onClick={() => exec("bold")}>
55
- <Bold className="size-4" />
56
- </Button>
57
- <Button type="button" variant="ghost" size="icon" aria-label="Italic" onClick={() => exec("italic")}>
58
- <Italic className="size-4" />
59
- </Button>
60
- <Button
61
- type="button"
62
- variant="ghost"
63
- size="icon"
64
- aria-label="Underline"
65
- onClick={() => exec("underline")}
66
- >
67
- <UnderlineIcon className="size-4" />
68
- </Button>
69
- <Button
70
- type="button"
71
- variant="ghost"
72
- size="icon"
73
- aria-label="Bullet list"
74
- onClick={() => exec("insertUnorderedList")}
75
- >
76
- <List className="size-4" />
77
- </Button>
78
- <Button
79
- type="button"
80
- variant="ghost"
81
- size="icon"
82
- aria-label="Numbered list"
83
- onClick={() => exec("insertOrderedList")}
84
- >
85
- <ListOrdered className="size-4" />
86
- </Button>
87
- </div>
88
- <div
89
- ref={ref}
90
- role="textbox"
91
- aria-multiline="true"
92
- contentEditable
93
- suppressContentEditableWarning
94
- data-placeholder={placeholder}
95
- className={cn(
96
- "px-3 py-2 text-sm outline-none empty:before:text-muted-foreground empty:before:content-[attr(data-placeholder)]",
97
- minHeightClassName,
98
- )}
99
- onInput={() => onChange?.(ref.current?.innerHTML ?? "")}
100
- />
101
- </div>
24
+ <SpatikaEditor
25
+ value={value}
26
+ onChange={(html) => onChange?.(html)}
27
+ placeholder={placeholder}
28
+ className={className}
29
+ heightClassName={minHeightClassName}
30
+ toolbar={{
31
+ layout: "compact",
32
+ textStyle: false,
33
+ alignment: false,
34
+ insert: false,
35
+ ai: false,
36
+ }}
37
+ />
102
38
  );
103
39
  }
@@ -0,0 +1,21 @@
1
+ import { render } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { SparkLineChart } from "@spatika/charts";
4
+
5
+ /**
6
+ * `chart-ui` moved to `@spatika/charts`. This file keeps colocated coverage
7
+ * next to the old path so the extract stays test-paired.
8
+ */
9
+ describe("chart-ui (re-exported via @spatika/charts)", () => {
10
+ it("still renders a sparkline frame that uses ChartFrame", () => {
11
+ const { container } = render(
12
+ <SparkLineChart data={[1, 4, 2]} width={120} height={36} />,
13
+ );
14
+ expect(container.querySelector('[data-slot="sparkline"]')).toBeTruthy();
15
+ });
16
+
17
+ it("does not paint a tooltip when nothing is hovered", () => {
18
+ render(<SparkLineChart data={[1, 4, 2]} width={120} height={36} />);
19
+ expect(document.querySelector('[role="tooltip"]')).toBeNull();
20
+ });
21
+ });
@@ -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,