@atlure/ui 0.5.0 → 0.6.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atlure/ui",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "Atlure React Native component library: NativeWind primitives built on @atlure/tokens, shipped as untranspiled TypeScript source",
5
5
  "license": "MIT",
6
6
  "author": "David Moreira",
@@ -37,9 +37,9 @@
37
37
  "class-variance-authority": "0.7.1",
38
38
  "clsx": "2.1.1",
39
39
  "tailwind-merge": "2.6.0",
40
- "@atlure/icons": "0.5.0",
41
- "@atlure/tokens": "0.5.0",
42
- "@atlure/types": "0.5.0"
40
+ "@atlure/icons": "0.6.1",
41
+ "@atlure/tokens": "0.6.1",
42
+ "@atlure/types": "0.6.1"
43
43
  },
44
44
  "peerDependencies": {
45
45
  "nativewind": "^4.2.6",
@@ -0,0 +1,189 @@
1
+ import { ChevronLeft, ChevronRight, iconSize } from "@atlure/icons";
2
+ import { useEffect, useMemo, useState } from "react";
3
+ import { Pressable, View } from "react-native";
4
+
5
+ import {
6
+ addMonths,
7
+ buildMonthGrid,
8
+ firstDayOfWeek,
9
+ formatFullDate,
10
+ formatMonthLabel,
11
+ formatWeekdayShort,
12
+ isDateInRange,
13
+ } from "../../lib/calendar";
14
+ import { cn } from "../../lib/cn";
15
+ import { useLocale } from "../../lib/locale";
16
+ import {
17
+ calendarContainerClassName,
18
+ calendarDayCellClassName,
19
+ calendarDayCellDisabledClassName,
20
+ calendarDayCellInRangeClassName,
21
+ calendarDayCellRangeEndClassName,
22
+ calendarDayCellRangeStartClassName,
23
+ calendarDayCellSelectedClassName,
24
+ calendarDayLabelClassName,
25
+ calendarDayLabelOutsideMonthClassName,
26
+ calendarDayLabelSelectedClassName,
27
+ calendarHeaderClassName,
28
+ calendarMarkerDotClassName,
29
+ calendarMonthLabelClassName,
30
+ calendarNavButtonClassName,
31
+ calendarWeekRowClassName,
32
+ calendarWeekdayCellClassName,
33
+ calendarWeekdayLabelClassName,
34
+ calendarWeekdayRowClassName,
35
+ } from "../../variants/calendar-variants";
36
+ import { Text } from "../text/text";
37
+
38
+ export interface CalendarHighlight {
39
+ start?: string;
40
+ end?: string;
41
+ inProgressStart?: string;
42
+ }
43
+
44
+ export interface CalendarProps {
45
+ yearMonth: string;
46
+ selected?: string;
47
+ onSelect?: (iso: string) => void;
48
+ onMonthChange?: (yearMonth: string) => void;
49
+ disabledDates?: (iso: string) => boolean;
50
+ minDate?: string;
51
+ maxDate?: string;
52
+ markers?: readonly string[];
53
+ highlight?: CalendarHighlight;
54
+ accessibilityLabel?: string;
55
+ previousMonthAccessibilityLabel?: string;
56
+ nextMonthAccessibilityLabel?: string;
57
+ className?: string;
58
+ }
59
+
60
+ export function Calendar({
61
+ yearMonth,
62
+ selected,
63
+ onSelect,
64
+ onMonthChange,
65
+ disabledDates,
66
+ minDate,
67
+ maxDate,
68
+ markers,
69
+ highlight,
70
+ accessibilityLabel,
71
+ previousMonthAccessibilityLabel = "Previous month",
72
+ nextMonthAccessibilityLabel = "Next month",
73
+ className,
74
+ }: CalendarProps) {
75
+ const { locale } = useLocale();
76
+ const [displayYearMonth, setDisplayYearMonth] = useState(yearMonth);
77
+
78
+ useEffect(() => setDisplayYearMonth(yearMonth), [yearMonth]);
79
+
80
+ const firstDow = useMemo(() => firstDayOfWeek(locale), [locale]);
81
+ const weeks = useMemo(
82
+ () => buildMonthGrid(displayYearMonth, firstDow),
83
+ [displayYearMonth, firstDow],
84
+ );
85
+ const weekdayLabels = useMemo(
86
+ () => Array.from({ length: 7 }, (_, i) => formatWeekdayShort((firstDow + i) % 7, locale)),
87
+ [firstDow, locale],
88
+ );
89
+ const markerSet = useMemo(() => new Set(markers ?? []), [markers]);
90
+
91
+ const changeMonth = (delta: number) => {
92
+ const next = addMonths(displayYearMonth, delta);
93
+ setDisplayYearMonth(next);
94
+ onMonthChange?.(next);
95
+ };
96
+
97
+ return (
98
+ <View
99
+ accessibilityLabel={accessibilityLabel}
100
+ className={cn(calendarContainerClassName, className)}
101
+ >
102
+ <View className={calendarHeaderClassName}>
103
+ <Pressable
104
+ accessibilityRole="button"
105
+ accessibilityLabel={previousMonthAccessibilityLabel}
106
+ className={calendarNavButtonClassName}
107
+ onPress={() => changeMonth(-1)}
108
+ >
109
+ <ChevronLeft size={iconSize.md} />
110
+ </Pressable>
111
+ <Text className={calendarMonthLabelClassName}>
112
+ {formatMonthLabel(displayYearMonth, locale)}
113
+ </Text>
114
+ <Pressable
115
+ accessibilityRole="button"
116
+ accessibilityLabel={nextMonthAccessibilityLabel}
117
+ className={calendarNavButtonClassName}
118
+ onPress={() => changeMonth(1)}
119
+ >
120
+ <ChevronRight size={iconSize.md} />
121
+ </Pressable>
122
+ </View>
123
+
124
+ <View className={calendarWeekdayRowClassName}>
125
+ {weekdayLabels.map((label, weekdayIndex) => (
126
+ <View key={weekdayIndex} className={calendarWeekdayCellClassName}>
127
+ <Text className={calendarWeekdayLabelClassName}>{label}</Text>
128
+ </View>
129
+ ))}
130
+ </View>
131
+
132
+ {weeks.map((week, weekIndex) => (
133
+ <View key={weekIndex} className={calendarWeekRowClassName}>
134
+ {week.map((cell) => {
135
+ const isDisabled =
136
+ disabledDates?.(cell.iso) === true || !isDateInRange(cell.iso, minDate, maxDate);
137
+ const isSelected = selected === cell.iso;
138
+ const isRangeStart = highlight?.start === cell.iso;
139
+ const isRangeEnd = highlight?.end === cell.iso;
140
+ const isInProgressStart = highlight?.inProgressStart === cell.iso;
141
+ const isInRange =
142
+ highlight?.start !== undefined &&
143
+ highlight.end !== undefined &&
144
+ cell.iso > highlight.start &&
145
+ cell.iso < highlight.end;
146
+ const isHighlighted =
147
+ isSelected || isRangeStart || isRangeEnd || isInProgressStart;
148
+ const hasMarker = markerSet.has(cell.iso);
149
+
150
+ return (
151
+ <Pressable
152
+ key={cell.iso}
153
+ accessibilityRole="button"
154
+ accessibilityLabel={formatFullDate(cell.iso, locale)}
155
+ accessibilityState={{ disabled: isDisabled, selected: isHighlighted }}
156
+ aria-disabled={isDisabled}
157
+ aria-selected={isHighlighted}
158
+ disabled={isDisabled}
159
+ onPress={() => {
160
+ if (isDisabled) return;
161
+ onSelect?.(cell.iso);
162
+ }}
163
+ className={cn(
164
+ calendarDayCellClassName,
165
+ isInRange && calendarDayCellInRangeClassName,
166
+ isRangeStart && calendarDayCellRangeStartClassName,
167
+ isRangeEnd && calendarDayCellRangeEndClassName,
168
+ (isSelected || isInProgressStart) && calendarDayCellSelectedClassName,
169
+ isDisabled && calendarDayCellDisabledClassName,
170
+ )}
171
+ >
172
+ <Text
173
+ className={cn(
174
+ calendarDayLabelClassName,
175
+ !cell.isCurrentMonth && calendarDayLabelOutsideMonthClassName,
176
+ isHighlighted && calendarDayLabelSelectedClassName,
177
+ )}
178
+ >
179
+ {cell.dayNumber}
180
+ </Text>
181
+ {hasMarker && <View className={calendarMarkerDotClassName} />}
182
+ </Pressable>
183
+ );
184
+ })}
185
+ </View>
186
+ ))}
187
+ </View>
188
+ );
189
+ }
@@ -0,0 +1,104 @@
1
+ import { useState } from "react";
2
+ import { View } from "react-native";
3
+
4
+ import { daysBetween } from "../../lib/calendar";
5
+ import { cn } from "../../lib/cn";
6
+ import { formatDateRange, type DateStyle } from "../../lib/format/date";
7
+ import { useLocale } from "../../lib/locale";
8
+ import {
9
+ dateRangePickerContainerClassName,
10
+ dateRangePickerSummaryClassName,
11
+ } from "../../variants/calendar-variants";
12
+ import { Calendar } from "../calendar/calendar";
13
+ import { Text } from "../text/text";
14
+
15
+ export interface DateRange {
16
+ start?: string;
17
+ end?: string;
18
+ }
19
+
20
+ export interface DateRangePickerProps {
21
+ yearMonth: string;
22
+ onYearMonthChange?: (yearMonth: string) => void;
23
+ range?: DateRange;
24
+ onRangeChange: (range: { start: string; end: string }) => void;
25
+ minNights?: number;
26
+ maxNights?: number;
27
+ disabledDates?: (iso: string) => boolean;
28
+ minDate?: string;
29
+ maxDate?: string;
30
+ markers?: readonly string[];
31
+ accessibilityLabel?: string;
32
+ summaryAccessibilityLabel?: string;
33
+ summaryDateStyle?: DateStyle;
34
+ className?: string;
35
+ }
36
+
37
+ export function DateRangePicker({
38
+ yearMonth,
39
+ onYearMonthChange,
40
+ range,
41
+ onRangeChange,
42
+ minNights,
43
+ maxNights,
44
+ disabledDates,
45
+ minDate,
46
+ maxDate,
47
+ markers,
48
+ accessibilityLabel,
49
+ summaryAccessibilityLabel = "Selected date range",
50
+ summaryDateStyle = "medium",
51
+ className,
52
+ }: DateRangePickerProps) {
53
+ const { locale } = useLocale();
54
+ const [inProgressStart, setInProgressStart] = useState<string | undefined>(undefined);
55
+
56
+ const handleSelect = (iso: string) => {
57
+ if (inProgressStart === undefined) {
58
+ setInProgressStart(iso);
59
+ return;
60
+ }
61
+
62
+ setInProgressStart(undefined);
63
+
64
+ const [start, end] =
65
+ inProgressStart < iso ? [inProgressStart, iso] : [iso, inProgressStart];
66
+ const nights = daysBetween(start, end);
67
+
68
+ if (nights <= 0) return;
69
+ if (minNights !== undefined && nights < minNights) return;
70
+ if (maxNights !== undefined && nights > maxNights) return;
71
+
72
+ onRangeChange({ start, end });
73
+ };
74
+
75
+ return (
76
+ <View
77
+ accessibilityLabel={accessibilityLabel}
78
+ className={cn(dateRangePickerContainerClassName, className)}
79
+ >
80
+ <Calendar
81
+ yearMonth={yearMonth}
82
+ onMonthChange={onYearMonthChange}
83
+ onSelect={handleSelect}
84
+ disabledDates={disabledDates}
85
+ minDate={minDate}
86
+ maxDate={maxDate}
87
+ markers={markers}
88
+ highlight={{
89
+ start: range?.start,
90
+ end: range?.end,
91
+ inProgressStart,
92
+ }}
93
+ />
94
+ {range?.start !== undefined && range.end !== undefined && (
95
+ <Text
96
+ accessibilityLabel={summaryAccessibilityLabel}
97
+ className={dateRangePickerSummaryClassName}
98
+ >
99
+ {formatDateRange(range.start, range.end, locale, summaryDateStyle)}
100
+ </Text>
101
+ )}
102
+ </View>
103
+ );
104
+ }
@@ -0,0 +1,131 @@
1
+ import { useMemo } from "react";
2
+ import { Pressable, ScrollView, View } from "react-native";
3
+
4
+ import {
5
+ formatHourLabel,
6
+ formatTimeValue,
7
+ isLocaleHour12,
8
+ padTwoDigits,
9
+ parseTimeValue,
10
+ } from "../../lib/calendar";
11
+ import { cn } from "../../lib/cn";
12
+ import { useLocale } from "../../lib/locale";
13
+ import {
14
+ timePickerBodyClassName,
15
+ timePickerColumnClassName,
16
+ timePickerOptionClassName,
17
+ timePickerOptionLabelClassName,
18
+ timePickerOptionSelectedClassName,
19
+ } from "../../variants/calendar-variants";
20
+ import { Sheet } from "../sheet/sheet";
21
+ import { Text } from "../text/text";
22
+
23
+ export interface TimePickerProps {
24
+ isOpen: boolean;
25
+ onClose: () => void;
26
+ value?: string;
27
+ onChange: (value: string) => void;
28
+ minuteStep?: number;
29
+ hour12?: boolean;
30
+ bottomInset?: number;
31
+ accessibilityLabel: string;
32
+ backdropAccessibilityLabel: string;
33
+ hourColumnAccessibilityLabel?: string;
34
+ minuteColumnAccessibilityLabel?: string;
35
+ }
36
+
37
+ const HOURS_IN_DAY = 24;
38
+ const MINUTES_IN_HOUR = 60;
39
+
40
+ export function TimePicker({
41
+ isOpen,
42
+ onClose,
43
+ value,
44
+ onChange,
45
+ minuteStep = 15,
46
+ hour12,
47
+ bottomInset,
48
+ accessibilityLabel,
49
+ backdropAccessibilityLabel,
50
+ hourColumnAccessibilityLabel = "Hour",
51
+ minuteColumnAccessibilityLabel = "Minute",
52
+ }: TimePickerProps) {
53
+ const { locale } = useLocale();
54
+ const useHour12 = hour12 ?? isLocaleHour12(locale);
55
+
56
+ const parsed = value !== undefined ? parseTimeValue(value) : undefined;
57
+ const selectedHour = parsed?.hour;
58
+ const selectedMinute = parsed?.minute;
59
+
60
+ const hours = useMemo(() => Array.from({ length: HOURS_IN_DAY }, (_, i) => i), []);
61
+ const minutes = useMemo(
62
+ () =>
63
+ Array.from({ length: Math.ceil(MINUTES_IN_HOUR / minuteStep) }, (_, i) => i * minuteStep),
64
+ [minuteStep],
65
+ );
66
+
67
+ const emit = (hour: number, minute: number) => onChange(formatTimeValue(hour, minute));
68
+
69
+ return (
70
+ <Sheet
71
+ isOpen={isOpen}
72
+ onClose={onClose}
73
+ accessibilityLabel={accessibilityLabel}
74
+ backdropAccessibilityLabel={backdropAccessibilityLabel}
75
+ bottomInset={bottomInset}
76
+ >
77
+ <View className={timePickerBodyClassName}>
78
+ <ScrollView
79
+ accessibilityLabel={hourColumnAccessibilityLabel}
80
+ className={timePickerColumnClassName}
81
+ >
82
+ {hours.map((hour) => {
83
+ const isActive = hour === selectedHour;
84
+ return (
85
+ <Pressable
86
+ key={hour}
87
+ accessibilityRole="button"
88
+ accessibilityLabel={formatHourLabel(hour, useHour12, locale)}
89
+ accessibilityState={{ selected: isActive }}
90
+ aria-selected={isActive}
91
+ onPress={() => emit(hour, selectedMinute ?? 0)}
92
+ className={cn(
93
+ timePickerOptionClassName,
94
+ isActive && timePickerOptionSelectedClassName,
95
+ )}
96
+ >
97
+ <Text className={timePickerOptionLabelClassName}>
98
+ {formatHourLabel(hour, useHour12, locale)}
99
+ </Text>
100
+ </Pressable>
101
+ );
102
+ })}
103
+ </ScrollView>
104
+ <ScrollView
105
+ accessibilityLabel={minuteColumnAccessibilityLabel}
106
+ className={timePickerColumnClassName}
107
+ >
108
+ {minutes.map((minute) => {
109
+ const isActive = minute === selectedMinute;
110
+ return (
111
+ <Pressable
112
+ key={minute}
113
+ accessibilityRole="button"
114
+ accessibilityLabel={padTwoDigits(minute)}
115
+ accessibilityState={{ selected: isActive }}
116
+ aria-selected={isActive}
117
+ onPress={() => emit(selectedHour ?? 0, minute)}
118
+ className={cn(
119
+ timePickerOptionClassName,
120
+ isActive && timePickerOptionSelectedClassName,
121
+ )}
122
+ >
123
+ <Text className={timePickerOptionLabelClassName}>{padTwoDigits(minute)}</Text>
124
+ </Pressable>
125
+ );
126
+ })}
127
+ </ScrollView>
128
+ </View>
129
+ </Sheet>
130
+ );
131
+ }
package/src/index.ts CHANGED
@@ -7,10 +7,12 @@ export * from "./components/badge/badge";
7
7
  export * from "./components/badge/urgency-badge";
8
8
  export * from "./components/button/button";
9
9
  export * from "./components/button/types";
10
+ export * from "./components/calendar/calendar";
10
11
  export * from "./components/card/card";
11
12
  export * from "./components/card/card-section";
12
13
  export * from "./components/checkbox/checkbox";
13
14
  export * from "./components/chip/chip";
15
+ export * from "./components/date-range-picker/date-range-picker";
14
16
  export * from "./components/dialog/dialog";
15
17
  export * from "./components/empty-state/empty-state";
16
18
  export * from "./components/error-state/error-state";
@@ -50,9 +52,11 @@ export * from "./components/tabs/tabs-context";
50
52
  export * from "./components/text/text";
51
53
  export * from "./components/text/text-class-context";
52
54
  export * from "./components/textarea/textarea";
55
+ export * from "./components/time-picker/time-picker";
53
56
  export * from "./components/toast/toast";
54
57
  export * from "./components/toast/toast-context";
55
58
  export * from "./components/toast/toast-provider";
59
+ export * from "./lib/calendar";
56
60
  export * from "./lib/cn";
57
61
  export * from "./lib/format/date";
58
62
  export * from "./lib/format/distance";
@@ -66,6 +70,7 @@ export * from "./lib/use-toast";
66
70
  export * from "./variants/avatar-variants";
67
71
  export * from "./variants/badge-variants";
68
72
  export * from "./variants/button-variants";
73
+ export * from "./variants/calendar-variants";
69
74
  export * from "./variants/card-variants";
70
75
  export * from "./variants/checkbox-variants";
71
76
  export * from "./variants/chip-variants";
@@ -0,0 +1,204 @@
1
+ export type IsoDate = string;
2
+ export type YearMonth = string;
3
+
4
+ const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
5
+ const YEAR_MONTH_PATTERN = /^\d{4}-\d{2}$/;
6
+ const MS_PER_DAY = 86_400_000;
7
+
8
+ const SUNDAY_FIRST_REGIONS: ReadonlySet<string> = new Set([
9
+ "US",
10
+ "CA",
11
+ "MX",
12
+ "JP",
13
+ "KR",
14
+ "PH",
15
+ "BR",
16
+ "IL",
17
+ "SA",
18
+ "AE",
19
+ "EG",
20
+ "AU",
21
+ ]);
22
+
23
+ export function parseIsoDate(iso: IsoDate): { year: number; month: number; day: number } {
24
+ if (!ISO_DATE_PATTERN.test(iso)) {
25
+ throw new Error(`Invalid ISO date: ${iso}`);
26
+ }
27
+ const parts = iso.split("-");
28
+ return {
29
+ year: Number(parts[0]),
30
+ month: Number(parts[1]),
31
+ day: Number(parts[2]),
32
+ };
33
+ }
34
+
35
+ export function parseYearMonth(yearMonth: YearMonth): { year: number; month: number } {
36
+ if (!YEAR_MONTH_PATTERN.test(yearMonth)) {
37
+ throw new Error(`Invalid year-month: ${yearMonth}`);
38
+ }
39
+ const parts = yearMonth.split("-");
40
+ return {
41
+ year: Number(parts[0]),
42
+ month: Number(parts[1]),
43
+ };
44
+ }
45
+
46
+ export function toIsoDate(year: number, month: number, day: number): IsoDate {
47
+ return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
48
+ }
49
+
50
+ export function toYearMonth(year: number, month: number): YearMonth {
51
+ return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}`;
52
+ }
53
+
54
+ export function yearMonthOf(iso: IsoDate): YearMonth {
55
+ const { year, month } = parseIsoDate(iso);
56
+ return toYearMonth(year, month);
57
+ }
58
+
59
+ export function daysInMonth(year: number, month: number): number {
60
+ return new Date(Date.UTC(year, month, 0)).getUTCDate();
61
+ }
62
+
63
+ export function dayOfWeek(iso: IsoDate): number {
64
+ const { year, month, day } = parseIsoDate(iso);
65
+ return new Date(Date.UTC(year, month - 1, day)).getUTCDay();
66
+ }
67
+
68
+ export function addMonths(yearMonth: YearMonth, delta: number): YearMonth {
69
+ const { year, month } = parseYearMonth(yearMonth);
70
+ const totalMonths = year * 12 + (month - 1) + delta;
71
+ const nextYear = Math.floor(totalMonths / 12);
72
+ const nextMonth = ((totalMonths % 12) + 12) % 12 + 1;
73
+ return toYearMonth(nextYear, nextMonth);
74
+ }
75
+
76
+ export function daysBetween(startIso: IsoDate, endIso: IsoDate): number {
77
+ const start = parseIsoDate(startIso);
78
+ const end = parseIsoDate(endIso);
79
+ const startUtc = Date.UTC(start.year, start.month - 1, start.day);
80
+ const endUtc = Date.UTC(end.year, end.month - 1, end.day);
81
+ return Math.round((endUtc - startUtc) / MS_PER_DAY);
82
+ }
83
+
84
+ export function firstDayOfWeek(locale: string): number {
85
+ try {
86
+ const loc = new Intl.Locale(locale);
87
+ const info =
88
+ (loc as unknown as { getWeekInfo?: () => { firstDay?: number } }).getWeekInfo?.() ??
89
+ (loc as unknown as { weekInfo?: { firstDay?: number } }).weekInfo;
90
+ if (info && typeof info.firstDay === "number") {
91
+ return info.firstDay === 7 ? 0 : info.firstDay;
92
+ }
93
+ const region = loc.maximize().region;
94
+ return region !== undefined && SUNDAY_FIRST_REGIONS.has(region) ? 0 : 1;
95
+ } catch {
96
+ return 1;
97
+ }
98
+ }
99
+
100
+ export interface MonthGridCell {
101
+ iso: IsoDate;
102
+ dayNumber: number;
103
+ isCurrentMonth: boolean;
104
+ }
105
+
106
+ export function buildMonthGrid(yearMonth: YearMonth, firstDow: number): MonthGridCell[][] {
107
+ const { year, month } = parseYearMonth(yearMonth);
108
+ const firstOfMonthDow = dayOfWeek(toIsoDate(year, month, 1));
109
+ const leading = (firstOfMonthDow - firstDow + 7) % 7;
110
+ const inThisMonth = daysInMonth(year, month);
111
+ const totalCells = Math.ceil((leading + inThisMonth) / 7) * 7;
112
+
113
+ const previousYearMonth = addMonths(yearMonth, -1);
114
+ const { year: prevY, month: prevM } = parseYearMonth(previousYearMonth);
115
+ const previousMonthDays = daysInMonth(prevY, prevM);
116
+ const nextYearMonth = addMonths(yearMonth, 1);
117
+ const { year: nextY, month: nextM } = parseYearMonth(nextYearMonth);
118
+
119
+ const cells: MonthGridCell[] = [];
120
+ for (let cellIndex = 0; cellIndex < totalCells; cellIndex++) {
121
+ const offset = cellIndex - leading;
122
+ if (offset < 0) {
123
+ const day = previousMonthDays + offset + 1;
124
+ cells.push({ iso: toIsoDate(prevY, prevM, day), dayNumber: day, isCurrentMonth: false });
125
+ } else if (offset < inThisMonth) {
126
+ const day = offset + 1;
127
+ cells.push({ iso: toIsoDate(year, month, day), dayNumber: day, isCurrentMonth: true });
128
+ } else {
129
+ const day = offset - inThisMonth + 1;
130
+ cells.push({ iso: toIsoDate(nextY, nextM, day), dayNumber: day, isCurrentMonth: false });
131
+ }
132
+ }
133
+
134
+ const weeks: MonthGridCell[][] = [];
135
+ for (let start = 0; start < cells.length; start += 7) {
136
+ weeks.push(cells.slice(start, start + 7));
137
+ }
138
+ return weeks;
139
+ }
140
+
141
+ export function isDateInRange(iso: IsoDate, minDate?: IsoDate, maxDate?: IsoDate): boolean {
142
+ if (minDate !== undefined && iso < minDate) return false;
143
+ if (maxDate !== undefined && iso > maxDate) return false;
144
+ return true;
145
+ }
146
+
147
+ export function formatMonthLabel(yearMonth: YearMonth, locale: string): string {
148
+ const { year, month } = parseYearMonth(yearMonth);
149
+ return new Intl.DateTimeFormat(locale, {
150
+ year: "numeric",
151
+ month: "long",
152
+ timeZone: "UTC",
153
+ }).format(new Date(Date.UTC(year, month - 1, 1, 12, 0, 0)));
154
+ }
155
+
156
+ const SUNDAY_ANCHOR_UTC = Date.UTC(2024, 0, 7);
157
+
158
+ export function formatWeekdayShort(dayOfWeekIndex: number, locale: string): string {
159
+ const date = new Date(SUNDAY_ANCHOR_UTC + dayOfWeekIndex * MS_PER_DAY);
160
+ return new Intl.DateTimeFormat(locale, { weekday: "short", timeZone: "UTC" }).format(date);
161
+ }
162
+
163
+ export function formatFullDate(iso: IsoDate, locale: string): string {
164
+ const { year, month, day } = parseIsoDate(iso);
165
+ return new Intl.DateTimeFormat(locale, { dateStyle: "full", timeZone: "UTC" }).format(
166
+ new Date(Date.UTC(year, month - 1, day, 12, 0, 0)),
167
+ );
168
+ }
169
+
170
+ export function isLocaleHour12(locale: string): boolean {
171
+ const parts = new Intl.DateTimeFormat(locale, {
172
+ hour: "numeric",
173
+ minute: "numeric",
174
+ timeZone: "UTC",
175
+ }).formatToParts(new Date(Date.UTC(2024, 0, 1, 13, 0, 0)));
176
+ return parts.some((part) => part.type === "dayPeriod");
177
+ }
178
+
179
+ export function formatHourLabel(hour24: number, hour12Mode: boolean, locale: string): string {
180
+ const date = new Date(Date.UTC(2024, 0, 1, hour24, 0, 0));
181
+ return new Intl.DateTimeFormat(locale, {
182
+ hour: "numeric",
183
+ hour12: hour12Mode,
184
+ timeZone: "UTC",
185
+ }).format(date);
186
+ }
187
+
188
+ export function padTwoDigits(value: number): string {
189
+ return String(value).padStart(2, "0");
190
+ }
191
+
192
+ export function formatTimeValue(hour24: number, minute: number): string {
193
+ return `${padTwoDigits(hour24)}:${padTwoDigits(minute)}`;
194
+ }
195
+
196
+ export function parseTimeValue(value: string): { hour: number; minute: number } | undefined {
197
+ const match = /^(\d{2}):(\d{2})$/.exec(value);
198
+ if (match === null) return undefined;
199
+ const hour = Number(match[1]);
200
+ const minute = Number(match[2]);
201
+ if (Number.isNaN(hour) || Number.isNaN(minute)) return undefined;
202
+ if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return undefined;
203
+ return { hour, minute };
204
+ }
@@ -0,0 +1,26 @@
1
+ export const calendarContainerClassName = "w-full px-md";
2
+ export const calendarHeaderClassName = "flex-row items-center justify-between py-md";
3
+ export const calendarMonthLabelClassName = "text-base font-medium text-foreground";
4
+ export const calendarNavButtonClassName = "px-md py-sm";
5
+ export const calendarWeekdayRowClassName = "flex-row";
6
+ export const calendarWeekdayCellClassName = "flex-1 items-center py-xs";
7
+ export const calendarWeekdayLabelClassName = "text-xs uppercase text-muted-foreground";
8
+ export const calendarWeekRowClassName = "flex-row";
9
+ export const calendarDayCellClassName =
10
+ "flex-1 aspect-square items-center justify-center rounded-full";
11
+ export const calendarDayCellSelectedClassName = "bg-primary";
12
+ export const calendarDayCellInRangeClassName = "rounded-none bg-primary/20";
13
+ export const calendarDayCellRangeStartClassName = "rounded-l-full rounded-r-none bg-primary";
14
+ export const calendarDayCellRangeEndClassName = "rounded-l-none rounded-r-full bg-primary";
15
+ export const calendarDayCellDisabledClassName = "opacity-40";
16
+ export const calendarDayLabelClassName = "text-sm text-foreground";
17
+ export const calendarDayLabelOutsideMonthClassName = "text-muted-foreground/50";
18
+ export const calendarDayLabelSelectedClassName = "text-primary-foreground";
19
+ export const calendarMarkerDotClassName = "absolute bottom-1 h-1 w-1 rounded-full bg-primary";
20
+ export const dateRangePickerContainerClassName = "w-full";
21
+ export const dateRangePickerSummaryClassName = "px-md pb-md text-sm text-foreground";
22
+ export const timePickerBodyClassName = "flex-row py-md";
23
+ export const timePickerColumnClassName = "flex-1";
24
+ export const timePickerOptionClassName = "items-center py-sm";
25
+ export const timePickerOptionSelectedClassName = "bg-primary/10";
26
+ export const timePickerOptionLabelClassName = "text-base text-foreground";