@dxos/react-ui-calendar 0.9.0 → 0.9.1-staging.ee54ba693a

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 (32) hide show
  1. package/dist/lib/browser/index.mjs +582 -107
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/lib/node-esm/index.mjs +582 -107
  5. package/dist/lib/node-esm/index.mjs.map +4 -4
  6. package/dist/lib/node-esm/meta.json +1 -1
  7. package/dist/types/src/components/Calendar/Calendar.d.ts +25 -35
  8. package/dist/types/src/components/Calendar/Calendar.d.ts.map +1 -1
  9. package/dist/types/src/components/Calendar/Calendar.stories.d.ts +2 -0
  10. package/dist/types/src/components/Calendar/Calendar.stories.d.ts.map +1 -1
  11. package/dist/types/src/components/Calendar/Week.d.ts +30 -0
  12. package/dist/types/src/components/Calendar/Week.d.ts.map +1 -0
  13. package/dist/types/src/components/Calendar/Weekdays.d.ts +18 -0
  14. package/dist/types/src/components/Calendar/Weekdays.d.ts.map +1 -0
  15. package/dist/types/src/components/Calendar/context.d.ts +40 -0
  16. package/dist/types/src/components/Calendar/context.d.ts.map +1 -0
  17. package/dist/types/src/components/Calendar/util.d.ts +36 -0
  18. package/dist/types/src/components/Calendar/util.d.ts.map +1 -1
  19. package/dist/types/src/components/Calendar/util.test.d.ts +2 -0
  20. package/dist/types/src/components/Calendar/util.test.d.ts.map +1 -0
  21. package/dist/types/src/types.d.ts +5 -5
  22. package/dist/types/src/types.d.ts.map +1 -1
  23. package/dist/types/tsconfig.tsbuildinfo +1 -1
  24. package/package.json +19 -19
  25. package/src/components/Calendar/Calendar.stories.tsx +43 -3
  26. package/src/components/Calendar/Calendar.tsx +118 -97
  27. package/src/components/Calendar/Week.tsx +488 -0
  28. package/src/components/Calendar/Weekdays.tsx +57 -0
  29. package/src/components/Calendar/context.ts +60 -0
  30. package/src/components/Calendar/util.test.ts +90 -0
  31. package/src/components/Calendar/util.ts +92 -1
  32. package/src/types.ts +5 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/react-ui-calendar",
3
- "version": "0.9.0",
3
+ "version": "0.9.1-staging.ee54ba693a",
4
4
  "description": "A calendar component.",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -39,31 +39,31 @@
39
39
  "react-resize-detector": "^11.0.1",
40
40
  "react-virtualized": "^9.22.6",
41
41
  "react-window": "^2.2.3",
42
- "@dxos/async": "0.9.0",
43
- "@dxos/invariant": "0.9.0",
44
- "@dxos/debug": "0.9.0",
45
- "@dxos/log": "0.9.0",
46
- "@dxos/util": "0.9.0"
42
+ "@dxos/debug": "0.9.1-staging.ee54ba693a",
43
+ "@dxos/invariant": "0.9.1-staging.ee54ba693a",
44
+ "@dxos/log": "0.9.1-staging.ee54ba693a",
45
+ "@dxos/util": "0.9.1-staging.ee54ba693a",
46
+ "@dxos/async": "0.9.1-staging.ee54ba693a"
47
47
  },
48
48
  "devDependencies": {
49
- "@types/react": "~19.2.7",
49
+ "@types/react": "~19.2.17",
50
50
  "@types/react-dom": "~19.2.3",
51
51
  "@types/react-virtualized": "^9.22.3",
52
- "effect": "3.21.3",
53
- "react": "~19.2.3",
54
- "react-dom": "~19.2.3",
52
+ "effect": "3.21.4",
53
+ "react": "~19.2.7",
54
+ "react-dom": "~19.2.7",
55
55
  "vite": "^8.0.16",
56
- "@dxos/random": "0.9.0",
57
- "@dxos/react-ui": "0.9.0",
58
- "@dxos/ui-theme": "0.9.0",
59
- "@dxos/storybook-utils": "0.9.0"
56
+ "@dxos/random": "0.9.1-staging.ee54ba693a",
57
+ "@dxos/react-ui": "0.9.1-staging.ee54ba693a",
58
+ "@dxos/storybook-utils": "0.9.1-staging.ee54ba693a",
59
+ "@dxos/ui-theme": "0.9.1-staging.ee54ba693a"
60
60
  },
61
61
  "peerDependencies": {
62
- "effect": "3.21.3",
63
- "react": "~19.2.3",
64
- "react-dom": "~19.2.3",
65
- "@dxos/react-ui": "0.9.0",
66
- "@dxos/ui-theme": "0.9.0"
62
+ "effect": "3.21.4",
63
+ "react": "~19.2.7",
64
+ "react-dom": "~19.2.7",
65
+ "@dxos/react-ui": "0.9.1-staging.ee54ba693a",
66
+ "@dxos/ui-theme": "0.9.1-staging.ee54ba693a"
67
67
  },
68
68
  "publishConfig": {
69
69
  "access": "public"
@@ -3,15 +3,15 @@
3
3
  //
4
4
 
5
5
  import { type Meta, type StoryObj } from '@storybook/react-vite';
6
- import { format } from 'date-fns';
7
- import React, { useState } from 'react';
6
+ import { addDays, addMinutes, format, startOfDay, startOfWeek } from 'date-fns';
7
+ import React, { useMemo, useRef, useState } from 'react';
8
8
 
9
9
  import { Panel } from '@dxos/react-ui';
10
10
  import { withLayout, withTheme } from '@dxos/react-ui/testing';
11
11
 
12
12
  import { translations } from '#translations';
13
13
 
14
- import { Calendar, type Range as DateRange } from './Calendar';
14
+ import { Calendar, type CalendarEvent, type Range as DateRange } from './Calendar';
15
15
 
16
16
  const meta = {
17
17
  title: 'ui/react-ui-calendar/Calendar',
@@ -68,3 +68,43 @@ export const Column: Story = {
68
68
  </Calendar.Root>
69
69
  ),
70
70
  };
71
+
72
+ export const Week: StoryObj<typeof Calendar.Week> = {
73
+ decorators: [withTheme(), withLayout({ layout: 'column', classNames: 'w-auto' })],
74
+ render: () => {
75
+ const idRef = useRef(100);
76
+ const initial = useMemo<CalendarEvent[]>(() => {
77
+ const weekStart = startOfWeek(new Date(), { weekStartsOn: 1 });
78
+ const at = (dayOffset: number, hour: number, minute = 0) =>
79
+ addMinutes(startOfDay(addDays(weekStart, dayOffset)), hour * 60 + minute);
80
+ return [
81
+ { id: '1', title: 'Standup', start: at(1, 9, 0), end: at(1, 9, 30) },
82
+ { id: '2', title: 'Design review', start: at(1, 9, 15), end: at(1, 10, 30) },
83
+ { id: '3', title: 'Lunch', start: at(2, 12, 0), end: at(2, 13, 0) },
84
+ { id: '4', title: '1:1', start: at(3, 14, 0), end: at(3, 15, 0) },
85
+ { id: '5', title: 'Focus', start: at(3, 14, 30), end: at(3, 16, 0) },
86
+ { id: '6', title: 'Planning', start: at(4, 10, 0), end: at(4, 11, 30) },
87
+ ];
88
+ }, []);
89
+ const [events, setEvents] = useState<CalendarEvent[]>(initial);
90
+
91
+ return (
92
+ <Calendar.Root>
93
+ <Panel.Root>
94
+ <Panel.Toolbar asChild>
95
+ <Calendar.Toolbar />
96
+ </Panel.Toolbar>
97
+ <Calendar.Week
98
+ events={events}
99
+ onEventCreate={({ start, end }) =>
100
+ setEvents((current) => [...current, { id: `${idRef.current++}`, title: 'New event', start, end }])
101
+ }
102
+ onEventUpdate={({ id, start, end }) =>
103
+ setEvents((current) => current.map((event) => (event.id === id ? { ...event, start, end } : event)))
104
+ }
105
+ />
106
+ </Panel.Root>
107
+ </Calendar.Root>
108
+ );
109
+ },
110
+ };
@@ -2,14 +2,11 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import { createContext } from '@radix-ui/react-context';
6
- import { type Day, addDays, format, startOfDay, startOfWeek } from 'date-fns';
5
+ import { addDays, format, startOfDay } from 'date-fns';
7
6
  import React, {
8
- type Dispatch,
7
+ type PropsWithChildren,
9
8
  type KeyboardEvent as ReactKeyboardEvent,
10
9
  type PointerEvent as ReactPointerEvent,
11
- type PropsWithChildren,
12
- type SetStateAction,
13
10
  forwardRef,
14
11
  useCallback,
15
12
  useEffect,
@@ -28,10 +25,20 @@ import { mx } from '@dxos/ui-theme';
28
25
 
29
26
  import { translationKey } from '#translations';
30
27
 
31
- import { getDate, getRowIndex, isSameDay } from './util';
28
+ import {
29
+ CalendarContextProvider,
30
+ type CalendarContextValue,
31
+ type CalendarController,
32
+ type CalendarScrollEvent,
33
+ type Range,
34
+ useCalendarContext,
35
+ } from './context';
36
+ import { getDate, getRowIndex, gridEpoch, isSameDay } from './util';
37
+ import { type CalendarEvent, CalendarWeek, type CalendarWeekProps } from './Week';
38
+ import { Weekdays } from './Weekdays';
32
39
 
33
40
  const maxRows = 50 * 100;
34
- const start = new Date('1970-01-01');
41
+ const start = gridEpoch;
35
42
  const size = 40;
36
43
  const defaultWidth = 7 * size;
37
44
 
@@ -39,19 +46,17 @@ const defaultWidth = 7 * size;
39
46
  const EDGE_SCROLL_ZONE = 32; // px
40
47
  const EDGE_SCROLL_MAX_SPEED = 12; // px per frame
41
48
 
49
+ const DATE_CLASS_NAMES = {
50
+ current: 'ring-2 ring-primary-500',
51
+ today: 'border-2 border-amber-500 bg-amber-500/50 text-inverse-fg',
52
+ busy: 'border border-green-700',
53
+ starred: 'border-2 border-dashed border-amber-500',
54
+ };
55
+
42
56
  //
43
57
  // Range
44
58
  //
45
59
 
46
- /**
47
- * Inclusive date range. `from <= to`. Both endpoints are anchored at the
48
- * start of their day; callers should not rely on time-of-day precision.
49
- */
50
- export type Range = {
51
- from: Date;
52
- to: Date;
53
- };
54
-
55
60
  /** Normalize an ordered pair of dates into a Range (start-of-day, from <= to). */
56
61
  const makeRange = (a: Date, b: Date): Range => {
57
62
  const dayA = startOfDay(a);
@@ -81,40 +86,6 @@ const cellDate = (el: Element | null): Date | undefined => {
81
86
  return undefined;
82
87
  };
83
88
 
84
- //
85
- // Context
86
- //
87
-
88
- type CalendarEvent = {
89
- type: 'scroll';
90
- date: Date;
91
- };
92
-
93
- type CalendarContextValue = {
94
- weekStartsOn: Day;
95
- event: Event<CalendarEvent>;
96
- index: number | undefined;
97
- setIndex: Dispatch<SetStateAction<number | undefined>>;
98
- selected: Date | undefined;
99
- setSelected: Dispatch<SetStateAction<Date | undefined>>;
100
- /** Committed date range, set by the most recent drag or shift+arrow selection. */
101
- range: Range | undefined;
102
- setRange: Dispatch<SetStateAction<Range | undefined>>;
103
- /** Live drag preview; non-undefined only while the user is dragging. */
104
- pendingRange: Range | undefined;
105
- setPendingRange: Dispatch<SetStateAction<Range | undefined>>;
106
- };
107
-
108
- const [CalendarContextProvider, useCalendarContext] = createContext<CalendarContextValue>('Calendar');
109
-
110
- //
111
- // Controller
112
- //
113
-
114
- type CalendarController = {
115
- scrollTo: (date: Date) => void;
116
- };
117
-
118
89
  //
119
90
  // Root
120
91
  //
@@ -123,7 +94,7 @@ type CalendarRootProps = PropsWithChildren<Partial<Pick<CalendarContextValue, 'w
123
94
 
124
95
  const CalendarRoot = forwardRef<CalendarController, CalendarRootProps>(
125
96
  ({ children, weekStartsOn = 1 }, forwardedRef) => {
126
- const event = useMemo(() => new Event<CalendarEvent>(), []);
97
+ const event = useMemo(() => new Event<CalendarScrollEvent>(), []);
127
98
  const [selected, setSelected] = useState<Date | undefined>();
128
99
  const [index, setIndex] = useState<number | undefined>();
129
100
  const [range, setRange] = useState<Range | undefined>();
@@ -135,6 +106,9 @@ const CalendarRoot = forwardRef<CalendarController, CalendarRootProps>(
135
106
  scrollTo: (date: Date) => {
136
107
  event.emit({ type: 'scroll', date });
137
108
  },
109
+ select: (date: Date) => {
110
+ event.emit({ type: 'select', date });
111
+ },
138
112
  }),
139
113
  [event],
140
114
  );
@@ -183,7 +157,6 @@ const CalendarToolbar = composable<HTMLDivElement, CalendarToolbarProps>(({ clas
183
157
  classNames: ['shrink-0 grid! grid-cols-3 items-center bg-toolbar-surface', classNames],
184
158
  })}
185
159
  ref={forwardedRef}
186
- style={{ width: defaultWidth }}
187
160
  >
188
161
  <div className='flex justify-start'>
189
162
  <IconButton
@@ -209,16 +182,30 @@ CalendarToolbar.displayName = CALENDAR_TOOLBAR_NAME;
209
182
 
210
183
  const CALENDAR_GRID_NAME = 'CalendarGrid';
211
184
 
185
+ /** Semantic kind of a {@link DateMarker}; the grid maps each kind to its own border treatment. */
186
+ export type DateMarkerTag = 'busy' | 'star';
187
+
188
+ /**
189
+ * A date (or inclusive date range) to mark on the grid. */
190
+ export type DateMarker = { startDate: Date; endDate?: Date; tag?: DateMarkerTag };
191
+
212
192
  type CalendarGridProps = {
213
193
  rows?: number;
214
- /** Dates to highlight on the grid. Each date that appears in this array receives a border indicator. */
215
- dates?: Date[];
194
+ /**
195
+ * Dates to mark on the grid; each marked day gets a border keyed off its `tag` kind (defaults to `busy`).
196
+ */
197
+ dates?: DateMarker[];
216
198
  /**
217
199
  * Date the grid scrolls into view on mount, and whenever this value changes.
218
200
  * Defaults to today. Pass a stable (memoized) Date so the grid does not
219
201
  * re-scroll on every render.
220
202
  */
221
203
  initialDate?: Date;
204
+ /**
205
+ * Weeks of context kept above a date when scrolling it into view (on mount and via the controller's
206
+ * `scrollTo`), so the date sits below the top edge rather than pinned to it. Defaults to 2.
207
+ */
208
+ scrollMargin?: number;
222
209
  /** Fired when a user selects a single date (click or arrow key). */
223
210
  onSelect?: (event: { date: Date }) => void;
224
211
  /**
@@ -230,7 +217,10 @@ type CalendarGridProps = {
230
217
  };
231
218
 
232
219
  const CalendarGrid = composable<HTMLDivElement, CalendarGridProps>(
233
- ({ classNames, rows, dates = [], initialDate, onSelect, onSelectRange, ...props }, forwardedRef) => {
220
+ (
221
+ { classNames, rows, dates = [], initialDate, scrollMargin = 2, onSelect, onSelectRange, ...props },
222
+ forwardedRef,
223
+ ) => {
234
224
  const { weekStartsOn, event, setIndex, selected, setSelected, range, setRange, pendingRange, setPendingRange } =
235
225
  useCalendarContext(CALENDAR_GRID_NAME);
236
226
  const { ref: containerRef, width = 0, height = 0 } = useResizeDetector();
@@ -239,36 +229,50 @@ const CalendarGrid = composable<HTMLDivElement, CalendarGridProps>(
239
229
  const gridRef = useRef<HTMLDivElement>(null);
240
230
  const today = useMemo(() => new Date(), []);
241
231
 
242
- // Build a set of ISO date strings (YYYY-MM-DD) for O(1) per-cell lookup.
243
- const dateSet = useMemo(() => new Set(dates.map((date) => startOfDay(date).toISOString())), [dates]);
232
+ // Map each marked ISO day to its tag kind, expanding ranges. `star` wins over `busy` on the same
233
+ // day so a starred event keeps its highlighted border.
234
+ const dateMarkers = useMemo(() => {
235
+ const markers = new Map<string, DateMarkerTag>();
236
+ for (const { startDate, endDate, tag = 'busy' } of dates) {
237
+ const end = endDate ? startOfDay(endDate) : startOfDay(startDate);
238
+ for (let date = startOfDay(startDate); date <= end; date = addDays(date, 1)) {
239
+ const iso = date.toISOString();
240
+ if (markers.get(iso) !== 'star') {
241
+ markers.set(iso, tag);
242
+ }
243
+ }
244
+ }
245
+
246
+ return markers;
247
+ }, [dates]);
244
248
 
245
- const hasDate = useCallback((date: Date) => dateSet.has(startOfDay(date).toISOString()), [dateSet]);
249
+ const getMarker = useCallback(
250
+ (date: Date): { tag: DateMarkerTag } | undefined => {
251
+ const iso = startOfDay(date).toISOString();
252
+ const tag = dateMarkers.get(iso);
253
+ return tag ? { tag } : undefined;
254
+ },
255
+ [dateMarkers],
256
+ );
246
257
 
247
258
  const [initialized, setInitialized] = useState(false);
248
259
  useEffect(() => {
249
260
  const index = getRowIndex(start, initialDate ?? today, weekStartsOn);
250
- listRef.current?.scrollToRow(index);
251
- }, [initialized, start, today, initialDate, weekStartsOn]);
261
+ // Keep `scrollMargin` weeks of context above the target row.
262
+ listRef.current?.scrollToRow(Math.max(0, index - scrollMargin));
263
+ }, [initialized, start, today, initialDate, weekStartsOn, scrollMargin]);
252
264
 
253
265
  useEffect(() => {
254
266
  return event.on((event) => {
255
- switch (event.type) {
256
- case 'scroll': {
257
- const index = getRowIndex(start, event.date, weekStartsOn);
258
- listRef.current?.scrollToRow(index);
259
- break;
260
- }
267
+ // `select` also sets the grid's selection (e.g. when the active event changes); the grid still
268
+ // owns selection — a user click sets it locally and isn't overwritten until the next `select`.
269
+ if (event.type === 'select') {
270
+ setSelected(event.date);
261
271
  }
272
+ const index = getRowIndex(start, event.date, weekStartsOn);
273
+ listRef.current?.scrollToRow(Math.max(0, index - scrollMargin));
262
274
  });
263
- }, [event]);
264
-
265
- const days = useMemo(() => {
266
- const weekStart = startOfWeek(new Date(), { weekStartsOn });
267
- return Array.from({ length: 7 }, (_, i) => {
268
- const day = addDays(weekStart, i);
269
- return format(day, 'EEE'); // Short day name (Mon, Tue, etc.)
270
- });
271
- }, []);
275
+ }, [event, start, weekStartsOn, scrollMargin, setSelected]);
272
276
 
273
277
  //
274
278
  // Selection refs.
@@ -575,33 +579,45 @@ const CalendarGrid = composable<HTMLDivElement, CalendarGridProps>(
575
579
  <div className='grid grid-cols-7 bg-input-surface' style={{ gridTemplateColumns: `repeat(7, ${size}px)` }}>
576
580
  {Array.from({ length: 7 }).map((_, i) => {
577
581
  const date = getDate(start, index, i, weekStartsOn);
578
- const inRange = isInRange(date, activeRange);
579
- const border = isSameDay(date, selected)
580
- ? 'border-primary-500'
581
- : isSameDay(date, today)
582
- ? 'border-amber-500'
583
- : hasDate(date)
584
- ? 'border-neutral-700 border-dashed'
582
+ const marker = getMarker(date);
583
+ const isToday = isSameDay(date, today);
584
+ const isCurrent = isSameDay(date, selected);
585
+ const dateClassNames = isToday
586
+ ? DATE_CLASS_NAMES.today
587
+ : marker?.tag === 'star'
588
+ ? DATE_CLASS_NAMES.starred
589
+ : marker
590
+ ? DATE_CLASS_NAMES.busy
585
591
  : undefined;
586
592
 
593
+ const inRange = isInRange(date, activeRange);
594
+
587
595
  return (
588
596
  <div
589
597
  key={i}
590
598
  data-date={startOfDay(date).toISOString()}
591
- className={mx(
592
- 'relative flex justify-center items-center cursor-pointer select-none',
593
- getBgColor(date),
594
- )}
599
+ className={mx('relative flex justify-center cursor-pointer select-none', getBgColor(date))}
595
600
  onPointerDown={(ev) => handleDayPointerDown(date, ev)}
596
601
  onPointerEnter={() => handleDayPointerEnter(date)}
597
602
  onPointerUp={() => handleDayPointerUp(date)}
598
603
  >
604
+ {/* Selection range */}
599
605
  {inRange && <div className='absolute inset-0 bg-primary-500/20' />}
600
- <span className='relative text-description text-sm'>{date.getDate()}</span>
601
- {!border && date.getDate() === 1 && (
606
+ {/* Month */}
607
+ {!dateClassNames && date.getDate() === 1 && (
602
608
  <span className='absolute top-0 text-xs text-description'>{format(date, 'MMM')}</span>
603
609
  )}
604
- {border && <div className={mx('absolute inset-1 border-2 rounded-full', border)} />}
610
+ {/* Day + Marker */}
611
+ <div
612
+ className={mx(
613
+ 'absolute inset-1 rounded-full flex justify-center items-center text-sm text-description',
614
+ dateClassNames,
615
+ )}
616
+ >
617
+ {date.getDate()}
618
+ </div>
619
+ {/* Current */}
620
+ {isCurrent && <div className={mx('absolute inset-0.5 rounded-full', DATE_CLASS_NAMES.current)} />}
605
621
  </div>
606
622
  );
607
623
  })}
@@ -609,7 +625,7 @@ const CalendarGrid = composable<HTMLDivElement, CalendarGridProps>(
609
625
  </div>
610
626
  );
611
627
  },
612
- [activeRange, handleDayPointerDown, handleDayPointerEnter, handleDayPointerUp, hasDate, selected, weekStartsOn],
628
+ [activeRange, handleDayPointerDown, handleDayPointerEnter, handleDayPointerUp, getMarker, selected, weekStartsOn],
613
629
  );
614
630
 
615
631
  return (
@@ -630,12 +646,8 @@ const CalendarGrid = composable<HTMLDivElement, CalendarGridProps>(
630
646
  onKeyDown={handleKeyDown}
631
647
  >
632
648
  {/* Day of week labels */}
633
- <div className='grid w-full grid-cols-7' style={{ width: defaultWidth }}>
634
- {days.map((date, i) => (
635
- <div key={i} className='flex justify-center p-2 text-sm font-thin'>
636
- {date}
637
- </div>
638
- ))}
649
+ <div style={{ width: defaultWidth }}>
650
+ <Weekdays weekStartsOn={weekStartsOn} columnWidth={size} />
639
651
  </div>
640
652
 
641
653
  {/* Grid */}
@@ -669,6 +681,15 @@ export const Calendar = {
669
681
  Root: CalendarRoot,
670
682
  Toolbar: CalendarToolbar,
671
683
  Grid: CalendarGrid,
684
+ Week: CalendarWeek,
672
685
  };
673
686
 
674
- export type { CalendarController, CalendarRootProps, CalendarToolbarProps, CalendarGridProps };
687
+ export type {
688
+ CalendarController,
689
+ CalendarEvent,
690
+ CalendarGridProps,
691
+ CalendarRootProps,
692
+ CalendarToolbarProps,
693
+ CalendarWeekProps,
694
+ Range,
695
+ };