@windrun-huaiin/third-ui 29.0.4 → 29.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.
Files changed (64) hide show
  1. package/dist/fuma/base/custom-header.js +6 -3
  2. package/dist/fuma/base/custom-header.mjs +6 -3
  3. package/dist/main/alert-dialog/confirm-dialog.d.ts +7 -3
  4. package/dist/main/alert-dialog/confirm-dialog.js +11 -6
  5. package/dist/main/alert-dialog/confirm-dialog.mjs +13 -8
  6. package/dist/main/alert-dialog/dialog-loading-action.d.ts +12 -0
  7. package/dist/main/alert-dialog/dialog-loading-action.js +42 -0
  8. package/dist/main/alert-dialog/dialog-loading-action.mjs +40 -0
  9. package/dist/main/alert-dialog/high-priority-confirm-dialog.d.ts +6 -3
  10. package/dist/main/alert-dialog/high-priority-confirm-dialog.js +13 -4
  11. package/dist/main/alert-dialog/high-priority-confirm-dialog.mjs +14 -5
  12. package/dist/main/alert-dialog/index.d.ts +1 -0
  13. package/dist/main/alert-dialog/info-dialog.d.ts +4 -2
  14. package/dist/main/alert-dialog/info-dialog.js +6 -5
  15. package/dist/main/alert-dialog/info-dialog.mjs +7 -6
  16. package/dist/main/alert-dialog/undoable-confirm-dialog.d.ts +8 -4
  17. package/dist/main/alert-dialog/undoable-confirm-dialog.js +23 -16
  18. package/dist/main/alert-dialog/undoable-confirm-dialog.mjs +24 -17
  19. package/dist/main/buttons/gradient-button.d.ts +3 -1
  20. package/dist/main/buttons/gradient-button.js +29 -3
  21. package/dist/main/buttons/gradient-button.mjs +29 -3
  22. package/dist/main/buttons/index.d.ts +1 -0
  23. package/dist/main/buttons/index.js +3 -0
  24. package/dist/main/buttons/index.mjs +1 -0
  25. package/dist/main/buttons/use-press-feedback.d.ts +18 -0
  26. package/dist/main/buttons/use-press-feedback.js +42 -0
  27. package/dist/main/buttons/use-press-feedback.mjs +39 -0
  28. package/dist/main/buttons/x-button.d.ts +3 -0
  29. package/dist/main/buttons/x-button.js +36 -6
  30. package/dist/main/buttons/x-button.mjs +36 -6
  31. package/dist/main/calendar/calendar-date-range-input.d.ts +17 -0
  32. package/dist/main/calendar/calendar-date-range-input.js +81 -0
  33. package/dist/main/calendar/calendar-date-range-input.mjs +79 -0
  34. package/dist/main/calendar/calendar-status-view.d.ts +23 -0
  35. package/dist/main/calendar/calendar-status-view.js +155 -0
  36. package/dist/main/calendar/calendar-status-view.mjs +153 -0
  37. package/dist/main/calendar/index.d.ts +3 -0
  38. package/dist/main/calendar/index.js +12 -0
  39. package/dist/main/calendar/index.mjs +4 -0
  40. package/dist/main/calendar/random-date-range-dialog.d.ts +15 -0
  41. package/dist/main/calendar/random-date-range-dialog.js +447 -0
  42. package/dist/main/calendar/random-date-range-dialog.mjs +445 -0
  43. package/dist/main/features.js +2 -2
  44. package/dist/main/features.mjs +1 -1
  45. package/dist/main/usage.js +2 -2
  46. package/dist/main/usage.mjs +1 -1
  47. package/package.json +9 -4
  48. package/src/fuma/base/custom-header.tsx +6 -3
  49. package/src/main/alert-dialog/confirm-dialog.tsx +59 -46
  50. package/src/main/alert-dialog/dialog-loading-action.tsx +63 -0
  51. package/src/main/alert-dialog/high-priority-confirm-dialog.tsx +67 -48
  52. package/src/main/alert-dialog/index.ts +1 -0
  53. package/src/main/alert-dialog/info-dialog.tsx +50 -44
  54. package/src/main/alert-dialog/undoable-confirm-dialog.tsx +96 -81
  55. package/src/main/buttons/gradient-button.tsx +36 -3
  56. package/src/main/buttons/index.ts +1 -0
  57. package/src/main/buttons/use-press-feedback.ts +58 -0
  58. package/src/main/buttons/x-button.tsx +53 -11
  59. package/src/main/calendar/calendar-date-range-input.tsx +173 -0
  60. package/src/main/calendar/calendar-status-view.tsx +365 -0
  61. package/src/main/calendar/index.ts +5 -0
  62. package/src/main/calendar/random-date-range-dialog.tsx +741 -0
  63. package/src/main/features.tsx +1 -1
  64. package/src/main/usage.tsx +1 -1
@@ -0,0 +1,741 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
+ import { createPortal } from 'react-dom';
5
+ import {
6
+ CheckCheckIcon,
7
+ ChevronLeftIcon,
8
+ ChevronRightIcon,
9
+ ChevronsLeftIcon,
10
+ ChevronsRightIcon,
11
+ XIcon,
12
+ } from '@windrun-huaiin/base-ui/icons';
13
+ import { cn } from '@windrun-huaiin/lib/utils';
14
+ import { usePressFeedback } from '../buttons/use-press-feedback';
15
+
16
+ export type RandomCalendarRange = {
17
+ startDate: string | null;
18
+ endDate: string | null;
19
+ };
20
+
21
+ type RandomDateRangeDialogProps = {
22
+ open: boolean;
23
+ value: RandomCalendarRange;
24
+ anchorDate: string;
25
+ defaultRangeDays?: number;
26
+ onOpenChange: (open: boolean) => void;
27
+ onApply: (range: RandomCalendarRange) => void;
28
+ onClear?: (range: RandomCalendarRange) => void;
29
+ };
30
+
31
+ type QuickRangeDays = 7 | 10 | 15 | 30;
32
+ type DialogNavButtonKey = 'prevYear' | 'prevMonth' | 'nextMonth' | 'nextYear';
33
+
34
+ const DEFAULT_RANGE_DAYS = 7;
35
+ const MAX_RANGE_DAYS = 30;
36
+ const TRACK_MIN_DAYS = 45;
37
+ const TRACK_PADDING_DAYS = 20;
38
+ const DIALOG_ICON_BUTTON_CLASS_NAME =
39
+ 'inline-flex h-8 w-8 items-center justify-center rounded-full border border-black/10 bg-white text-slate-500 transition duration-150 hover:border-black/20 hover:bg-black/5 hover:text-slate-900 dark:border-white/10 dark:bg-slate-950 dark:text-slate-300 dark:hover:bg-white/5 dark:hover:text-white';
40
+ const DIALOG_NAV_BUTTON_CLASS_NAME =
41
+ 'inline-flex h-8 w-8 items-center justify-center rounded-full transition-[transform,background-color,color,box-shadow,border-color] duration-150 ease-out';
42
+ const DIALOG_NAV_BUTTON_REST_CLASS_NAME =
43
+ 'border border-black/10 bg-white text-slate-500 shadow-[0_1px_2px_rgba(15,23,42,0.08)] hover:border-black/15 dark:border-white/10 dark:bg-slate-950 dark:text-slate-400 dark:hover:border-white/15';
44
+ const DIALOG_NAV_BUTTON_PRESSED_CLASS_NAME =
45
+ 'translate-y-[2px] scale-[0.9] border border-black/30 bg-slate-300 text-slate-950 shadow-[inset_0_2px_4px_rgba(15,23,42,0.22)] dark:border-white/25 dark:bg-white/20 dark:text-white dark:shadow-[inset_0_2px_4px_rgba(255,255,255,0.16)]';
46
+ const DIALOG_PILL_BUTTON_CLASS_NAME =
47
+ 'rounded-full bg-slate-100 px-3 py-1.5 text-xs font-semibold text-slate-700 shadow-sm transition-[transform,background-color,color,box-shadow] duration-100 ease-out active:scale-[0.94] active:bg-slate-300 active:text-slate-950 active:shadow-inner dark:bg-white/10 dark:text-slate-200 dark:active:scale-[0.94] dark:active:bg-white/22 dark:active:text-white sm:text-sm';
48
+ const DIALOG_PILL_BUTTON_COMPACT_CLASS_NAME =
49
+ 'rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-700 shadow-sm transition-[transform,background-color,color,box-shadow] duration-100 ease-out active:scale-[0.94] active:bg-slate-300 active:text-slate-950 active:shadow-inner dark:bg-white/10 dark:text-slate-200 dark:active:scale-[0.94] dark:active:bg-white/22 dark:active:text-white';
50
+
51
+ function parseDateString(value: string): Date {
52
+ return new Date(`${value}T00:00:00.000Z`);
53
+ }
54
+
55
+ function getTodayString(): string {
56
+ return new Date().toISOString().slice(0, 10);
57
+ }
58
+
59
+ function formatDateString(date: Date): string {
60
+ return date.toISOString().slice(0, 10);
61
+ }
62
+
63
+ function addDays(value: string, days: number): string {
64
+ const date = parseDateString(value);
65
+ date.setUTCDate(date.getUTCDate() + days);
66
+ return formatDateString(date);
67
+ }
68
+
69
+ function compareDateStrings(left: string, right: string): number {
70
+ return left.localeCompare(right);
71
+ }
72
+
73
+ function getInclusiveDayCount(range: RandomCalendarRange): number {
74
+ if (!range.startDate || !range.endDate) {
75
+ return 0;
76
+ }
77
+
78
+ const startTime = parseDateString(range.startDate).getTime();
79
+ const endTime = parseDateString(range.endDate).getTime();
80
+
81
+ return Math.max(0, Math.floor((endTime - startTime) / 86400000) + 1);
82
+ }
83
+
84
+ function getRangeLabel(range: RandomCalendarRange): string {
85
+ if (!range.startDate || !range.endDate) {
86
+ return 'No range selected';
87
+ }
88
+
89
+ return `${range.startDate} ~ ${range.endDate}`;
90
+ }
91
+
92
+ function clampWindowDays(days: number): number {
93
+ return Math.max(1, Math.min(MAX_RANGE_DAYS, Math.floor(days)));
94
+ }
95
+
96
+ function buildTrackRange(referenceDate: string, windowDays = DEFAULT_RANGE_DAYS): RandomCalendarRange {
97
+ const resolvedWindowDays = clampWindowDays(windowDays);
98
+ const resolvedTotalDays = Math.max(TRACK_MIN_DAYS, resolvedWindowDays + TRACK_PADDING_DAYS);
99
+ const daysBefore = Math.floor((resolvedTotalDays - resolvedWindowDays) / 3);
100
+ const startDate = addDays(referenceDate, -daysBefore);
101
+ const endDate = addDays(startDate, resolvedTotalDays - 1);
102
+ return { startDate, endDate };
103
+ }
104
+
105
+ function clampDateToRange(date: string, bounds: RandomCalendarRange): string {
106
+ if (!bounds.startDate || !bounds.endDate) {
107
+ return date;
108
+ }
109
+
110
+ if (compareDateStrings(date, bounds.startDate) < 0) {
111
+ return bounds.startDate;
112
+ }
113
+
114
+ if (compareDateStrings(date, bounds.endDate) > 0) {
115
+ return bounds.endDate;
116
+ }
117
+
118
+ return date;
119
+ }
120
+
121
+ function getDaysBetween(startDate: string, endDate: string): number {
122
+ const start = parseDateString(startDate).getTime();
123
+ const end = parseDateString(endDate).getTime();
124
+ return Math.max(0, Math.floor((end - start) / 86400000));
125
+ }
126
+
127
+ function getDateByRatio(bounds: RandomCalendarRange, ratio: number): string {
128
+ if (!bounds.startDate || !bounds.endDate) {
129
+ return getTodayString();
130
+ }
131
+
132
+ const totalDays = Math.max(1, getDaysBetween(bounds.startDate, bounds.endDate));
133
+ return addDays(bounds.startDate, Math.round(totalDays * ratio));
134
+ }
135
+
136
+ function getRatioByDate(date: string, bounds: RandomCalendarRange): number {
137
+ if (!bounds.startDate || !bounds.endDate) {
138
+ return 0;
139
+ }
140
+
141
+ const totalDays = Math.max(1, getDaysBetween(bounds.startDate, bounds.endDate));
142
+ const distance = getDaysBetween(bounds.startDate, clampDateToRange(date, bounds));
143
+ return distance / totalDays;
144
+ }
145
+
146
+ function formatMonthShort(value: string): string {
147
+ return parseDateString(value).toLocaleDateString('en-US', {
148
+ month: 'short',
149
+ timeZone: 'UTC',
150
+ });
151
+ }
152
+
153
+ function addMonthsClamped(value: string, months: number): string {
154
+ const source = parseDateString(value);
155
+ const sourceYear = source.getUTCFullYear();
156
+ const sourceMonth = source.getUTCMonth();
157
+ const sourceDay = source.getUTCDate();
158
+ const targetMonthIndex = sourceMonth + months;
159
+ const targetYear = sourceYear + Math.floor(targetMonthIndex / 12);
160
+ const normalizedMonth = ((targetMonthIndex % 12) + 12) % 12;
161
+ const targetMonthLastDay = new Date(Date.UTC(targetYear, normalizedMonth + 1, 0)).getUTCDate();
162
+ const targetDay = Math.min(sourceDay, targetMonthLastDay);
163
+
164
+ return formatDateString(new Date(Date.UTC(targetYear, normalizedMonth, targetDay)));
165
+ }
166
+
167
+ function getMonthStart(value: string): string {
168
+ const date = parseDateString(value);
169
+ return formatDateString(new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1)));
170
+ }
171
+
172
+ function getMonthEnd(value: string): string {
173
+ const date = parseDateString(value);
174
+ return formatDateString(new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0)));
175
+ }
176
+
177
+ export function RandomDateRangeDialog({
178
+ open,
179
+ value,
180
+ anchorDate,
181
+ defaultRangeDays = DEFAULT_RANGE_DAYS,
182
+ onOpenChange,
183
+ onApply,
184
+ onClear,
185
+ }: RandomDateRangeDialogProps) {
186
+ const resolvedDefaultRangeDays = clampWindowDays(defaultRangeDays);
187
+ const [draftRange, setDraftRange] = useState<RandomCalendarRange>(value);
188
+ const [referenceDate, setReferenceDate] = useState(anchorDate);
189
+ const [trackBounds, setTrackBounds] = useState<RandomCalendarRange>(() => buildTrackRange(anchorDate || getTodayString(), resolvedDefaultRangeDays));
190
+ const [windowDays, setWindowDays] = useState<number>(resolvedDefaultRangeDays);
191
+ const {
192
+ pressedKey: pressedNavButton,
193
+ flash: flashNavButtonPress,
194
+ getPressProps: getNavButtonPressProps,
195
+ } = usePressFeedback<DialogNavButtonKey>();
196
+ const dragStartRangeRef = useRef<RandomCalendarRange | null>(null);
197
+ const dragModeRef = useRef<'start' | 'end' | 'window' | null>(null);
198
+ const pointerIdRef = useRef<number | null>(null);
199
+ const windowDragOffsetDaysRef = useRef(0);
200
+ const trackRef = useRef<HTMLDivElement | null>(null);
201
+ const selectionRef = useRef<HTMLDivElement | null>(null);
202
+ const startHandleRef = useRef<HTMLButtonElement | null>(null);
203
+ const endHandleRef = useRef<HTMLButtonElement | null>(null);
204
+ const resultLabelRef = useRef<HTMLDivElement | null>(null);
205
+ const selectionDaysRef = useRef<HTMLDivElement | null>(null);
206
+ const dragPreviewRef = useRef<RandomCalendarRange | null>(null);
207
+ const frameRef = useRef<number | null>(null);
208
+ const pendingClientXRef = useRef<number | null>(null);
209
+ const syncPreviewDomRef = useRef<(range: RandomCalendarRange) => void>(() => {});
210
+ const buildDraggedRangeRef = useRef<(clientX: number) => RandomCalendarRange | null>(() => null);
211
+ const previousBodyOverflowRef = useRef<string | null>(null);
212
+ const today = useMemo(() => getTodayString(), []);
213
+ const baseReferenceDate = anchorDate || today;
214
+ const previousOpenRef = useRef(false);
215
+ const startRatio = getRatioByDate(draftRange.startDate ?? baseReferenceDate, trackBounds);
216
+ const endRatio = getRatioByDate(draftRange.endDate ?? baseReferenceDate, trackBounds);
217
+ const leftPercent = Math.min(startRatio, endRatio) * 100;
218
+ const rightPercent = Math.max(startRatio, endRatio) * 100;
219
+ const widthPercent = Math.max(rightPercent - leftPercent, 0.5);
220
+ const isSingleDay = (draftRange.startDate ?? null) === (draftRange.endDate ?? null);
221
+ const startHandlePercent = isSingleDay ? Math.max(leftPercent - 0.8, 0) : leftPercent;
222
+ const endHandlePercent = isSingleDay ? Math.min(rightPercent + 0.8, 100) : rightPercent;
223
+ const trackTickCount = Math.max(getDaysBetween(trackBounds.startDate ?? baseReferenceDate, trackBounds.endDate ?? baseReferenceDate) + 1, 2);
224
+ const monthLabels = useMemo(() => {
225
+ const values = [trackBounds.startDate, trackBounds.endDate]
226
+ .filter((item): item is string => Boolean(item))
227
+ .map((item) => formatMonthShort(item));
228
+
229
+ return [...new Set(values)];
230
+ }, [trackBounds.endDate, trackBounds.startDate]);
231
+
232
+ useEffect(() => {
233
+ if (open && !previousOpenRef.current) {
234
+ const nextRange = {
235
+ startDate: baseReferenceDate,
236
+ endDate: addDays(baseReferenceDate, resolvedDefaultRangeDays - 1),
237
+ };
238
+ setDraftRange(nextRange);
239
+ setReferenceDate(baseReferenceDate);
240
+ setTrackBounds(buildTrackRange(baseReferenceDate, resolvedDefaultRangeDays));
241
+ setWindowDays(resolvedDefaultRangeDays);
242
+ dragStartRangeRef.current = null;
243
+ dragModeRef.current = null;
244
+ pointerIdRef.current = null;
245
+ dragPreviewRef.current = nextRange;
246
+ }
247
+ previousOpenRef.current = open;
248
+ }, [baseReferenceDate, open, resolvedDefaultRangeDays]);
249
+
250
+ function updateRangeByReference(nextReferenceDate: string, nextWindowDays: number, options?: { preserveTrack?: boolean }) {
251
+ const clampedWindowDays = clampWindowDays(nextWindowDays);
252
+ const nextRange = {
253
+ startDate: nextReferenceDate,
254
+ endDate: addDays(nextReferenceDate, Math.max(clampedWindowDays - 1, 0)),
255
+ };
256
+ setReferenceDate(nextReferenceDate);
257
+ setWindowDays(clampedWindowDays);
258
+ setDraftRange(nextRange);
259
+ if (!options?.preserveTrack) {
260
+ setTrackBounds(buildTrackRange(nextReferenceDate, clampedWindowDays));
261
+ }
262
+ }
263
+
264
+ const getPreviewPercents = useCallback((range: RandomCalendarRange) => {
265
+ const start = range.startDate ?? baseReferenceDate;
266
+ const end = range.endDate ?? start;
267
+ const startR = getRatioByDate(start, trackBounds);
268
+ const endR = getRatioByDate(end, trackBounds);
269
+ const left = Math.min(startR, endR) * 100;
270
+ const right = Math.max(startR, endR) * 100;
271
+ const width = Math.max(right - left, 0.5);
272
+ const single = start === end;
273
+
274
+ return {
275
+ left,
276
+ right,
277
+ width,
278
+ startHandle: single ? Math.max(left - 0.8, 0) : left,
279
+ endHandle: single ? Math.min(right + 0.8, 100) : right,
280
+ };
281
+ }, [baseReferenceDate, trackBounds]);
282
+
283
+ const syncPreviewDom = useCallback((range: RandomCalendarRange) => {
284
+ const percents = getPreviewPercents(range);
285
+ if (selectionRef.current) {
286
+ selectionRef.current.style.left = `${percents.left}%`;
287
+ selectionRef.current.style.width = `${percents.width}%`;
288
+ }
289
+ if (startHandleRef.current) {
290
+ startHandleRef.current.style.left = `${percents.startHandle}%`;
291
+ }
292
+ if (endHandleRef.current) {
293
+ endHandleRef.current.style.left = `${percents.endHandle}%`;
294
+ }
295
+ if (resultLabelRef.current) {
296
+ resultLabelRef.current.textContent = getRangeLabel(range);
297
+ }
298
+ if (selectionDaysRef.current) {
299
+ selectionDaysRef.current.textContent = `${getInclusiveDayCount(range)}D`;
300
+ }
301
+ }, [getPreviewPercents]);
302
+
303
+ useEffect(() => {
304
+ dragPreviewRef.current = draftRange;
305
+ syncPreviewDom(draftRange);
306
+ }, [draftRange, syncPreviewDom]);
307
+
308
+ function resetReferenceFromClientX(clientX: number) {
309
+ if (!trackRef.current) {
310
+ return;
311
+ }
312
+
313
+ const rect = trackRef.current.getBoundingClientRect();
314
+ const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
315
+ const nextReferenceDate = getDateByRatio(trackBounds, ratio);
316
+ updateRangeByReference(nextReferenceDate, resolvedDefaultRangeDays, { preserveTrack: true });
317
+ }
318
+
319
+ function applyQuickRange(dayCount: QuickRangeDays) {
320
+ updateRangeByReference(referenceDate, dayCount);
321
+ }
322
+
323
+ function shiftReferenceDateByMonths(monthOffset: number) {
324
+ updateRangeByReference(addMonthsClamped(referenceDate, monthOffset), windowDays);
325
+ }
326
+
327
+ function shiftReferenceDateByYears(yearOffset: number) {
328
+ updateRangeByReference(addMonthsClamped(referenceDate, yearOffset * 12), windowDays);
329
+ }
330
+
331
+ function beginDrag(mode: 'start' | 'end' | 'window', pointerId: number, clientX?: number) {
332
+ document.body.style.userSelect = 'none';
333
+ dragModeRef.current = mode;
334
+ pointerIdRef.current = pointerId;
335
+ dragStartRangeRef.current = { ...draftRange };
336
+ dragPreviewRef.current = { ...draftRange };
337
+
338
+ if (
339
+ mode === 'window' &&
340
+ clientX !== undefined &&
341
+ trackRef.current &&
342
+ draftRange.startDate &&
343
+ draftRange.endDate &&
344
+ trackBounds.startDate &&
345
+ trackBounds.endDate
346
+ ) {
347
+ const rect = trackRef.current.getBoundingClientRect();
348
+ const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
349
+ const pointerDate = getDateByRatio(trackBounds, ratio);
350
+ windowDragOffsetDaysRef.current = getDaysBetween(draftRange.startDate, pointerDate);
351
+ } else {
352
+ windowDragOffsetDaysRef.current = 0;
353
+ }
354
+ }
355
+
356
+ const buildDraggedRange = useCallback((clientX: number): RandomCalendarRange | null => {
357
+ if (!dragModeRef.current || !dragStartRangeRef.current || !trackBounds.startDate || !trackBounds.endDate || !trackRef.current) {
358
+ return null;
359
+ }
360
+
361
+ const rect = trackRef.current.getBoundingClientRect();
362
+ const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
363
+ const pointerDate = getDateByRatio(trackBounds, ratio);
364
+ const currentRange = dragStartRangeRef.current;
365
+
366
+ if (!currentRange.startDate || !currentRange.endDate) {
367
+ return null;
368
+ }
369
+
370
+ if (dragModeRef.current === 'start') {
371
+ const earliestStart = addDays(currentRange.endDate, -(MAX_RANGE_DAYS - 1));
372
+ const boundedPointerDate = compareDateStrings(pointerDate, earliestStart) < 0 ? earliestStart : pointerDate;
373
+ const nextStart = compareDateStrings(boundedPointerDate, currentRange.endDate) > 0 ? currentRange.endDate : boundedPointerDate;
374
+ return { startDate: nextStart, endDate: currentRange.endDate };
375
+ }
376
+
377
+ if (dragModeRef.current === 'end') {
378
+ const latestEnd = addDays(currentRange.startDate, MAX_RANGE_DAYS - 1);
379
+ const boundedPointerDate = compareDateStrings(pointerDate, latestEnd) > 0 ? latestEnd : pointerDate;
380
+ const nextEnd = compareDateStrings(boundedPointerDate, currentRange.startDate) < 0 ? currentRange.startDate : boundedPointerDate;
381
+ return { startDate: currentRange.startDate, endDate: nextEnd };
382
+ }
383
+
384
+ const spanDays = getDaysBetween(currentRange.startDate, currentRange.endDate);
385
+ const nextStart = clampDateToRange(addDays(pointerDate, -windowDragOffsetDaysRef.current), {
386
+ startDate: trackBounds.startDate,
387
+ endDate: addDays(trackBounds.endDate, -spanDays),
388
+ });
389
+ const nextEnd = addDays(nextStart, spanDays);
390
+ return { startDate: nextStart, endDate: nextEnd };
391
+ }, [trackBounds]);
392
+
393
+ useEffect(() => {
394
+ syncPreviewDomRef.current = syncPreviewDom;
395
+ buildDraggedRangeRef.current = buildDraggedRange;
396
+ }, [syncPreviewDom, buildDraggedRange]);
397
+
398
+ function endDrag() {
399
+ if (frameRef.current !== null) {
400
+ window.cancelAnimationFrame(frameRef.current);
401
+ frameRef.current = null;
402
+ }
403
+ pendingClientXRef.current = null;
404
+ document.body.style.userSelect = '';
405
+
406
+ const nextRange = dragPreviewRef.current;
407
+ dragStartRangeRef.current = null;
408
+ dragModeRef.current = null;
409
+ pointerIdRef.current = null;
410
+ windowDragOffsetDaysRef.current = 0;
411
+ if (nextRange?.startDate && nextRange.endDate) {
412
+ setDraftRange(nextRange);
413
+ setReferenceDate(nextRange.startDate);
414
+ setWindowDays(getInclusiveDayCount(nextRange));
415
+ }
416
+ }
417
+
418
+ useEffect(() => {
419
+ if (!open) {
420
+ return undefined;
421
+ }
422
+
423
+ previousBodyOverflowRef.current = document.body.style.overflow;
424
+ document.body.style.overflow = 'hidden';
425
+
426
+ function handleWindowPointerMove(event: PointerEvent) {
427
+ if (dragModeRef.current === null) {
428
+ return;
429
+ }
430
+
431
+ pendingClientXRef.current = event.clientX;
432
+ if (frameRef.current !== null) {
433
+ return;
434
+ }
435
+
436
+ frameRef.current = window.requestAnimationFrame(() => {
437
+ frameRef.current = null;
438
+ if (pendingClientXRef.current === null) {
439
+ return;
440
+ }
441
+
442
+ const nextRange = buildDraggedRangeRef.current(pendingClientXRef.current);
443
+ if (nextRange) {
444
+ dragPreviewRef.current = nextRange;
445
+ syncPreviewDomRef.current(nextRange);
446
+ }
447
+ });
448
+ }
449
+
450
+ function handleWindowPointerUp(event: PointerEvent) {
451
+ if (pointerIdRef.current !== null && event.pointerId !== pointerIdRef.current) {
452
+ return;
453
+ }
454
+
455
+ if (dragModeRef.current !== null) {
456
+ endDrag();
457
+ }
458
+ }
459
+
460
+ window.addEventListener('pointermove', handleWindowPointerMove, { passive: true });
461
+ window.addEventListener('pointerup', handleWindowPointerUp);
462
+ window.addEventListener('pointercancel', handleWindowPointerUp);
463
+
464
+ return () => {
465
+ document.body.style.overflow = previousBodyOverflowRef.current ?? '';
466
+ previousBodyOverflowRef.current = null;
467
+ document.body.style.userSelect = '';
468
+ window.removeEventListener('pointermove', handleWindowPointerMove);
469
+ window.removeEventListener('pointerup', handleWindowPointerUp);
470
+ window.removeEventListener('pointercancel', handleWindowPointerUp);
471
+ };
472
+ }, [open]);
473
+
474
+ if (!open) {
475
+ return null;
476
+ }
477
+
478
+ return createPortal(
479
+ <div className="fixed inset-0 z-120 flex select-none items-center justify-center bg-slate-950/60 px-3 py-6 backdrop-blur-sm">
480
+ <div className="w-full max-w-2xl overflow-hidden rounded-3xl border border-black/10 bg-white shadow-2xl dark:border-white/10 dark:bg-slate-950">
481
+ <div className="space-y-5 p-4">
482
+ <div className="relative flex items-center justify-center px-16 text-center">
483
+ <div ref={resultLabelRef} className="select-none text-base font-semibold text-slate-900 dark:text-white">{getRangeLabel(draftRange)}</div>
484
+ <div className="absolute right-0 top-1/2 flex -translate-y-1/2 items-center gap-2">
485
+ <button
486
+ type="button"
487
+ onClick={() => onOpenChange(false)}
488
+ className={DIALOG_ICON_BUTTON_CLASS_NAME}
489
+ aria-label="Cancel"
490
+ >
491
+ <XIcon className="h-4 w-4" />
492
+ </button>
493
+ <button
494
+ type="button"
495
+ onClick={() => {
496
+ onApply(draftRange);
497
+ onOpenChange(false);
498
+ }}
499
+ disabled={!draftRange.startDate || !draftRange.endDate}
500
+ className={cn(
501
+ DIALOG_ICON_BUTTON_CLASS_NAME,
502
+ 'text-slate-700 dark:text-slate-100',
503
+ 'disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:border-black/10 disabled:hover:bg-white disabled:hover:text-slate-700 dark:disabled:hover:border-white/10 dark:disabled:hover:bg-slate-950 dark:disabled:hover:text-slate-100'
504
+ )}
505
+ aria-label="Apply"
506
+ >
507
+ <CheckCheckIcon className="h-4 w-4" />
508
+ </button>
509
+ </div>
510
+ </div>
511
+
512
+ <div className="space-y-4">
513
+ <div className="flex items-center justify-between gap-2">
514
+ <div className="flex items-center gap-1">
515
+ <button
516
+ type="button"
517
+ onClick={() => {
518
+ flashNavButtonPress('prevYear');
519
+ shiftReferenceDateByYears(-1);
520
+ }}
521
+ className={cn(
522
+ DIALOG_NAV_BUTTON_CLASS_NAME,
523
+ pressedNavButton === 'prevYear'
524
+ ? DIALOG_NAV_BUTTON_PRESSED_CLASS_NAME
525
+ : DIALOG_NAV_BUTTON_REST_CLASS_NAME
526
+ )}
527
+ {...getNavButtonPressProps('prevYear')}
528
+ aria-label="Previous year"
529
+ title="Previous year"
530
+ >
531
+ <ChevronsLeftIcon className="h-4 w-4" />
532
+ </button>
533
+ <button
534
+ type="button"
535
+ onClick={() => {
536
+ flashNavButtonPress('prevMonth');
537
+ shiftReferenceDateByMonths(-1);
538
+ }}
539
+ className={cn(
540
+ DIALOG_NAV_BUTTON_CLASS_NAME,
541
+ pressedNavButton === 'prevMonth'
542
+ ? DIALOG_NAV_BUTTON_PRESSED_CLASS_NAME
543
+ : DIALOG_NAV_BUTTON_REST_CLASS_NAME
544
+ )}
545
+ {...getNavButtonPressProps('prevMonth')}
546
+ aria-label="Previous month"
547
+ title="Previous month"
548
+ >
549
+ <ChevronLeftIcon className="h-4 w-4" />
550
+ </button>
551
+ </div>
552
+
553
+ <div className="flex items-center gap-2">
554
+ <button
555
+ type="button"
556
+ onClick={() => {
557
+ const nextRange = {
558
+ startDate: baseReferenceDate,
559
+ endDate: addDays(baseReferenceDate, resolvedDefaultRangeDays - 1),
560
+ };
561
+ setReferenceDate(baseReferenceDate);
562
+ setTrackBounds(buildTrackRange(baseReferenceDate, resolvedDefaultRangeDays));
563
+ setWindowDays(resolvedDefaultRangeDays);
564
+ setDraftRange(nextRange);
565
+ onClear?.(nextRange);
566
+ }}
567
+ className={DIALOG_PILL_BUTTON_CLASS_NAME}
568
+ >
569
+ Current Day
570
+ </button>
571
+ <button
572
+ type="button"
573
+ onClick={() => {
574
+ const nextRange = {
575
+ startDate: getMonthStart(referenceDate),
576
+ endDate: addDays(getMonthStart(referenceDate), MAX_RANGE_DAYS - 1),
577
+ };
578
+ const clampedEndDate = compareDateStrings(nextRange.endDate, getMonthEnd(referenceDate)) > 0
579
+ ? getMonthEnd(referenceDate)
580
+ : nextRange.endDate;
581
+ const normalizedRange = {
582
+ startDate: nextRange.startDate,
583
+ endDate: clampedEndDate,
584
+ };
585
+ setDraftRange(normalizedRange);
586
+ setWindowDays(getInclusiveDayCount(normalizedRange));
587
+ setTrackBounds(buildTrackRange(normalizedRange.startDate, getInclusiveDayCount(normalizedRange)));
588
+ }}
589
+ className={DIALOG_PILL_BUTTON_CLASS_NAME}
590
+ >
591
+ This Month
592
+ </button>
593
+ </div>
594
+
595
+ <div className="flex items-center gap-1">
596
+ <button
597
+ type="button"
598
+ onClick={() => {
599
+ flashNavButtonPress('nextMonth');
600
+ shiftReferenceDateByMonths(1);
601
+ }}
602
+ className={cn(
603
+ DIALOG_NAV_BUTTON_CLASS_NAME,
604
+ pressedNavButton === 'nextMonth'
605
+ ? DIALOG_NAV_BUTTON_PRESSED_CLASS_NAME
606
+ : DIALOG_NAV_BUTTON_REST_CLASS_NAME
607
+ )}
608
+ {...getNavButtonPressProps('nextMonth')}
609
+ aria-label="Next month"
610
+ title="Next month"
611
+ >
612
+ <ChevronRightIcon className="h-4 w-4" />
613
+ </button>
614
+ <button
615
+ type="button"
616
+ onClick={() => {
617
+ flashNavButtonPress('nextYear');
618
+ shiftReferenceDateByYears(1);
619
+ }}
620
+ className={cn(
621
+ DIALOG_NAV_BUTTON_CLASS_NAME,
622
+ pressedNavButton === 'nextYear'
623
+ ? DIALOG_NAV_BUTTON_PRESSED_CLASS_NAME
624
+ : DIALOG_NAV_BUTTON_REST_CLASS_NAME
625
+ )}
626
+ {...getNavButtonPressProps('nextYear')}
627
+ aria-label="Next year"
628
+ title="Next year"
629
+ >
630
+ <ChevronsRightIcon className="h-4 w-4" />
631
+ </button>
632
+ </div>
633
+ </div>
634
+
635
+ <div className="relative h-24">
636
+ <div className="absolute inset-x-0 top-0 grid grid-cols-[3.5rem_minmax(0,1fr)_3.5rem] items-center gap-2 text-sm font-semibold text-slate-500 dark:text-slate-400">
637
+ <span className="relative block select-none text-center">
638
+ {monthLabels[0] ?? formatMonthShort(trackBounds.startDate ?? baseReferenceDate)}
639
+ <span className="pointer-events-none absolute left-1/2 top-7 h-2.5 w-2.5 -translate-x-1/2 rounded-full bg-slate-400 dark:bg-slate-500" />
640
+ <span className="pointer-events-none absolute left-1/2 top-[1.95rem] h-9 w-0.5 -translate-x-1/2 bg-slate-400 dark:bg-slate-500" />
641
+ </span>
642
+ <div className="flex min-w-0 items-center justify-center gap-1">
643
+ {([
644
+ { label: '+7', days: 7 },
645
+ { label: '+10', days: 10 },
646
+ { label: '+15', days: 15 },
647
+ { label: '+30', days: 30 },
648
+ ] as const).map((item) => (
649
+ <button
650
+ key={item.label}
651
+ type="button"
652
+ onClick={() => applyQuickRange(item.days)}
653
+ className={DIALOG_PILL_BUTTON_COMPACT_CLASS_NAME}
654
+ >
655
+ {item.label}
656
+ </button>
657
+ ))}
658
+ </div>
659
+ <span className="relative block select-none text-center">
660
+ {monthLabels[1] ?? formatMonthShort(trackBounds.endDate ?? baseReferenceDate)}
661
+ <span className="pointer-events-none absolute right-1/2 top-7 h-2.5 w-2.5 translate-x-1/2 rounded-full bg-slate-400 dark:bg-slate-500" />
662
+ <span className="pointer-events-none absolute right-1/2 top-[1.95rem] h-9 w-0.5 translate-x-1/2 bg-slate-400 dark:bg-slate-500" />
663
+ </span>
664
+ </div>
665
+
666
+ <div
667
+ className="absolute inset-x-0 top-[3.35rem] h-10 touch-none"
668
+ onDoubleClick={(event) => {
669
+ event.stopPropagation();
670
+ resetReferenceFromClientX(event.clientX);
671
+ }}
672
+ >
673
+ <div
674
+ ref={trackRef}
675
+ className="absolute inset-x-0 top-1/2 h-3 -translate-y-1/2 rounded-full bg-slate-400/30 dark:bg-slate-500/25"
676
+ >
677
+ <div
678
+ aria-hidden="true"
679
+ className="pointer-events-none absolute inset-x-0 top-1/2 grid h-8 -translate-y-1/2 items-center"
680
+ style={{ gridTemplateColumns: `repeat(${trackTickCount}, minmax(0, 1fr))` }}
681
+ >
682
+ {Array.from({ length: trackTickCount }, (_, index) => {
683
+ return (
684
+ <span key={index} className="flex justify-center">
685
+ <span
686
+ className={cn(
687
+ 'rounded-full bg-slate-400/55 dark:bg-slate-500/55',
688
+ 'h-3 w-px'
689
+ )}
690
+ />
691
+ </span>
692
+ );
693
+ })}
694
+ </div>
695
+ <div
696
+ ref={selectionRef}
697
+ className="absolute top-1/2 z-10 h-4 touch-none -translate-y-1/2 overflow-visible rounded-md border border-sky-500 bg-white dark:border-sky-300 dark:bg-slate-950"
698
+ style={{ left: `${leftPercent}%`, width: `${widthPercent}%` }}
699
+ onPointerDown={(event) => {
700
+ event.stopPropagation();
701
+ beginDrag('window', event.pointerId, event.clientX);
702
+ }}
703
+ >
704
+ <div ref={selectionDaysRef} className="pointer-events-none absolute inset-0 z-30 flex select-none items-center justify-center text-xs font-semibold text-sky-700 dark:text-sky-100">
705
+ {`${getInclusiveDayCount(draftRange)}D`}
706
+ </div>
707
+ </div>
708
+
709
+ <button
710
+ ref={startHandleRef}
711
+ type="button"
712
+ className="absolute top-1/2 z-20 h-6 w-6 touch-none -translate-x-1/2 -translate-y-1/2 rounded-full border border-sky-500 bg-white shadow-sm dark:border-sky-300 dark:bg-slate-950"
713
+ style={{ left: `${startHandlePercent}%` }}
714
+ onPointerDown={(event) => {
715
+ event.stopPropagation();
716
+ beginDrag('start', event.pointerId);
717
+ }}
718
+ aria-label="Adjust start date"
719
+ />
720
+ <button
721
+ ref={endHandleRef}
722
+ type="button"
723
+ className="absolute top-1/2 z-20 h-6 w-6 touch-none -translate-x-1/2 -translate-y-1/2 rounded-full border border-sky-500 bg-white shadow-sm dark:border-sky-300 dark:bg-slate-950"
724
+ style={{ left: `${endHandlePercent}%` }}
725
+ onPointerDown={(event) => {
726
+ event.stopPropagation();
727
+ beginDrag('end', event.pointerId);
728
+ }}
729
+ aria-label="Adjust end date"
730
+ />
731
+ </div>
732
+ </div>
733
+ </div>
734
+
735
+ </div>
736
+ </div>
737
+ </div>
738
+ </div>,
739
+ document.body
740
+ );
741
+ }