@mlola-ui/registry 1.0.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 (177) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +38 -0
  3. package/components/accordion.json +54 -0
  4. package/components/activity-heatmap.json +54 -0
  5. package/components/alert.json +56 -0
  6. package/components/app-shell.json +53 -0
  7. package/components/avatar.json +54 -0
  8. package/components/badge.json +62 -0
  9. package/components/breadcrumb.json +49 -0
  10. package/components/button.json +62 -0
  11. package/components/calendar.json +55 -0
  12. package/components/card.json +53 -0
  13. package/components/carousel.json +52 -0
  14. package/components/checkbox.json +50 -0
  15. package/components/color-picker.json +60 -0
  16. package/components/combobox.json +64 -0
  17. package/components/command-menu.json +64 -0
  18. package/components/context-menu.json +61 -0
  19. package/components/date-picker.json +59 -0
  20. package/components/dropdown-menu.json +51 -0
  21. package/components/dropzone.json +45 -0
  22. package/components/empty-state.json +45 -0
  23. package/components/hover-card.json +59 -0
  24. package/components/input.json +56 -0
  25. package/components/kbd.json +45 -0
  26. package/components/modal.json +63 -0
  27. package/components/number-input.json +58 -0
  28. package/components/otp-input.json +49 -0
  29. package/components/pagination.json +50 -0
  30. package/components/popover.json +58 -0
  31. package/components/priority-icon.json +53 -0
  32. package/components/progress.json +57 -0
  33. package/components/radio-group.json +48 -0
  34. package/components/rating.json +48 -0
  35. package/components/resizable.json +46 -0
  36. package/components/segmented-control.json +51 -0
  37. package/components/select.json +67 -0
  38. package/components/sheet.json +66 -0
  39. package/components/skeleton.json +48 -0
  40. package/components/slider.json +53 -0
  41. package/components/status-icon.json +53 -0
  42. package/components/stepper.json +50 -0
  43. package/components/table.json +53 -0
  44. package/components/tabs.json +54 -0
  45. package/components/tag-input.json +53 -0
  46. package/components/textarea.json +48 -0
  47. package/components/time-picker.json +58 -0
  48. package/components/timeline.json +54 -0
  49. package/components/toast.json +56 -0
  50. package/components/toggle.json +52 -0
  51. package/components/tooltip.json +54 -0
  52. package/components/tour.json +62 -0
  53. package/components/virtual-list.json +54 -0
  54. package/generated/catalog.ts +2037 -0
  55. package/index.json +413 -0
  56. package/package.json +47 -0
  57. package/schema/mlola.config.schema.json +51 -0
  58. package/schema/registry-index.schema.json +116 -0
  59. package/schema/registry-item.schema.json +80 -0
  60. package/snapshot/index.json +2938 -0
  61. package/snapshot/items/components/accordion.json +54 -0
  62. package/snapshot/items/components/activity-heatmap.json +54 -0
  63. package/snapshot/items/components/alert.json +56 -0
  64. package/snapshot/items/components/app-shell.json +53 -0
  65. package/snapshot/items/components/avatar.json +54 -0
  66. package/snapshot/items/components/badge.json +62 -0
  67. package/snapshot/items/components/breadcrumb.json +49 -0
  68. package/snapshot/items/components/button.json +62 -0
  69. package/snapshot/items/components/calendar.json +55 -0
  70. package/snapshot/items/components/card.json +53 -0
  71. package/snapshot/items/components/carousel.json +52 -0
  72. package/snapshot/items/components/checkbox.json +50 -0
  73. package/snapshot/items/components/color-picker.json +60 -0
  74. package/snapshot/items/components/combobox.json +64 -0
  75. package/snapshot/items/components/command-menu.json +64 -0
  76. package/snapshot/items/components/context-menu.json +61 -0
  77. package/snapshot/items/components/date-picker.json +59 -0
  78. package/snapshot/items/components/dropdown-menu.json +51 -0
  79. package/snapshot/items/components/dropzone.json +45 -0
  80. package/snapshot/items/components/empty-state.json +45 -0
  81. package/snapshot/items/components/hover-card.json +59 -0
  82. package/snapshot/items/components/input.json +56 -0
  83. package/snapshot/items/components/kbd.json +45 -0
  84. package/snapshot/items/components/modal.json +63 -0
  85. package/snapshot/items/components/number-input.json +58 -0
  86. package/snapshot/items/components/otp-input.json +49 -0
  87. package/snapshot/items/components/pagination.json +50 -0
  88. package/snapshot/items/components/popover.json +58 -0
  89. package/snapshot/items/components/priority-icon.json +53 -0
  90. package/snapshot/items/components/progress.json +57 -0
  91. package/snapshot/items/components/radio-group.json +48 -0
  92. package/snapshot/items/components/rating.json +48 -0
  93. package/snapshot/items/components/resizable.json +46 -0
  94. package/snapshot/items/components/segmented-control.json +51 -0
  95. package/snapshot/items/components/select.json +67 -0
  96. package/snapshot/items/components/sheet.json +66 -0
  97. package/snapshot/items/components/skeleton.json +48 -0
  98. package/snapshot/items/components/slider.json +53 -0
  99. package/snapshot/items/components/status-icon.json +53 -0
  100. package/snapshot/items/components/stepper.json +50 -0
  101. package/snapshot/items/components/table.json +53 -0
  102. package/snapshot/items/components/tabs.json +54 -0
  103. package/snapshot/items/components/tag-input.json +53 -0
  104. package/snapshot/items/components/textarea.json +48 -0
  105. package/snapshot/items/components/time-picker.json +58 -0
  106. package/snapshot/items/components/timeline.json +54 -0
  107. package/snapshot/items/components/toast.json +56 -0
  108. package/snapshot/items/components/toggle.json +52 -0
  109. package/snapshot/items/components/tooltip.json +54 -0
  110. package/snapshot/items/components/tour.json +62 -0
  111. package/snapshot/items/components/virtual-list.json +54 -0
  112. package/snapshot/source/packages/components/_internal/anchor.ts +54 -0
  113. package/snapshot/source/packages/components/_internal/dialog.ts +142 -0
  114. package/snapshot/source/packages/components/_internal/floating.ts +73 -0
  115. package/snapshot/source/packages/components/_internal/match.ts +39 -0
  116. package/snapshot/source/packages/components/_internal/react.ts +40 -0
  117. package/snapshot/source/packages/components/_internal/scroll.ts +15 -0
  118. package/snapshot/source/packages/components/accordion/accordion.tsx +164 -0
  119. package/snapshot/source/packages/components/activity-heatmap/activity-heatmap.tsx +204 -0
  120. package/snapshot/source/packages/components/activity-heatmap/calendar.ts +92 -0
  121. package/snapshot/source/packages/components/alert/alert.tsx +83 -0
  122. package/snapshot/source/packages/components/app-shell/app-shell.tsx +186 -0
  123. package/snapshot/source/packages/components/avatar/avatar.tsx +56 -0
  124. package/snapshot/source/packages/components/badge/badge.tsx +39 -0
  125. package/snapshot/source/packages/components/breadcrumb/breadcrumb.tsx +61 -0
  126. package/snapshot/source/packages/components/button/button.tsx +42 -0
  127. package/snapshot/source/packages/components/calendar/calendar.tsx +309 -0
  128. package/snapshot/source/packages/components/calendar/dates.ts +163 -0
  129. package/snapshot/source/packages/components/card/card.tsx +39 -0
  130. package/snapshot/source/packages/components/carousel/carousel.tsx +248 -0
  131. package/snapshot/source/packages/components/checkbox/checkbox.tsx +66 -0
  132. package/snapshot/source/packages/components/color-picker/color-picker.tsx +261 -0
  133. package/snapshot/source/packages/components/color-picker/color.ts +124 -0
  134. package/snapshot/source/packages/components/combobox/combobox.tsx +320 -0
  135. package/snapshot/source/packages/components/command-menu/command-menu.tsx +199 -0
  136. package/snapshot/source/packages/components/context-menu/context-menu.tsx +192 -0
  137. package/snapshot/source/packages/components/date-picker/date-picker.tsx +185 -0
  138. package/snapshot/source/packages/components/dropdown-menu/dropdown-menu.tsx +157 -0
  139. package/snapshot/source/packages/components/dropzone/dropzone.tsx +185 -0
  140. package/snapshot/source/packages/components/empty-state/empty-state.tsx +32 -0
  141. package/snapshot/source/packages/components/hover-card/hover-card.tsx +103 -0
  142. package/snapshot/source/packages/components/input/input.tsx +101 -0
  143. package/snapshot/source/packages/components/kbd/kbd.tsx +54 -0
  144. package/snapshot/source/packages/components/modal/modal.tsx +100 -0
  145. package/snapshot/source/packages/components/number-input/number-input.tsx +184 -0
  146. package/snapshot/source/packages/components/number-input/number.ts +50 -0
  147. package/snapshot/source/packages/components/otp-input/otp-input.tsx +116 -0
  148. package/snapshot/source/packages/components/pagination/pagination.tsx +65 -0
  149. package/snapshot/source/packages/components/popover/popover.tsx +107 -0
  150. package/snapshot/source/packages/components/priority-icon/labels.ts +13 -0
  151. package/snapshot/source/packages/components/priority-icon/priority-icon.tsx +60 -0
  152. package/snapshot/source/packages/components/progress/progress.tsx +110 -0
  153. package/snapshot/source/packages/components/radio-group/radio-group.tsx +106 -0
  154. package/snapshot/source/packages/components/rating/rating.tsx +94 -0
  155. package/snapshot/source/packages/components/resizable/resizable.tsx +171 -0
  156. package/snapshot/source/packages/components/segmented-control/segmented-control.tsx +78 -0
  157. package/snapshot/source/packages/components/select/select.tsx +332 -0
  158. package/snapshot/source/packages/components/sheet/sheet.tsx +109 -0
  159. package/snapshot/source/packages/components/skeleton/skeleton.tsx +84 -0
  160. package/snapshot/source/packages/components/slider/slider.tsx +99 -0
  161. package/snapshot/source/packages/components/status-icon/labels.ts +14 -0
  162. package/snapshot/source/packages/components/status-icon/status-icon.tsx +76 -0
  163. package/snapshot/source/packages/components/stepper/stepper.tsx +87 -0
  164. package/snapshot/source/packages/components/table/table.tsx +66 -0
  165. package/snapshot/source/packages/components/tabs/tabs.tsx +163 -0
  166. package/snapshot/source/packages/components/tag-input/tag-input.tsx +273 -0
  167. package/snapshot/source/packages/components/textarea/textarea.tsx +70 -0
  168. package/snapshot/source/packages/components/time-picker/time-picker.tsx +286 -0
  169. package/snapshot/source/packages/components/time-picker/times.ts +76 -0
  170. package/snapshot/source/packages/components/timeline/timeline.tsx +115 -0
  171. package/snapshot/source/packages/components/toast/toast.tsx +317 -0
  172. package/snapshot/source/packages/components/toggle/toggle.tsx +80 -0
  173. package/snapshot/source/packages/components/tooltip/tooltip.tsx +70 -0
  174. package/snapshot/source/packages/components/tour/tour.tsx +193 -0
  175. package/snapshot/source/packages/components/virtual-list/virtual-list.tsx +121 -0
  176. package/snapshot/source/packages/components/virtual-list/virtual.ts +33 -0
  177. package/snapshot/source-manifest.json +67 -0
@@ -0,0 +1,309 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { IconChevronLeft, IconChevronRight } from "@mlola-ui/icons";
5
+ import { cx } from "../_internal/react";
6
+ import {
7
+ addDays,
8
+ addMonths,
9
+ clampDate,
10
+ daysBetween,
11
+ endOfWeek,
12
+ isBetween,
13
+ isDateBlocked,
14
+ monthGrid,
15
+ rangeReach,
16
+ sameMonth,
17
+ startOfMonth,
18
+ startOfWeek,
19
+ today as todayOf,
20
+ toTime,
21
+ type DateMatchers,
22
+ type ISODate,
23
+ } from "./dates";
24
+
25
+ export type { DateMatcher, DateMatchers, ISODate, RangeProblem, RangeRules } from "./dates";
26
+ export { addDays, addMonths, isDateBlocked, isValidISODate, matchesDate, rangeProblem, today } from "./dates";
27
+
28
+ export interface DateRange {
29
+ from: ISODate;
30
+ to?: ISODate;
31
+ }
32
+
33
+ interface BaseProps {
34
+ /** The month shown first: any date inside it. */
35
+ month?: ISODate;
36
+ defaultMonth?: ISODate;
37
+ onMonthChange?: (month: ISODate) => void;
38
+ /** One month, or two side by side for ranges. */
39
+ months?: 1 | 2;
40
+ min?: ISODate;
41
+ max?: ISODate;
42
+ /**
43
+ * Dates that cannot be picked: single dates, `{ from, to }` ranges,
44
+ * `{ before }`, `{ after }`, `{ dayOfWeek: [0, 6] }` or a function.
45
+ */
46
+ disabledDates?: DateMatchers;
47
+ /** When given, only these dates can be picked; same forms as `disabledDates`. */
48
+ enabledDates?: DateMatchers;
49
+ /** A function form of `disabledDates`, kept for convenience. */
50
+ isDisabled?: (date: ISODate) => boolean;
51
+ /** 1 starts weeks on Monday, 0 on Sunday. */
52
+ weekStart?: 0 | 1;
53
+ locale?: string;
54
+ showOutsideDays?: boolean;
55
+ /** Today's date. Pass it when rendering on the server so both renders agree. */
56
+ today?: ISODate;
57
+ label?: string;
58
+ className?: string;
59
+ }
60
+
61
+ interface SingleProps {
62
+ mode?: "single";
63
+ value?: ISODate | null;
64
+ defaultValue?: ISODate | null;
65
+ onValueChange?: (value: ISODate | null) => void;
66
+ }
67
+
68
+ interface RangeProps {
69
+ mode: "range";
70
+ value?: DateRange | null;
71
+ defaultValue?: DateRange | null;
72
+ onValueChange?: (value: DateRange | null) => void;
73
+ /** Shortest range in days, counted inclusively (the 1st to the 3rd is 3 days). */
74
+ minDays?: number;
75
+ /** Longest range in days, counted inclusively. */
76
+ maxDays?: number;
77
+ /** Let a range pass over disabled dates; by default it stops at the first one. */
78
+ allowDisabledInRange?: boolean;
79
+ }
80
+
81
+ interface MultipleProps {
82
+ mode: "multiple";
83
+ value?: ISODate[];
84
+ defaultValue?: ISODate[];
85
+ onValueChange?: (value: ISODate[]) => void;
86
+ }
87
+
88
+ export type CalendarProps = BaseProps & (SingleProps | RangeProps | MultipleProps);
89
+
90
+ type Selection = ISODate | null | DateRange | ISODate[];
91
+
92
+ /** Where the calendar opens: the selection, else today, clamped to the allowed range. */
93
+ function anchorOf(selection: Selection, fallback: ISODate) {
94
+ if (typeof selection === "string") return selection;
95
+ if (Array.isArray(selection)) return selection[0] ?? fallback;
96
+ return selection?.from ?? fallback;
97
+ }
98
+
99
+ /**
100
+ * A month grid for picking one date, a range, or several. Arrow keys move by
101
+ * day and week, Home and End to the week's edges, Page Up and Page Down by
102
+ * month (with Shift, by year). Ranges preview as the pointer moves.
103
+ */
104
+ export function Calendar(props: CalendarProps) {
105
+ const {
106
+ month,
107
+ defaultMonth,
108
+ onMonthChange,
109
+ months = 1,
110
+ min,
111
+ max,
112
+ disabledDates,
113
+ enabledDates,
114
+ isDisabled,
115
+ weekStart = 1,
116
+ locale = "en",
117
+ showOutsideDays = true,
118
+ today: todayProp,
119
+ label = "Calendar",
120
+ className,
121
+ } = props;
122
+ const mode = props.mode ?? "single";
123
+ const today = todayProp ?? todayOf();
124
+
125
+ const [innerValue, setInnerValue] = React.useState<Selection>((props.defaultValue as Selection) ?? (mode === "multiple" ? [] : null));
126
+ const selection: Selection = props.value !== undefined ? (props.value as Selection) : innerValue;
127
+ const commit = (next: Selection) => {
128
+ if (props.value === undefined) setInnerValue(next);
129
+ (props.onValueChange as ((value: Selection) => void) | undefined)?.(next);
130
+ };
131
+
132
+ const [innerMonth, setInnerMonth] = React.useState(() => startOfMonth(defaultMonth ?? clampDate(anchorOf(selection, today), min, max)));
133
+ const shownMonth = month ? startOfMonth(month) : innerMonth;
134
+ const setMonth = (next: ISODate) => {
135
+ const first = startOfMonth(next);
136
+ if (!month) setInnerMonth(first);
137
+ onMonthChange?.(first);
138
+ };
139
+
140
+ const [focused, setFocused] = React.useState<ISODate>(() => clampDate(anchorOf(selection, today), min, max));
141
+ const [hovered, setHovered] = React.useState<ISODate | null>(null);
142
+ const moved = React.useRef(false);
143
+ const buttons = React.useRef(new Map<ISODate, HTMLButtonElement>());
144
+
145
+ // Keep the focused day inside what is shown.
146
+ const visible = Array.from({ length: months }, (_, index) => addMonths(shownMonth, index));
147
+ const inView = (date: ISODate) => visible.some((first) => sameMonth(first, date));
148
+ const focusable = inView(focused) ? focused : shownMonth;
149
+
150
+ React.useEffect(() => {
151
+ if (!moved.current) return;
152
+ moved.current = false;
153
+ buttons.current.get(focused)?.focus();
154
+ }, [focused, shownMonth]);
155
+
156
+ const blocked = (date: ISODate) => isDateBlocked(date, { min, max, disabled: disabledDates, enabled: enabledDates }) || Boolean(isDisabled?.(date));
157
+
158
+ // While a range waits for its end, dates it cannot reach (past a disabled day,
159
+ // too short or too long) are marked; picking one starts a new range there.
160
+ const rules = props.mode === "range" ? { minDays: props.minDays, maxDays: props.maxDays, allowBlockedInside: props.allowDisabledInRange } : {};
161
+ const pending = mode === "range" && selection && !Array.isArray(selection) && typeof selection !== "string" && !selection.to ? selection.from : null;
162
+ const reach = pending ? rangeReach(pending, blocked, rules) : null;
163
+ const reachable = (date: ISODate) => {
164
+ if (!pending || !reach) return true;
165
+ if (blocked(date) || date < reach.low || date > reach.high) return false;
166
+ // maxDays is already inside the reach; only the shortest length is left to check.
167
+ return Math.abs(daysBetween(pending, date)) + 1 >= (rules.minDays ?? 1);
168
+ };
169
+
170
+ const select = (date: ISODate) => {
171
+ if (blocked(date)) return;
172
+ if (mode === "single") commit(date === selection ? null : date);
173
+ else if (mode === "multiple") {
174
+ const list = Array.isArray(selection) ? selection : [];
175
+ commit(list.includes(date) ? list.filter((item) => item !== date) : [...list, date].sort());
176
+ } else {
177
+ const range = selection as DateRange | null;
178
+ if (!range || range.to || !reachable(date)) commit({ from: date });
179
+ else if (date < range.from) commit({ from: date, to: range.from });
180
+ else commit({ from: range.from, to: date });
181
+ }
182
+ };
183
+
184
+ const moveTo = (date: ISODate) => {
185
+ const next = clampDate(date, min, max);
186
+ moved.current = true;
187
+ setFocused(next);
188
+ if (!inView(next)) setMonth(next < shownMonth ? next : addMonths(next, -(months - 1)));
189
+ };
190
+
191
+ const onKeyDown = (event: React.KeyboardEvent) => {
192
+ const year = event.shiftKey ? 12 : 1;
193
+ const moves: Record<string, () => ISODate> = {
194
+ ArrowLeft: () => addDays(focusable, -1),
195
+ ArrowRight: () => addDays(focusable, 1),
196
+ ArrowUp: () => addDays(focusable, -7),
197
+ ArrowDown: () => addDays(focusable, 7),
198
+ Home: () => startOfWeek(focusable, weekStart),
199
+ End: () => endOfWeek(focusable, weekStart),
200
+ PageUp: () => addMonths(focusable, -year),
201
+ PageDown: () => addMonths(focusable, year),
202
+ };
203
+ if (moves[event.key]) {
204
+ event.preventDefault();
205
+ moveTo(moves[event.key]());
206
+ } else if (event.key === "Enter" || event.key === " ") {
207
+ event.preventDefault();
208
+ select(focusable);
209
+ }
210
+ };
211
+
212
+ const range = mode === "range" ? (selection as DateRange | null) : null;
213
+ const rangeEnd = range ? range.to ?? (hovered && reachable(hovered) ? hovered : undefined) : undefined;
214
+ const [rangeLow, rangeHigh] = range && rangeEnd ? (range.from <= rangeEnd ? [range.from, rangeEnd] : [rangeEnd, range.from]) : [undefined, undefined];
215
+ const isSelected = (date: ISODate) =>
216
+ mode === "single" ? selection === date : mode === "multiple" ? Array.isArray(selection) && selection.includes(date) : Boolean(range && (range.from === date || range.to === date));
217
+
218
+ const monthTitle = new Intl.DateTimeFormat(locale, { month: "long", year: "numeric", timeZone: "UTC" });
219
+ const weekdayShort = new Intl.DateTimeFormat(locale, { weekday: "short", timeZone: "UTC" });
220
+ const weekdayLong = new Intl.DateTimeFormat(locale, { weekday: "long", timeZone: "UTC" });
221
+ const fullDate = new Intl.DateTimeFormat(locale, { weekday: "long", month: "long", day: "numeric", year: "numeric", timeZone: "UTC" });
222
+ const dayNumber = new Intl.DateTimeFormat(locale, { day: "numeric", timeZone: "UTC" });
223
+ const firstWeek = monthGrid(shownMonth, weekStart)[0];
224
+ const canPrev = !min || startOfMonth(min) < shownMonth;
225
+ const canNext = !max || startOfMonth(max) > visible[visible.length - 1];
226
+
227
+ return (
228
+ <div role="group" aria-label={label} className={cx("ml-calendar", className)} data-months={months}>
229
+ <div className="ml-calendar-months" onKeyDown={onKeyDown} onPointerLeave={() => setHovered(null)}>
230
+ {visible.map((first, index) => (
231
+ <div key={first} className="ml-calendar-month">
232
+ <div className="ml-calendar-caption">
233
+ {index === 0 ? (
234
+ <button type="button" className="ml-calendar-nav" aria-label="Previous month" disabled={!canPrev} onClick={() => setMonth(addMonths(shownMonth, -1))}>
235
+ <IconChevronLeft aria-hidden="true" size="1em" />
236
+ </button>
237
+ ) : (
238
+ <span aria-hidden="true" />
239
+ )}
240
+ <p className="ml-calendar-title" aria-live="polite">
241
+ {monthTitle.format(new Date(toTime(first)))}
242
+ </p>
243
+ {index === visible.length - 1 ? (
244
+ <button type="button" className="ml-calendar-nav" aria-label="Next month" disabled={!canNext} onClick={() => setMonth(addMonths(shownMonth, 1))}>
245
+ <IconChevronRight aria-hidden="true" size="1em" />
246
+ </button>
247
+ ) : (
248
+ <span aria-hidden="true" />
249
+ )}
250
+ </div>
251
+ <div role="grid" aria-label={monthTitle.format(new Date(toTime(first)))} className="ml-calendar-grid">
252
+ <div role="row" className="ml-calendar-row">
253
+ {firstWeek.map(({ date }) => (
254
+ <span key={date} role="columnheader" className="ml-calendar-weekday" aria-label={weekdayLong.format(new Date(toTime(date)))}>
255
+ {weekdayShort.format(new Date(toTime(date))).slice(0, 2)}
256
+ </span>
257
+ ))}
258
+ </div>
259
+ {monthGrid(first, weekStart).map((week) => (
260
+ <div key={week[0].date} role="row" className="ml-calendar-row">
261
+ {week.map(({ date, inMonth }) => {
262
+ if (!inMonth && (!showOutsideDays || months > 1)) return <span key={date} role="gridcell" className="ml-calendar-cell" aria-hidden="true" />;
263
+ const selected = isSelected(date);
264
+ const inRange = Boolean(range && rangeEnd && isBetween(date, range.from, rangeEnd));
265
+ return (
266
+ <span
267
+ key={date}
268
+ role="gridcell"
269
+ aria-selected={selected || inRange || undefined}
270
+ className="ml-calendar-cell"
271
+ data-range={inRange ? (date === rangeLow ? "start" : date === rangeHigh ? "end" : "middle") : undefined}
272
+ data-preview={inRange && !range?.to ? "" : undefined}
273
+ >
274
+ <button
275
+ ref={(element) => {
276
+ if (element) buttons.current.set(date, element);
277
+ else buttons.current.delete(date);
278
+ }}
279
+ type="button"
280
+ className="ml-calendar-day"
281
+ tabIndex={date === focusable && inMonth ? 0 : -1}
282
+ aria-label={fullDate.format(new Date(toTime(date)))}
283
+ aria-current={date === today ? "date" : undefined}
284
+ aria-disabled={blocked(date) || undefined}
285
+ data-selected={selected || undefined}
286
+ data-unreachable={(!blocked(date) && !reachable(date)) || undefined}
287
+ data-outside={!inMonth || undefined}
288
+ data-today={date === today || undefined}
289
+ onClick={() => {
290
+ setFocused(date);
291
+ select(date);
292
+ }}
293
+ onPointerEnter={() => mode === "range" && setHovered(date)}
294
+ onFocus={() => setFocused(date)}
295
+ >
296
+ {dayNumber.format(new Date(toTime(date)))}
297
+ </button>
298
+ </span>
299
+ );
300
+ })}
301
+ </div>
302
+ ))}
303
+ </div>
304
+ </div>
305
+ ))}
306
+ </div>
307
+ </div>
308
+ );
309
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Calendar dates as ISO strings (YYYY-MM-DD) in UTC arithmetic: no time of
3
+ * day, no time zone drift, so "the 1st" is the 1st wherever the reader is.
4
+ */
5
+ export type ISODate = string;
6
+
7
+ const DAY = 86_400_000;
8
+ const pad = (value: number) => String(value).padStart(2, "0");
9
+
10
+ export const toTime = (date: ISODate) => Date.parse(`${date}T00:00:00Z`);
11
+ export const fromTime = (time: number): ISODate => new Date(time).toISOString().slice(0, 10);
12
+
13
+ /** Today in the reader's own calendar, not UTC's. */
14
+ export function today(now = new Date()): ISODate {
15
+ return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
16
+ }
17
+
18
+ export const addDays = (date: ISODate, days: number) => fromTime(toTime(date) + days * DAY);
19
+
20
+ /** Months later or earlier, keeping the day where the month allows (31 Jan + 1 month = 28/29 Feb). */
21
+ export function addMonths(date: ISODate, months: number): ISODate {
22
+ const [year, month, day] = date.split("-").map(Number);
23
+ const index = year * 12 + (month - 1) + months;
24
+ const nextYear = Math.floor(index / 12);
25
+ const nextMonth = index - nextYear * 12;
26
+ const last = new Date(Date.UTC(nextYear, nextMonth + 1, 0)).getUTCDate();
27
+ return `${nextYear}-${pad(nextMonth + 1)}-${pad(Math.min(day, last))}`;
28
+ }
29
+
30
+ export const startOfMonth = (date: ISODate): ISODate => `${date.slice(0, 7)}-01`;
31
+ export const sameMonth = (a: ISODate, b: ISODate) => a.slice(0, 7) === b.slice(0, 7);
32
+ export const weekday = (date: ISODate) => new Date(toTime(date)).getUTCDay();
33
+
34
+ export function startOfWeek(date: ISODate, weekStart: 0 | 1 = 1): ISODate {
35
+ return addDays(date, -((weekday(date) - weekStart + 7) % 7));
36
+ }
37
+
38
+ export const endOfWeek = (date: ISODate, weekStart: 0 | 1 = 1) => addDays(startOfWeek(date, weekStart), 6);
39
+
40
+ export const compare = (a: ISODate, b: ISODate) => (a < b ? -1 : a > b ? 1 : 0);
41
+
42
+ /** Inclusive, in either order. */
43
+ export function isBetween(date: ISODate, a: ISODate, b: ISODate) {
44
+ const [low, high] = a <= b ? [a, b] : [b, a];
45
+ return date >= low && date <= high;
46
+ }
47
+
48
+ export const daysBetween = (a: ISODate, b: ISODate) => Math.round((toTime(b) - toTime(a)) / DAY);
49
+
50
+ /**
51
+ * Six weeks for the month of `date`, so the grid never changes height as the
52
+ * reader pages through months.
53
+ */
54
+ export function monthGrid(date: ISODate, weekStart: 0 | 1 = 1): Array<Array<{ date: ISODate; inMonth: boolean }>> {
55
+ const first = startOfMonth(date);
56
+ const start = startOfWeek(first, weekStart);
57
+ return Array.from({ length: 6 }, (_, week) =>
58
+ Array.from({ length: 7 }, (_, day) => {
59
+ const cell = addDays(start, week * 7 + day);
60
+ return { date: cell, inMonth: sameMonth(cell, first) };
61
+ }),
62
+ );
63
+ }
64
+
65
+ /** Clamp a date into [min, max] where given. */
66
+ export function clampDate(date: ISODate, min?: ISODate, max?: ISODate): ISODate {
67
+ if (min && date < min) return min;
68
+ if (max && date > max) return max;
69
+ return date;
70
+ }
71
+
72
+ export function isValidISODate(value: string): value is ISODate {
73
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
74
+ return fromTime(toTime(value)) === value;
75
+ }
76
+
77
+ /**
78
+ * Which dates a rule covers: one date, an inclusive range, everything before
79
+ * or after a date, days of the week (0 is Sunday), or a function.
80
+ */
81
+ export type DateMatcher =
82
+ | ISODate
83
+ | { from: ISODate; to: ISODate }
84
+ | { before: ISODate }
85
+ | { after: ISODate }
86
+ | { dayOfWeek: number[] }
87
+ | ((date: ISODate) => boolean);
88
+ export type DateMatchers = DateMatcher | DateMatcher[];
89
+
90
+ export function matchesDate(date: ISODate, matchers?: DateMatchers): boolean {
91
+ if (matchers === undefined) return false;
92
+ const list = Array.isArray(matchers) ? matchers : [matchers];
93
+ return list.some((matcher) => {
94
+ if (typeof matcher === "function") return matcher(date);
95
+ if (typeof matcher === "string") return matcher === date;
96
+ if ("from" in matcher) return isBetween(date, matcher.from, matcher.to);
97
+ if ("before" in matcher) return date < matcher.before;
98
+ if ("after" in matcher) return date > matcher.after;
99
+ return matcher.dayOfWeek.includes(weekday(date));
100
+ });
101
+ }
102
+
103
+ export interface DateRules {
104
+ min?: ISODate;
105
+ max?: ISODate;
106
+ /** Dates that cannot be picked. */
107
+ disabled?: DateMatchers;
108
+ /** When given, only these dates can be picked (min, max and disabled still apply). */
109
+ enabled?: DateMatchers;
110
+ }
111
+
112
+ export function isDateBlocked(date: ISODate, rules: DateRules) {
113
+ return Boolean(
114
+ (rules.min && date < rules.min) ||
115
+ (rules.max && date > rules.max) ||
116
+ matchesDate(date, rules.disabled) ||
117
+ (rules.enabled !== undefined && !matchesDate(date, rules.enabled)),
118
+ );
119
+ }
120
+
121
+ export interface RangeRules {
122
+ /** Shortest range, in days counted inclusively (the 1st to the 3rd is 3 days). */
123
+ minDays?: number;
124
+ /** Longest range, in days counted inclusively. */
125
+ maxDays?: number;
126
+ /** Let a range pass over blocked dates; its two ends must still be free. */
127
+ allowBlockedInside?: boolean;
128
+ }
129
+
130
+ export type RangeProblem = "blocked" | "too-short" | "too-long";
131
+
132
+ /** Why a range cannot be chosen, or null when it can. */
133
+ export function rangeProblem(from: ISODate, to: ISODate, blocked: (date: ISODate) => boolean, rules: RangeRules = {}): RangeProblem | null {
134
+ const [low, high] = from <= to ? [from, to] : [to, from];
135
+ if (blocked(low) || blocked(high)) return "blocked";
136
+ const days = daysBetween(low, high) + 1;
137
+ if (rules.minDays && days < rules.minDays) return "too-short";
138
+ if (rules.maxDays && days > rules.maxDays) return "too-long";
139
+ if (!rules.allowBlockedInside) for (let date = addDays(low, 1); date < high; date = addDays(date, 1)) if (blocked(date)) return "blocked";
140
+ return null;
141
+ }
142
+
143
+ /**
144
+ * How far a range starting at `from` can reach each way: up to the first
145
+ * blocked date (unless ranges may pass over them) and never past maxDays.
146
+ * Scans at most `limit` days in each direction.
147
+ */
148
+ export function rangeReach(from: ISODate, blocked: (date: ISODate) => boolean, rules: RangeRules = {}, limit = 731) {
149
+ const span = rules.maxDays ? rules.maxDays - 1 : limit;
150
+ let low = from;
151
+ let high = from;
152
+ for (let step = 1; step <= span; step++) {
153
+ const date = addDays(from, -step);
154
+ if (!rules.allowBlockedInside && blocked(date)) break;
155
+ low = date;
156
+ }
157
+ for (let step = 1; step <= span; step++) {
158
+ const date = addDays(from, step);
159
+ if (!rules.allowBlockedInside && blocked(date)) break;
160
+ high = date;
161
+ }
162
+ return { low, high };
163
+ }
@@ -0,0 +1,39 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { cx } from "../_internal/react";
5
+
6
+ export type CardVariant = "default" | "elevated" | "glass" | "specular";
7
+ type CardProps = React.HTMLAttributes<HTMLDivElement> & {
8
+ variant?: CardVariant;
9
+ interactive?: boolean;
10
+ };
11
+
12
+ const Card = React.forwardRef<HTMLDivElement, CardProps>(
13
+ ({ variant = "default", interactive = false, className, ...props }, ref) => (
14
+ <div ref={ref} data-variant={variant} data-interactive={interactive ? "" : undefined} className={cx("ml-card", className)} {...props} />
15
+ )
16
+ );
17
+ Card.displayName = "Card";
18
+ const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
19
+ ({ className, ...props }, ref) => <div ref={ref} className={cx("ml-card-header", className)} {...props} />
20
+ );
21
+ CardHeader.displayName = "CardHeader";
22
+ const CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
23
+ ({ className, ...props }, ref) => <h3 ref={ref} className={cx("ml-card-title", className)} {...props} />
24
+ );
25
+ CardTitle.displayName = "CardTitle";
26
+ const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
27
+ ({ className, ...props }, ref) => <p ref={ref} className={cx("ml-card-description", className)} {...props} />
28
+ );
29
+ CardDescription.displayName = "CardDescription";
30
+ const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
31
+ ({ className, ...props }, ref) => <div ref={ref} className={cx("ml-card-content", className)} {...props} />
32
+ );
33
+ CardContent.displayName = "CardContent";
34
+ const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
35
+ ({ className, ...props }, ref) => <div ref={ref} className={cx("ml-card-footer", className)} {...props} />
36
+ );
37
+ CardFooter.displayName = "CardFooter";
38
+
39
+ export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter };