@super-calendar/dom 2.1.4 → 2.2.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.
@@ -0,0 +1,360 @@
1
+ import { format } from "date-fns";
2
+ import {
3
+ type CSSProperties,
4
+ type ReactElement,
5
+ type ReactNode,
6
+ useEffect,
7
+ useId,
8
+ useRef,
9
+ useState,
10
+ } from "react";
11
+ import {
12
+ type DateRange,
13
+ type DateSelectionConstraints,
14
+ nextDateRange,
15
+ type WeekStartsOn,
16
+ } from "@super-calendar/core";
17
+ import { MonthList, type MonthListSlot } from "./MonthList";
18
+ import { createSlots, type SlotStyleProps } from "./slots";
19
+ import { type DomCalendarTheme, mergeDomTheme } from "./theme";
20
+
21
+ /** Styleable chrome of the pickers (the calendar's own slots forward through too). */
22
+ export type DatePickerSlot = "trigger" | "popover" | "footer" | "clear";
23
+
24
+ interface PickerBaseProps
25
+ extends DateSelectionConstraints, SlotStyleProps<DatePickerSlot | MonthListSlot> {
26
+ /** First day of the week. Sunday = 0 (default) … Saturday = 6. */
27
+ weekStartsOn?: WeekStartsOn;
28
+ /** Height of the popover calendar viewport in px (default 320). */
29
+ height?: number;
30
+ /** Placeholder shown on the trigger when nothing is selected. */
31
+ placeholder?: string;
32
+ /** Disable the whole field. */
33
+ disabled?: boolean;
34
+ /** Theme overrides for the calendar. */
35
+ theme?: Partial<DomCalendarTheme>;
36
+ /** Class applied to the root element. */
37
+ className?: string;
38
+ /** Inline styles applied to the root element. */
39
+ style?: CSSProperties;
40
+ }
41
+
42
+ const triggerDefault: CSSProperties = {
43
+ display: "inline-flex",
44
+ alignItems: "center",
45
+ justifyContent: "space-between",
46
+ gap: 8,
47
+ minWidth: 220,
48
+ padding: "8px 12px",
49
+ borderRadius: 8,
50
+ cursor: "pointer",
51
+ font: "inherit",
52
+ textAlign: "left",
53
+ };
54
+
55
+ const popoverDefault: CSSProperties = {
56
+ position: "absolute",
57
+ zIndex: 20,
58
+ marginTop: 6,
59
+ overflow: "hidden",
60
+ borderRadius: 12,
61
+ minWidth: 300,
62
+ };
63
+
64
+ /**
65
+ * The shared popover shell: a trigger button and a calendar panel that closes on
66
+ * outside-click and Escape, and returns focus to the trigger. `renderCalendar`
67
+ * gets a close callback so a single-date pick can dismiss immediately.
68
+ */
69
+ function usePopover() {
70
+ const [open, setOpen] = useState(false);
71
+ const rootRef = useRef<HTMLDivElement>(null);
72
+ const triggerRef = useRef<HTMLButtonElement>(null);
73
+
74
+ useEffect(() => {
75
+ if (!open) return;
76
+ const onDown = (e: MouseEvent) => {
77
+ if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
78
+ };
79
+ const onKey = (e: KeyboardEvent) => {
80
+ if (e.key === "Escape") {
81
+ setOpen(false);
82
+ triggerRef.current?.focus();
83
+ }
84
+ };
85
+ document.addEventListener("mousedown", onDown);
86
+ document.addEventListener("keydown", onKey);
87
+ return () => {
88
+ document.removeEventListener("mousedown", onDown);
89
+ document.removeEventListener("keydown", onKey);
90
+ };
91
+ }, [open]);
92
+
93
+ return { open, setOpen, rootRef, triggerRef };
94
+ }
95
+
96
+ interface ShellProps {
97
+ open: boolean;
98
+ setOpen: (open: boolean) => void;
99
+ rootRef: React.RefObject<HTMLDivElement | null>;
100
+ triggerRef: React.RefObject<HTMLButtonElement | null>;
101
+ theme: DomCalendarTheme;
102
+ slot: ReturnType<typeof createSlots<DatePickerSlot | MonthListSlot>>;
103
+ label: string;
104
+ hasValue: boolean;
105
+ placeholder: string;
106
+ disabled?: boolean;
107
+ className?: string;
108
+ style?: CSSProperties;
109
+ ariaLabel: string;
110
+ children: ReactNode;
111
+ }
112
+
113
+ function PickerShell({
114
+ open,
115
+ setOpen,
116
+ rootRef,
117
+ triggerRef,
118
+ theme,
119
+ slot,
120
+ label,
121
+ hasValue,
122
+ placeholder,
123
+ disabled,
124
+ className,
125
+ style,
126
+ ariaLabel,
127
+ children,
128
+ }: ShellProps): ReactElement {
129
+ const dialogId = useId();
130
+ return (
131
+ <div
132
+ ref={rootRef}
133
+ className={className}
134
+ style={{
135
+ position: "relative",
136
+ display: "inline-block",
137
+ fontFamily: theme.fontFamily,
138
+ ...style,
139
+ }}
140
+ >
141
+ <button
142
+ ref={triggerRef}
143
+ type="button"
144
+ disabled={disabled}
145
+ aria-haspopup="dialog"
146
+ aria-expanded={open}
147
+ aria-controls={open ? dialogId : undefined}
148
+ onClick={() => setOpen(!open)}
149
+ {...slot("trigger", {
150
+ base: triggerDefault,
151
+ themed: {
152
+ border: `1px solid ${theme.gridLine}`,
153
+ background: theme.eventBackground,
154
+ color: hasValue ? theme.text : theme.textMuted,
155
+ opacity: disabled ? 0.5 : 1,
156
+ },
157
+ })}
158
+ >
159
+ <span>{hasValue ? label : placeholder}</span>
160
+ <span aria-hidden>▾</span>
161
+ </button>
162
+ {open ? (
163
+ <div
164
+ id={dialogId}
165
+ role="dialog"
166
+ aria-label={ariaLabel}
167
+ {...slot("popover", {
168
+ base: popoverDefault,
169
+ themed: { background: theme.eventBackground, border: `1px solid ${theme.gridLine}` },
170
+ })}
171
+ >
172
+ {children}
173
+ </div>
174
+ ) : null}
175
+ </div>
176
+ );
177
+ }
178
+
179
+ /** Props for {@link DatePicker}. */
180
+ export interface DatePickerProps extends PickerBaseProps {
181
+ /** The selected day, or `null`. */
182
+ value: Date | null;
183
+ /** Called with the day the user picks. */
184
+ onChange: (date: Date) => void;
185
+ /** Format the trigger label. Default: `d MMM yyyy`. */
186
+ formatLabel?: (date: Date) => string;
187
+ }
188
+
189
+ /**
190
+ * A single-date input with a popover calendar. Controlled via `value` / `onChange`.
191
+ * Composed from {@link MonthList}; forward calendar slots (e.g. `dayBadge`) through
192
+ * `classNames` to restyle the popover.
193
+ *
194
+ * @example
195
+ * ```tsx
196
+ * const [value, setValue] = useState<Date | null>(null);
197
+ * <DatePicker value={value} onChange={setValue} />
198
+ * ```
199
+ */
200
+ export function DatePicker({
201
+ value,
202
+ onChange,
203
+ formatLabel = (d) => format(d, "d MMM yyyy"),
204
+ weekStartsOn = 0,
205
+ height = 320,
206
+ placeholder = "Select a date",
207
+ disabled,
208
+ theme: themeOverrides,
209
+ className,
210
+ style,
211
+ classNames,
212
+ styles,
213
+ minDate,
214
+ maxDate,
215
+ isDateDisabled,
216
+ }: DatePickerProps): ReactElement {
217
+ const theme = mergeDomTheme(themeOverrides);
218
+ const slot = createSlots<DatePickerSlot | MonthListSlot>({ classNames, styles });
219
+ const { open, setOpen, rootRef, triggerRef } = usePopover();
220
+
221
+ return (
222
+ <PickerShell
223
+ open={open}
224
+ setOpen={setOpen}
225
+ rootRef={rootRef}
226
+ triggerRef={triggerRef}
227
+ theme={theme}
228
+ slot={slot}
229
+ label={value ? formatLabel(value) : ""}
230
+ hasValue={!!value}
231
+ placeholder={placeholder}
232
+ disabled={disabled}
233
+ className={className}
234
+ style={style}
235
+ ariaLabel="Choose a date"
236
+ >
237
+ <MonthList
238
+ date={value ?? new Date()}
239
+ weekStartsOn={weekStartsOn}
240
+ selectedDates={value ? [value] : []}
241
+ minDate={minDate}
242
+ maxDate={maxDate}
243
+ isDateDisabled={isDateDisabled}
244
+ onPressDay={(day) => {
245
+ onChange(day);
246
+ setOpen(false);
247
+ triggerRef.current?.focus();
248
+ }}
249
+ height={height}
250
+ classNames={classNames}
251
+ styles={styles}
252
+ />
253
+ </PickerShell>
254
+ );
255
+ }
256
+
257
+ /** Props for {@link DateRangePicker}. */
258
+ export interface DateRangePickerProps extends PickerBaseProps {
259
+ /** The selected range, or `null`. `end` is `null` while only the start is picked. */
260
+ value: DateRange | null;
261
+ /** Called with the next range on each pick (start-only, then complete). */
262
+ onChange: (range: DateRange | null) => void;
263
+ /** Format the trigger label. Default: `d MMM – d MMM yyyy`. */
264
+ formatLabel?: (range: DateRange) => string;
265
+ }
266
+
267
+ function defaultRangeLabel(range: DateRange): string {
268
+ const start = format(range.start, "d MMM yyyy");
269
+ return range.end ? `${start} – ${format(range.end, "d MMM yyyy")}` : `${start} – …`;
270
+ }
271
+
272
+ /**
273
+ * A date-range input with a popover calendar. Controlled via `value` / `onChange`;
274
+ * each pick advances the range through {@link nextDateRange} (start, then end), and
275
+ * the popover closes once both ends are set.
276
+ *
277
+ * @example
278
+ * ```tsx
279
+ * const [range, setRange] = useState<DateRange | null>(null);
280
+ * <DateRangePicker value={range} onChange={setRange} />
281
+ * ```
282
+ */
283
+ export function DateRangePicker({
284
+ value,
285
+ onChange,
286
+ formatLabel = defaultRangeLabel,
287
+ weekStartsOn = 0,
288
+ height = 320,
289
+ placeholder = "Select dates",
290
+ disabled,
291
+ theme: themeOverrides,
292
+ className,
293
+ style,
294
+ classNames,
295
+ styles,
296
+ minDate,
297
+ maxDate,
298
+ isDateDisabled,
299
+ }: DateRangePickerProps): ReactElement {
300
+ const theme = mergeDomTheme(themeOverrides);
301
+ const slot = createSlots<DatePickerSlot | MonthListSlot>({ classNames, styles });
302
+ const { open, setOpen, rootRef, triggerRef } = usePopover();
303
+ const constraints: DateSelectionConstraints = { minDate, maxDate, isDateDisabled };
304
+
305
+ return (
306
+ <PickerShell
307
+ open={open}
308
+ setOpen={setOpen}
309
+ rootRef={rootRef}
310
+ triggerRef={triggerRef}
311
+ theme={theme}
312
+ slot={slot}
313
+ label={value ? formatLabel(value) : ""}
314
+ hasValue={!!value}
315
+ placeholder={placeholder}
316
+ disabled={disabled}
317
+ className={className}
318
+ style={style}
319
+ ariaLabel="Choose a date range"
320
+ >
321
+ <MonthList
322
+ date={value?.start ?? new Date()}
323
+ weekStartsOn={weekStartsOn}
324
+ selectedRange={value ?? undefined}
325
+ minDate={minDate}
326
+ maxDate={maxDate}
327
+ isDateDisabled={isDateDisabled}
328
+ onPressDay={(day) => {
329
+ const next = nextDateRange(value, day, constraints);
330
+ onChange(next);
331
+ // Close once a full range is set.
332
+ if (next?.end) {
333
+ setOpen(false);
334
+ triggerRef.current?.focus();
335
+ }
336
+ }}
337
+ height={height}
338
+ classNames={classNames}
339
+ styles={styles}
340
+ />
341
+ <div
342
+ {...slot("footer", {
343
+ base: { display: "flex", justifyContent: "flex-end", padding: "8px 10px" },
344
+ themed: { borderTop: `1px solid ${theme.gridLine}` },
345
+ })}
346
+ >
347
+ <button
348
+ type="button"
349
+ onClick={() => onChange(null)}
350
+ {...slot("clear", {
351
+ base: { border: "none", background: "transparent", cursor: "pointer", font: "inherit" },
352
+ themed: { color: theme.textMuted },
353
+ })}
354
+ >
355
+ Clear
356
+ </button>
357
+ </div>
358
+ </PickerShell>
359
+ );
360
+ }
package/src/MonthList.tsx CHANGED
@@ -7,14 +7,25 @@ import {
7
7
  compareDayEvents,
8
8
  type DateRange,
9
9
  type DateSelectionConstraints,
10
+ type EventAccessibilityLabeler,
10
11
  groupEventsByDay,
12
+ type WeekdayFormat,
11
13
  type WeekStartsOn,
12
14
  } from "@super-calendar/core";
13
- import { type DomMonthEvent, MonthView } from "./MonthView";
15
+ import { type DomMonthEvent, MonthView, type MonthViewSlot } from "./MonthView";
16
+ import { createSlots, type SlotStyleProps } from "./slots";
14
17
  import { type DomCalendarTheme, mergeDomTheme } from "./theme";
15
18
 
19
+ /**
20
+ * Styleable parts of {@link MonthList}. The shared weekday header uses `weekdays`
21
+ * / `weekday`; every other slot is forwarded to each month's {@link MonthView}
22
+ * (e.g. `title`, `day`, `chip`). See {@link MonthViewSlot}.
23
+ */
24
+ export type MonthListSlot = MonthViewSlot;
25
+
16
26
  /** Props for {@link MonthList}. */
17
- export interface MonthListProps<T = unknown> extends DateSelectionConstraints {
27
+ export interface MonthListProps<T = unknown>
28
+ extends DateSelectionConstraints, SlotStyleProps<MonthListSlot> {
18
29
  /** Anchor month; the list spans `pastMonths` before to `futureMonths` after. */
19
30
  date: Date;
20
31
  /** Months to render before the anchor (default 1). */
@@ -23,10 +34,18 @@ export interface MonthListProps<T = unknown> extends DateSelectionConstraints {
23
34
  futureMonths?: number;
24
35
  /** First day of the week. Sunday = 0 (default) ... Saturday = 6. */
25
36
  weekStartsOn?: WeekStartsOn;
37
+ /** Weekday header label width: `narrow` ("M"), `short` ("Mon", default), or `long` ("Monday"). */
38
+ weekdayFormat?: WeekdayFormat;
26
39
  /** Events to render as chips in each day cell (calendar layout when provided). */
27
40
  events?: CalendarEvent<T>[];
28
41
  /** Custom chip renderer; falls back to the built-in titled chip. */
29
42
  renderEvent?: DomMonthEvent<T>;
43
+ /**
44
+ * Override the screen-reader label for each event chip. Receives the event and a
45
+ * `{ mode: "month", isAllDay, ampm: false }` context; return the full text to
46
+ * announce. Defaults to the event title and day (e.g. "Standup, 15 July").
47
+ */
48
+ eventAccessibilityLabel?: EventAccessibilityLabeler<T>;
30
49
  /** Max chips shown per day before a "+N more" row (default 3). */
31
50
  maxVisibleEventCount?: number;
32
51
  /** Template for the overflow row; `{moreCount}` is replaced. */
@@ -70,9 +89,11 @@ export function MonthList<T = unknown>({
70
89
  date,
71
90
  pastMonths = 1,
72
91
  futureMonths = 12,
92
+ weekdayFormat = "short",
73
93
  weekStartsOn = 0,
74
94
  events,
75
95
  renderEvent,
96
+ eventAccessibilityLabel,
76
97
  maxVisibleEventCount,
77
98
  moreLabel,
78
99
  onPressEvent,
@@ -90,8 +111,11 @@ export function MonthList<T = unknown>({
90
111
  onPressDay,
91
112
  className,
92
113
  style,
114
+ classNames,
115
+ styles,
93
116
  }: MonthListProps<T>): ReactElement {
94
117
  const theme = useMemo(() => mergeDomTheme(themeOverrides), [themeOverrides]);
118
+ const slot = createSlots<MonthListSlot>({ classNames, styles });
95
119
 
96
120
  const months = useMemo(() => {
97
121
  const first = startOfMonth(addMonths(date, -pastMonths));
@@ -102,8 +126,8 @@ export function MonthList<T = unknown>({
102
126
  // The weekday header only depends on the week start and locale, not the
103
127
  // rendered month window.
104
128
  const weekdays = useMemo(
105
- () => buildMonthGrid(date, { weekStartsOn, locale }).weekdays,
106
- [date, weekStartsOn, locale],
129
+ () => buildMonthGrid(date, { weekStartsOn, weekdayFormat, locale }).weekdays,
130
+ [date, weekStartsOn, weekdayFormat, locale],
107
131
  );
108
132
 
109
133
  // Build the day→events index once for the whole list rather than per month.
@@ -128,17 +152,22 @@ export function MonthList<T = unknown>({
128
152
  style={{ fontFamily: theme.fontFamily, color: theme.text, ...style }}
129
153
  >
130
154
  <div
131
- style={{
132
- display: "grid",
133
- gridTemplateColumns: "repeat(7, minmax(0, 1fr))",
134
- borderBottom: `1px solid ${theme.gridLine}`,
135
- padding: "8px 0",
136
- }}
155
+ {...slot("weekdays", {
156
+ base: { display: "grid", gridTemplateColumns: "repeat(7, minmax(0, 1fr))" },
157
+ themed: { borderBottom: `1px solid ${theme.gridLine}`, padding: "8px 0" },
158
+ })}
137
159
  >
138
160
  {weekdays.map((wd) => (
139
161
  <span
140
162
  key={wd.label}
141
- style={{ textAlign: "center", fontSize: 12, fontWeight: 600, color: theme.textMuted }}
163
+ {...slot("weekday", {
164
+ themed: {
165
+ textAlign: "center",
166
+ fontSize: 12,
167
+ fontWeight: 600,
168
+ color: theme.textMuted,
169
+ },
170
+ })}
142
171
  >
143
172
  {wd.label}
144
173
  </span>
@@ -161,6 +190,7 @@ export function MonthList<T = unknown>({
161
190
  events={events}
162
191
  eventsByDay={eventsByDay}
163
192
  renderEvent={renderEvent}
193
+ eventAccessibilityLabel={eventAccessibilityLabel}
164
194
  maxVisibleEventCount={maxVisibleEventCount}
165
195
  moreLabel={moreLabel}
166
196
  onPressEvent={onPressEvent}
@@ -177,6 +207,8 @@ export function MonthList<T = unknown>({
177
207
  isDateDisabled={isDateDisabled}
178
208
  keyboardDayNavigation={keyboardDayNavigation}
179
209
  onPressDay={onPressDay}
210
+ classNames={classNames}
211
+ styles={styles}
180
212
  />
181
213
  )}
182
214
  />