tekivex-ui 2.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 (173) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +201 -0
  3. package/dist/index.cjs +70 -0
  4. package/dist/index.d.ts +89 -0
  5. package/dist/index.d.ts.map +1 -0
  6. package/dist/index.js +11002 -0
  7. package/dist/src/a11y/index.d.ts +24 -0
  8. package/dist/src/a11y/index.d.ts.map +1 -0
  9. package/dist/src/components/TkxAccordion.d.ts +32 -0
  10. package/dist/src/components/TkxAccordion.d.ts.map +1 -0
  11. package/dist/src/components/TkxAlert.d.ts +14 -0
  12. package/dist/src/components/TkxAlert.d.ts.map +1 -0
  13. package/dist/src/components/TkxAvatar.d.ts +14 -0
  14. package/dist/src/components/TkxAvatar.d.ts.map +1 -0
  15. package/dist/src/components/TkxBadge.d.ts +12 -0
  16. package/dist/src/components/TkxBadge.d.ts.map +1 -0
  17. package/dist/src/components/TkxButton.d.ts +17 -0
  18. package/dist/src/components/TkxButton.d.ts.map +1 -0
  19. package/dist/src/components/TkxCard.d.ts +20 -0
  20. package/dist/src/components/TkxCard.d.ts.map +1 -0
  21. package/dist/src/components/TkxCarousel.d.ts +32 -0
  22. package/dist/src/components/TkxCarousel.d.ts.map +1 -0
  23. package/dist/src/components/TkxChat.d.ts +32 -0
  24. package/dist/src/components/TkxChat.d.ts.map +1 -0
  25. package/dist/src/components/TkxCheckbox.d.ts +13 -0
  26. package/dist/src/components/TkxCheckbox.d.ts.map +1 -0
  27. package/dist/src/components/TkxClock.d.ts +23 -0
  28. package/dist/src/components/TkxClock.d.ts.map +1 -0
  29. package/dist/src/components/TkxColorPicker.d.ts +18 -0
  30. package/dist/src/components/TkxColorPicker.d.ts.map +1 -0
  31. package/dist/src/components/TkxCommand.d.ts +34 -0
  32. package/dist/src/components/TkxCommand.d.ts.map +1 -0
  33. package/dist/src/components/TkxDatePicker.d.ts +47 -0
  34. package/dist/src/components/TkxDatePicker.d.ts.map +1 -0
  35. package/dist/src/components/TkxDivider.d.ts +8 -0
  36. package/dist/src/components/TkxDivider.d.ts.map +1 -0
  37. package/dist/src/components/TkxDrawer.d.ts +16 -0
  38. package/dist/src/components/TkxDrawer.d.ts.map +1 -0
  39. package/dist/src/components/TkxFileUpload.d.ts +16 -0
  40. package/dist/src/components/TkxFileUpload.d.ts.map +1 -0
  41. package/dist/src/components/TkxIcon.d.ts +16 -0
  42. package/dist/src/components/TkxIcon.d.ts.map +1 -0
  43. package/dist/src/components/TkxImage.d.ts +16 -0
  44. package/dist/src/components/TkxImage.d.ts.map +1 -0
  45. package/dist/src/components/TkxInput.d.ts +13 -0
  46. package/dist/src/components/TkxInput.d.ts.map +1 -0
  47. package/dist/src/components/TkxLogo.d.ts +21 -0
  48. package/dist/src/components/TkxLogo.d.ts.map +1 -0
  49. package/dist/src/components/TkxMenu.d.ts +64 -0
  50. package/dist/src/components/TkxMenu.d.ts.map +1 -0
  51. package/dist/src/components/TkxModal.d.ts +14 -0
  52. package/dist/src/components/TkxModal.d.ts.map +1 -0
  53. package/dist/src/components/TkxNumberInput.d.ts +32 -0
  54. package/dist/src/components/TkxNumberInput.d.ts.map +1 -0
  55. package/dist/src/components/TkxOTP.d.ts +24 -0
  56. package/dist/src/components/TkxOTP.d.ts.map +1 -0
  57. package/dist/src/components/TkxPagination.d.ts +19 -0
  58. package/dist/src/components/TkxPagination.d.ts.map +1 -0
  59. package/dist/src/components/TkxProgress.d.ts +13 -0
  60. package/dist/src/components/TkxProgress.d.ts.map +1 -0
  61. package/dist/src/components/TkxRadio.d.ts +22 -0
  62. package/dist/src/components/TkxRadio.d.ts.map +1 -0
  63. package/dist/src/components/TkxRating.d.ts +18 -0
  64. package/dist/src/components/TkxRating.d.ts.map +1 -0
  65. package/dist/src/components/TkxSelect.d.ts +37 -0
  66. package/dist/src/components/TkxSelect.d.ts.map +1 -0
  67. package/dist/src/components/TkxSkeleton.d.ts +12 -0
  68. package/dist/src/components/TkxSkeleton.d.ts.map +1 -0
  69. package/dist/src/components/TkxSlider.d.ts +33 -0
  70. package/dist/src/components/TkxSlider.d.ts.map +1 -0
  71. package/dist/src/components/TkxStepper.d.ts +31 -0
  72. package/dist/src/components/TkxStepper.d.ts.map +1 -0
  73. package/dist/src/components/TkxTable.d.ts +23 -0
  74. package/dist/src/components/TkxTable.d.ts.map +1 -0
  75. package/dist/src/components/TkxTabs.d.ts +32 -0
  76. package/dist/src/components/TkxTabs.d.ts.map +1 -0
  77. package/dist/src/components/TkxTag.d.ts +30 -0
  78. package/dist/src/components/TkxTag.d.ts.map +1 -0
  79. package/dist/src/components/TkxTimeline.d.ts +20 -0
  80. package/dist/src/components/TkxTimeline.d.ts.map +1 -0
  81. package/dist/src/components/TkxToast.d.ts +23 -0
  82. package/dist/src/components/TkxToast.d.ts.map +1 -0
  83. package/dist/src/components/TkxToggle.d.ts +11 -0
  84. package/dist/src/components/TkxToggle.d.ts.map +1 -0
  85. package/dist/src/components/TkxTooltip.d.ts +10 -0
  86. package/dist/src/components/TkxTooltip.d.ts.map +1 -0
  87. package/dist/src/components/TkxVideoPlayer.d.ts +43 -0
  88. package/dist/src/components/TkxVideoPlayer.d.ts.map +1 -0
  89. package/dist/src/components/index.d.ts +41 -0
  90. package/dist/src/components/index.d.ts.map +1 -0
  91. package/dist/src/engine/css.d.ts +24 -0
  92. package/dist/src/engine/css.d.ts.map +1 -0
  93. package/dist/src/engine/index.d.ts +10 -0
  94. package/dist/src/engine/index.d.ts.map +1 -0
  95. package/dist/src/engine/quantum.d.ts +47 -0
  96. package/dist/src/engine/quantum.d.ts.map +1 -0
  97. package/dist/src/engine/security.d.ts +54 -0
  98. package/dist/src/engine/security.d.ts.map +1 -0
  99. package/dist/src/engine/tkx.d.ts +29 -0
  100. package/dist/src/engine/tkx.d.ts.map +1 -0
  101. package/dist/src/engine/wcag.d.ts +34 -0
  102. package/dist/src/engine/wcag.d.ts.map +1 -0
  103. package/dist/src/hooks/index.d.ts +8 -0
  104. package/dist/src/hooks/index.d.ts.map +1 -0
  105. package/dist/src/themes/index.d.ts +26 -0
  106. package/dist/src/themes/index.d.ts.map +1 -0
  107. package/dist/style.css +1 -0
  108. package/dist/tests/TkxButton.test.d.ts +2 -0
  109. package/dist/tests/TkxButton.test.d.ts.map +1 -0
  110. package/dist/tests/a11y/components.test.d.ts +2 -0
  111. package/dist/tests/a11y/components.test.d.ts.map +1 -0
  112. package/dist/tests/quantum.test.d.ts +2 -0
  113. package/dist/tests/quantum.test.d.ts.map +1 -0
  114. package/dist/tests/security.test.d.ts +2 -0
  115. package/dist/tests/security.test.d.ts.map +1 -0
  116. package/dist/tests/setup.d.ts +2 -0
  117. package/dist/tests/setup.d.ts.map +1 -0
  118. package/dist/tests/tkx.test.d.ts +2 -0
  119. package/dist/tests/tkx.test.d.ts.map +1 -0
  120. package/dist/tests/wcag.test.d.ts +2 -0
  121. package/dist/tests/wcag.test.d.ts.map +1 -0
  122. package/package.json +78 -0
  123. package/src/a11y/index.tsx +87 -0
  124. package/src/components/TkxAccordion.tsx +588 -0
  125. package/src/components/TkxAlert.tsx +91 -0
  126. package/src/components/TkxAvatar.tsx +101 -0
  127. package/src/components/TkxBadge.tsx +73 -0
  128. package/src/components/TkxButton.tsx +153 -0
  129. package/src/components/TkxCard.tsx +92 -0
  130. package/src/components/TkxCarousel.tsx +552 -0
  131. package/src/components/TkxChat.tsx +354 -0
  132. package/src/components/TkxCheckbox.tsx +218 -0
  133. package/src/components/TkxClock.tsx +601 -0
  134. package/src/components/TkxColorPicker.tsx +633 -0
  135. package/src/components/TkxCommand.tsx +644 -0
  136. package/src/components/TkxDatePicker.tsx +1567 -0
  137. package/src/components/TkxDivider.tsx +49 -0
  138. package/src/components/TkxDrawer.tsx +234 -0
  139. package/src/components/TkxFileUpload.tsx +371 -0
  140. package/src/components/TkxIcon.tsx +683 -0
  141. package/src/components/TkxImage.tsx +277 -0
  142. package/src/components/TkxInput.tsx +101 -0
  143. package/src/components/TkxLogo.tsx +297 -0
  144. package/src/components/TkxMenu.tsx +1042 -0
  145. package/src/components/TkxModal.tsx +119 -0
  146. package/src/components/TkxNumberInput.tsx +408 -0
  147. package/src/components/TkxOTP.tsx +295 -0
  148. package/src/components/TkxPagination.tsx +388 -0
  149. package/src/components/TkxProgress.tsx +114 -0
  150. package/src/components/TkxRadio.tsx +284 -0
  151. package/src/components/TkxRating.tsx +286 -0
  152. package/src/components/TkxSelect.tsx +923 -0
  153. package/src/components/TkxSkeleton.tsx +105 -0
  154. package/src/components/TkxSlider.tsx +492 -0
  155. package/src/components/TkxStepper.tsx +485 -0
  156. package/src/components/TkxTable.tsx +148 -0
  157. package/src/components/TkxTabs.tsx +124 -0
  158. package/src/components/TkxTag.tsx +319 -0
  159. package/src/components/TkxTimeline.tsx +303 -0
  160. package/src/components/TkxToast.tsx +284 -0
  161. package/src/components/TkxToggle.tsx +93 -0
  162. package/src/components/TkxTooltip.tsx +69 -0
  163. package/src/components/TkxVideoPlayer.tsx +1013 -0
  164. package/src/components/index.ts +40 -0
  165. package/src/engine/css.ts +126 -0
  166. package/src/engine/index.ts +10 -0
  167. package/src/engine/quantum.ts +207 -0
  168. package/src/engine/security.ts +217 -0
  169. package/src/engine/tkx.ts +874 -0
  170. package/src/engine/wcag.ts +275 -0
  171. package/src/hooks/index.ts +120 -0
  172. package/src/styles/global.css +167 -0
  173. package/src/themes/index.ts +111 -0
@@ -0,0 +1,1567 @@
1
+ import {
2
+ useState,
3
+ useRef,
4
+ useEffect,
5
+ useCallback,
6
+ useId,
7
+ useReducer,
8
+ type CSSProperties,
9
+ type KeyboardEvent,
10
+ } from 'react';
11
+ import { createPortal } from 'react-dom';
12
+ import { useTheme } from '../themes';
13
+ import { sanitizeString } from '../engine/security';
14
+ import { useReducedMotion } from '../hooks';
15
+
16
+ // ── Types ─────────────────────────────────────────────────────────────────────
17
+
18
+ export type DatePickerMode = 'single' | 'range' | 'multiple';
19
+ export type DatePickerView = 'day' | 'month' | 'year';
20
+
21
+ export interface DatePreset {
22
+ label: string;
23
+ getValue: () => [Date, Date];
24
+ }
25
+
26
+ export interface TkxDatePickerProps {
27
+ // Value
28
+ value?: Date | null;
29
+ onChange?: (date: Date | null) => void;
30
+ rangeValue?: [Date | null, Date | null];
31
+ onRangeChange?: (range: [Date | null, Date | null]) => void;
32
+ multiValue?: Date[];
33
+ onMultiChange?: (dates: Date[]) => void;
34
+
35
+ // Config
36
+ mode?: DatePickerMode;
37
+ minDate?: Date;
38
+ maxDate?: Date;
39
+ disabledDates?: Date[] | ((date: Date) => boolean);
40
+ locale?: string;
41
+ dateFormat?: string;
42
+
43
+ // Time picker
44
+ showTime?: boolean;
45
+ timeValue?: { h: number; m: number };
46
+ onTimeChange?: (time: { h: number; m: number }) => void;
47
+
48
+ // Presets
49
+ showPresets?: boolean;
50
+ customPresets?: DatePreset[];
51
+
52
+ // UI
53
+ label?: string;
54
+ placeholder?: string;
55
+ hint?: string;
56
+ isDisabled?: boolean;
57
+ isInvalid?: boolean;
58
+ errorMessage?: string;
59
+ numberOfMonths?: number;
60
+
61
+ id?: string;
62
+ className?: string;
63
+ style?: CSSProperties;
64
+ }
65
+
66
+ // ── Constants ─────────────────────────────────────────────────────────────────
67
+
68
+ const DAYS_SHORT = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
69
+ const MONTH_NAMES = [
70
+ 'January', 'February', 'March', 'April', 'May', 'June',
71
+ 'July', 'August', 'September', 'October', 'November', 'December',
72
+ ];
73
+ const MONTH_ABBR = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
74
+
75
+ // ── Date helpers ──────────────────────────────────────────────────────────────
76
+
77
+ function isSameDay(a: Date, b: Date): boolean {
78
+ return (
79
+ a.getFullYear() === b.getFullYear() &&
80
+ a.getMonth() === b.getMonth() &&
81
+ a.getDate() === b.getDate()
82
+ );
83
+ }
84
+
85
+ function startOfDay(d: Date): Date {
86
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate());
87
+ }
88
+
89
+ function isInRange(d: Date, start: Date | null, end: Date | null): boolean {
90
+ if (!start || !end) return false;
91
+ const t = startOfDay(d).getTime();
92
+ const s = startOfDay(start).getTime();
93
+ const e = startOfDay(end).getTime();
94
+ const lo = Math.min(s, e);
95
+ const hi = Math.max(s, e);
96
+ return t > lo && t < hi;
97
+ }
98
+
99
+ function isRangeEndpoint(d: Date, start: Date | null, end: Date | null): boolean {
100
+ if (start && isSameDay(d, start)) return true;
101
+ if (end && isSameDay(d, end)) return true;
102
+ return false;
103
+ }
104
+
105
+ function formatDate(d: Date | null | undefined, locale = 'en-US'): string {
106
+ if (!d) return '';
107
+ return d.toLocaleDateString(locale, { month: '2-digit', day: '2-digit', year: 'numeric' });
108
+ }
109
+
110
+ function parseDate(str: string): Date | null {
111
+ const match = str.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
112
+ if (!match) return null;
113
+ const [, m, d, y] = match.map(Number);
114
+ if (m < 1 || m > 12 || d < 1 || d > 31) return null;
115
+ const date = new Date(y, m - 1, d);
116
+ if (date.getMonth() !== m - 1) return null;
117
+ return date;
118
+ }
119
+
120
+ function getDaysInMonth(year: number, month: number): number {
121
+ return new Date(year, month + 1, 0).getDate();
122
+ }
123
+
124
+ function getCalendarGrid(year: number, month: number): Date[] {
125
+ const firstDay = new Date(year, month, 1).getDay();
126
+ const daysInMonth = getDaysInMonth(year, month);
127
+ const cells: Date[] = [];
128
+
129
+ // Previous month tail
130
+ const prevDays = getDaysInMonth(year, month - 1);
131
+ for (let i = firstDay - 1; i >= 0; i--) {
132
+ cells.push(new Date(year, month - 1, prevDays - i));
133
+ }
134
+ // Current month
135
+ for (let d = 1; d <= daysInMonth; d++) {
136
+ cells.push(new Date(year, month, d));
137
+ }
138
+ // Next month head
139
+ let nextDay = 1;
140
+ while (cells.length < 42) {
141
+ cells.push(new Date(year, month + 1, nextDay++));
142
+ }
143
+ return cells;
144
+ }
145
+
146
+ function getDecadeStart(year: number): number {
147
+ return Math.floor(year / 10) * 10;
148
+ }
149
+
150
+ // ── Built-in presets ──────────────────────────────────────────────────────────
151
+
152
+ function buildBuiltinPresets(): DatePreset[] {
153
+ return [
154
+ {
155
+ label: 'Today',
156
+ getValue: () => {
157
+ const t = startOfDay(new Date());
158
+ return [t, t];
159
+ },
160
+ },
161
+ {
162
+ label: 'Yesterday',
163
+ getValue: () => {
164
+ const y = startOfDay(new Date());
165
+ y.setDate(y.getDate() - 1);
166
+ return [y, y];
167
+ },
168
+ },
169
+ {
170
+ label: 'Last 7 days',
171
+ getValue: () => {
172
+ const end = startOfDay(new Date());
173
+ const start = new Date(end);
174
+ start.setDate(start.getDate() - 6);
175
+ return [start, end];
176
+ },
177
+ },
178
+ {
179
+ label: 'Last 30 days',
180
+ getValue: () => {
181
+ const end = startOfDay(new Date());
182
+ const start = new Date(end);
183
+ start.setDate(start.getDate() - 29);
184
+ return [start, end];
185
+ },
186
+ },
187
+ {
188
+ label: 'Last 90 days',
189
+ getValue: () => {
190
+ const end = startOfDay(new Date());
191
+ const start = new Date(end);
192
+ start.setDate(start.getDate() - 89);
193
+ return [start, end];
194
+ },
195
+ },
196
+ {
197
+ label: 'This week',
198
+ getValue: () => {
199
+ const today = startOfDay(new Date());
200
+ const start = new Date(today);
201
+ start.setDate(start.getDate() - start.getDay());
202
+ const end = new Date(start);
203
+ end.setDate(end.getDate() + 6);
204
+ return [start, end];
205
+ },
206
+ },
207
+ {
208
+ label: 'Last week',
209
+ getValue: () => {
210
+ const today = startOfDay(new Date());
211
+ const end = new Date(today);
212
+ end.setDate(end.getDate() - end.getDay() - 1);
213
+ const start = new Date(end);
214
+ start.setDate(start.getDate() - 6);
215
+ return [start, end];
216
+ },
217
+ },
218
+ {
219
+ label: 'This month',
220
+ getValue: () => {
221
+ const t = new Date();
222
+ return [
223
+ new Date(t.getFullYear(), t.getMonth(), 1),
224
+ new Date(t.getFullYear(), t.getMonth() + 1, 0),
225
+ ];
226
+ },
227
+ },
228
+ {
229
+ label: 'Last month',
230
+ getValue: () => {
231
+ const t = new Date();
232
+ return [
233
+ new Date(t.getFullYear(), t.getMonth() - 1, 1),
234
+ new Date(t.getFullYear(), t.getMonth(), 0),
235
+ ];
236
+ },
237
+ },
238
+ {
239
+ label: 'This quarter',
240
+ getValue: () => {
241
+ const t = new Date();
242
+ const q = Math.floor(t.getMonth() / 3);
243
+ return [
244
+ new Date(t.getFullYear(), q * 3, 1),
245
+ new Date(t.getFullYear(), q * 3 + 3, 0),
246
+ ];
247
+ },
248
+ },
249
+ {
250
+ label: 'Last quarter',
251
+ getValue: () => {
252
+ const t = new Date();
253
+ const q = Math.floor(t.getMonth() / 3);
254
+ const prevQ = q === 0 ? 3 : q - 1;
255
+ const prevY = q === 0 ? t.getFullYear() - 1 : t.getFullYear();
256
+ return [
257
+ new Date(prevY, prevQ * 3, 1),
258
+ new Date(prevY, prevQ * 3 + 3, 0),
259
+ ];
260
+ },
261
+ },
262
+ {
263
+ label: 'This year',
264
+ getValue: () => {
265
+ const y = new Date().getFullYear();
266
+ return [new Date(y, 0, 1), new Date(y, 11, 31)];
267
+ },
268
+ },
269
+ {
270
+ label: 'Last year',
271
+ getValue: () => {
272
+ const y = new Date().getFullYear() - 1;
273
+ return [new Date(y, 0, 1), new Date(y, 11, 31)];
274
+ },
275
+ },
276
+ ];
277
+ }
278
+
279
+ // ── Portal helper ─────────────────────────────────────────────────────────────
280
+
281
+ interface PopupPosition {
282
+ top: number;
283
+ left: number;
284
+ flipUp: boolean;
285
+ }
286
+
287
+ function getPopupPosition(
288
+ anchorEl: HTMLElement,
289
+ popupHeight: number,
290
+ ): PopupPosition {
291
+ const rect = anchorEl.getBoundingClientRect();
292
+ const viewportH = window.innerHeight;
293
+ const scrollY = window.scrollY;
294
+ const scrollX = window.scrollX;
295
+
296
+ const spaceBelow = viewportH - rect.bottom;
297
+ const flipUp = spaceBelow < popupHeight + 8 && rect.top > popupHeight + 8;
298
+
299
+ return {
300
+ top: flipUp
301
+ ? scrollY + rect.top - popupHeight - 4
302
+ : scrollY + rect.bottom + 4,
303
+ left: scrollX + rect.left,
304
+ flipUp,
305
+ };
306
+ }
307
+
308
+ // ── Time Picker Column ────────────────────────────────────────────────────────
309
+
310
+ interface TimeColumnProps {
311
+ values: number[];
312
+ selected: number;
313
+ onSelect: (v: number) => void;
314
+ label: string;
315
+ theme: ReturnType<typeof import('../themes').useTheme>;
316
+ format?: (v: number) => string;
317
+ }
318
+
319
+ function TimeColumn({ values, selected, onSelect, label, theme, format }: TimeColumnProps) {
320
+ const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
321
+ const containerRef = useRef<HTMLDivElement>(null);
322
+
323
+ useEffect(() => {
324
+ const el = itemRefs.current[selected];
325
+ if (el && containerRef.current) {
326
+ el.scrollIntoView({ block: 'nearest' });
327
+ }
328
+ }, [selected]);
329
+
330
+ const fmt = format ?? ((v: number) => String(v).padStart(2, '0'));
331
+
332
+ return (
333
+ <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '4px' }}>
334
+ <div style={{ fontSize: '11px', fontWeight: 600, color: theme.textMuted, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
335
+ {label}
336
+ </div>
337
+ <div
338
+ ref={containerRef}
339
+ style={{
340
+ height: '160px',
341
+ overflowY: 'auto',
342
+ width: '52px',
343
+ scrollbarWidth: 'thin',
344
+ border: `1px solid ${theme.border}`,
345
+ borderRadius: '8px',
346
+ padding: '4px',
347
+ }}
348
+ >
349
+ {values.map((v) => {
350
+ const isSelected = v === selected;
351
+ return (
352
+ <button
353
+ key={v}
354
+ ref={(el) => { itemRefs.current[v] = el; }}
355
+ type="button"
356
+ onClick={() => onSelect(v)}
357
+ style={{
358
+ display: 'flex',
359
+ alignItems: 'center',
360
+ justifyContent: 'center',
361
+ width: '100%',
362
+ height: '32px',
363
+ borderRadius: '6px',
364
+ border: 'none',
365
+ cursor: 'pointer',
366
+ fontSize: '13px',
367
+ fontWeight: isSelected ? 600 : 400,
368
+ backgroundColor: isSelected ? theme.primary : 'transparent',
369
+ color: isSelected ? theme.bg : theme.text,
370
+ fontFamily: 'monospace',
371
+ transition: 'background-color 100ms ease',
372
+ }}
373
+ >
374
+ {fmt(v)}
375
+ </button>
376
+ );
377
+ })}
378
+ </div>
379
+ </div>
380
+ );
381
+ }
382
+
383
+ // ── Calendar Month Grid ───────────────────────────────────────────────────────
384
+
385
+ interface CalendarMonthProps {
386
+ year: number;
387
+ month: number;
388
+ today: Date;
389
+ mode: DatePickerMode;
390
+ selectedDate: Date | null;
391
+ selectedRange: [Date | null, Date | null];
392
+ multiDates: Date[];
393
+ hoverDate: Date | null;
394
+ focusedDate: Date | null;
395
+ isDateDisabled: (d: Date) => boolean;
396
+ onSelectDate: (d: Date) => void;
397
+ onHoverDate: (d: Date | null) => void;
398
+ onSetFocused: (d: Date | null) => void;
399
+ theme: ReturnType<typeof import('../themes').useTheme>;
400
+ locale: string;
401
+ }
402
+
403
+ function CalendarMonth({
404
+ year,
405
+ month,
406
+ today,
407
+ mode,
408
+ selectedDate,
409
+ selectedRange,
410
+ multiDates,
411
+ hoverDate,
412
+ focusedDate,
413
+ isDateDisabled,
414
+ onSelectDate,
415
+ onHoverDate,
416
+ onSetFocused,
417
+ theme,
418
+ locale,
419
+ }: CalendarMonthProps) {
420
+ const cells = getCalendarGrid(year, month);
421
+
422
+ return (
423
+ <div>
424
+ {/* Day-of-week header */}
425
+ <div
426
+ style={{
427
+ display: 'grid',
428
+ gridTemplateColumns: 'repeat(7, 1fr)',
429
+ gap: '2px',
430
+ marginBottom: '4px',
431
+ }}
432
+ >
433
+ {DAYS_SHORT.map((d) => (
434
+ <div
435
+ key={d}
436
+ style={{
437
+ textAlign: 'center',
438
+ fontSize: '11px',
439
+ fontWeight: 600,
440
+ color: theme.textMuted,
441
+ padding: '4px 0',
442
+ textTransform: 'uppercase',
443
+ letterSpacing: '0.05em',
444
+ }}
445
+ >
446
+ {d}
447
+ </div>
448
+ ))}
449
+ </div>
450
+
451
+ {/* Day grid */}
452
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: '2px' }}>
453
+ {cells.map((cell, idx) => {
454
+ const isCurrentMonth = cell.getMonth() === month;
455
+ const isToday = isSameDay(cell, today);
456
+ const isWeekend = cell.getDay() === 0 || cell.getDay() === 6;
457
+ const disabled = isDateDisabled(cell);
458
+
459
+ const isSelectedSingle = mode === 'single' && selectedDate != null && isSameDay(cell, selectedDate);
460
+ const isMultiSelected = mode === 'multiple' && multiDates.some((d) => isSameDay(d, cell));
461
+ const isRangeStart = mode === 'range' && isRangeEndpoint(cell, selectedRange[0], null);
462
+ const isRangeEnd = mode === 'range' && isRangeEndpoint(cell, null, selectedRange[1]);
463
+ const isRangeEndpt = isRangeStart || isRangeEnd;
464
+
465
+ // Range hover preview
466
+ const effectiveEnd = mode === 'range' && selectedRange[0] && !selectedRange[1] && hoverDate
467
+ ? hoverDate
468
+ : selectedRange[1];
469
+ const inRangeHighlight = mode === 'range' && isInRange(cell, selectedRange[0], effectiveEnd);
470
+ const isHoverEndpoint = mode === 'range' && selectedRange[0] && !selectedRange[1] && hoverDate && isSameDay(cell, hoverDate);
471
+
472
+ const isFullySelected = isSelectedSingle || isMultiSelected || isRangeEndpt;
473
+ const isFocused = focusedDate != null && isSameDay(cell, focusedDate);
474
+
475
+ let bg = 'transparent';
476
+ let textColor = isCurrentMonth
477
+ ? isWeekend ? `${theme.text}cc` : theme.text
478
+ : `${theme.textMuted}60`;
479
+ let borderStyle = 'none';
480
+ let fontWeight = 400;
481
+
482
+ if (isFullySelected || isHoverEndpoint) {
483
+ bg = theme.primary;
484
+ textColor = theme.bg;
485
+ fontWeight = 600;
486
+ } else if (inRangeHighlight) {
487
+ bg = `${theme.primary}18`;
488
+ }
489
+
490
+ if (isToday && !isFullySelected) {
491
+ borderStyle = `2px solid ${theme.primary}`;
492
+ fontWeight = 600;
493
+ }
494
+
495
+ if (disabled) {
496
+ textColor = `${theme.textMuted}50`;
497
+ }
498
+
499
+ if (isFocused && !isFullySelected) {
500
+ borderStyle = `2px solid ${theme.secondary}`;
501
+ }
502
+
503
+ const ariaLabel = cell.toLocaleDateString(locale, {
504
+ weekday: 'long',
505
+ year: 'numeric',
506
+ month: 'long',
507
+ day: 'numeric',
508
+ });
509
+
510
+ return (
511
+ <button
512
+ key={idx}
513
+ type="button"
514
+ aria-label={ariaLabel}
515
+ aria-pressed={isFullySelected}
516
+ aria-disabled={disabled}
517
+ disabled={disabled}
518
+ onClick={() => !disabled && onSelectDate(cell)}
519
+ onMouseEnter={() => onHoverDate(cell)}
520
+ onMouseLeave={() => onHoverDate(null)}
521
+ onFocus={() => onSetFocused(cell)}
522
+ style={{
523
+ width: '34px',
524
+ height: '34px',
525
+ borderRadius: '8px',
526
+ backgroundColor: bg,
527
+ color: textColor,
528
+ border: borderStyle,
529
+ fontSize: '13px',
530
+ fontWeight,
531
+ cursor: disabled ? 'not-allowed' : 'pointer',
532
+ opacity: disabled ? 0.35 : 1,
533
+ display: 'flex',
534
+ alignItems: 'center',
535
+ justifyContent: 'center',
536
+ fontFamily: 'inherit',
537
+ position: 'relative',
538
+ outline: 'none',
539
+ textDecoration: disabled ? 'line-through' : 'none',
540
+ transition: 'background-color 80ms ease',
541
+ }}
542
+ >
543
+ {cell.getDate()}
544
+ </button>
545
+ );
546
+ })}
547
+ </div>
548
+ </div>
549
+ );
550
+ }
551
+
552
+ // ── Main Component ────────────────────────────────────────────────────────────
553
+
554
+ export function TkxDatePicker({
555
+ value,
556
+ onChange,
557
+ rangeValue,
558
+ onRangeChange,
559
+ multiValue,
560
+ onMultiChange,
561
+ mode = 'single',
562
+ minDate,
563
+ maxDate,
564
+ disabledDates,
565
+ locale = 'en-US',
566
+ showTime = false,
567
+ timeValue,
568
+ onTimeChange,
569
+ showPresets = false,
570
+ customPresets,
571
+ label,
572
+ placeholder = 'MM/DD/YYYY',
573
+ hint,
574
+ isDisabled = false,
575
+ isInvalid = false,
576
+ errorMessage,
577
+ numberOfMonths = 1,
578
+ id: idProp,
579
+ className,
580
+ style,
581
+ }: TkxDatePickerProps) {
582
+ const theme = useTheme();
583
+ const autoId = useId();
584
+ const id = idProp ?? autoId;
585
+ const reduced = useReducedMotion();
586
+
587
+ const today = startOfDay(new Date());
588
+
589
+ // ── State ────────────────────────────────────────────────────────────────────
590
+
591
+ // Controlled/uncontrolled single
592
+ const isSingleControlled = value !== undefined;
593
+ const [internalDate, setInternalDate] = useState<Date | null>(null);
594
+ const selectedDate: Date | null = isSingleControlled ? (value ?? null) : internalDate;
595
+
596
+ // Controlled/uncontrolled range
597
+ const isRangeControlled = rangeValue !== undefined;
598
+ const [internalRange, setInternalRange] = useState<[Date | null, Date | null]>([null, null]);
599
+ const selectedRange: [Date | null, Date | null] = isRangeControlled ? rangeValue! : internalRange;
600
+
601
+ // Controlled/uncontrolled multi
602
+ const isMultiControlled = multiValue !== undefined;
603
+ const [internalMulti, setInternalMulti] = useState<Date[]>([]);
604
+ const multiDates: Date[] = isMultiControlled ? multiValue! : internalMulti;
605
+
606
+ // Range picking phase
607
+ const [rangePicking, setRangePicking] = useState<'start' | 'end'>('start');
608
+
609
+ // Pending range/time (apply/cancel mode)
610
+ const needsApply = mode === 'range' || (mode === 'single' && showTime);
611
+ const [pendingRange, setPendingRange] = useState<[Date | null, Date | null]>([null, null]);
612
+ const [pendingDate, setPendingDate] = useState<Date | null>(null);
613
+
614
+ // Calendar view
615
+ const initRef = selectedDate ?? selectedRange[0] ?? today;
616
+ const [viewYear, setViewYear] = useState(initRef.getFullYear());
617
+ const [viewMonth, setViewMonth] = useState(initRef.getMonth());
618
+ const [calView, setCalView] = useState<DatePickerView>('day');
619
+
620
+ // Time
621
+ const [internalTime, setInternalTime] = useState<{ h: number; m: number }>({ h: 0, m: 0 });
622
+ const currentTime = timeValue ?? internalTime;
623
+
624
+ // UI
625
+ const [open, setOpen] = useState(false);
626
+ const [inputValue, setInputValue] = useState(() => {
627
+ if (mode === 'single') return formatDate(selectedDate, locale);
628
+ if (mode === 'range') {
629
+ const [s, e] = selectedRange;
630
+ if (s && e) return `${formatDate(s, locale)} – ${formatDate(e, locale)}`;
631
+ }
632
+ return '';
633
+ });
634
+ const [focusedDate, setFocusedDate] = useState<Date | null>(null);
635
+ const [hoverDate, setHoverDate] = useState<Date | null>(null);
636
+ const [popupPos, setPopupPos] = useState<PopupPosition>({ top: 0, left: 0, flipUp: false });
637
+ const [, forceUpdate] = useReducer((x: number) => x + 1, 0);
638
+
639
+ const anchorRef = useRef<HTMLDivElement>(null);
640
+ const popupRef = useRef<HTMLDivElement>(null);
641
+ const inputRef = useRef<HTMLInputElement>(null);
642
+
643
+ const POPUP_ESTIMATED_HEIGHT = showPresets ? 420 : showTime ? 480 : 360;
644
+
645
+ // ── Sync input value ─────────────────────────────────────────────────────────
646
+
647
+ useEffect(() => {
648
+ if (mode === 'single') {
649
+ setInputValue(formatDate(selectedDate, locale));
650
+ } else if (mode === 'range') {
651
+ const [s, e] = selectedRange;
652
+ if (s && e) {
653
+ setInputValue(`${formatDate(s, locale)} – ${formatDate(e, locale)}`);
654
+ } else if (s) {
655
+ setInputValue(formatDate(s, locale));
656
+ } else {
657
+ setInputValue('');
658
+ }
659
+ } else if (mode === 'multiple') {
660
+ setInputValue(multiDates.length > 0 ? `${multiDates.length} date${multiDates.length !== 1 ? 's' : ''} selected` : '');
661
+ }
662
+ // eslint-disable-next-line react-hooks/exhaustive-deps
663
+ }, [selectedDate, selectedRange, multiDates, mode, locale]);
664
+
665
+ // ── Position popup ───────────────────────────────────────────────────────────
666
+
667
+ const updatePosition = useCallback(() => {
668
+ if (!anchorRef.current) return;
669
+ setPopupPos(getPopupPosition(anchorRef.current, POPUP_ESTIMATED_HEIGHT));
670
+ }, [POPUP_ESTIMATED_HEIGHT]);
671
+
672
+ useEffect(() => {
673
+ if (!open) return;
674
+ updatePosition();
675
+ window.addEventListener('scroll', updatePosition, true);
676
+ window.addEventListener('resize', updatePosition);
677
+ return () => {
678
+ window.removeEventListener('scroll', updatePosition, true);
679
+ window.removeEventListener('resize', updatePosition);
680
+ };
681
+ }, [open, updatePosition]);
682
+
683
+ // ── Outside click ────────────────────────────────────────────────────────────
684
+
685
+ useEffect(() => {
686
+ if (!open) return;
687
+ const handler = (e: PointerEvent) => {
688
+ if (
689
+ anchorRef.current?.contains(e.target as Node) ||
690
+ popupRef.current?.contains(e.target as Node)
691
+ ) return;
692
+ setOpen(false);
693
+ };
694
+ document.addEventListener('pointerdown', handler);
695
+ return () => document.removeEventListener('pointerdown', handler);
696
+ }, [open]);
697
+
698
+ // ── Escape key ───────────────────────────────────────────────────────────────
699
+
700
+ useEffect(() => {
701
+ if (!open) return;
702
+ const handler = (e: globalThis.KeyboardEvent) => {
703
+ if (e.key === 'Escape') {
704
+ setOpen(false);
705
+ inputRef.current?.focus();
706
+ }
707
+ };
708
+ document.addEventListener('keydown', handler);
709
+ return () => document.removeEventListener('keydown', handler);
710
+ }, [open]);
711
+
712
+ // ── isDateDisabled ────────────────────────────────────────────────────────────
713
+
714
+ const isDateDisabled = useCallback(
715
+ (d: Date): boolean => {
716
+ if (minDate && startOfDay(d) < startOfDay(minDate)) return true;
717
+ if (maxDate && startOfDay(d) > startOfDay(maxDate)) return true;
718
+ if (!disabledDates) return false;
719
+ if (typeof disabledDates === 'function') return disabledDates(d);
720
+ return disabledDates.some((dd) => isSameDay(dd, d));
721
+ },
722
+ [minDate, maxDate, disabledDates]
723
+ );
724
+
725
+ // ── Select date logic ─────────────────────────────────────────────────────────
726
+
727
+ const selectDate = useCallback(
728
+ (d: Date) => {
729
+ if (isDateDisabled(d)) return;
730
+
731
+ if (mode === 'single') {
732
+ if (needsApply) {
733
+ setPendingDate(d);
734
+ } else {
735
+ if (!isSingleControlled) setInternalDate(d);
736
+ onChange?.(d);
737
+ setInputValue(formatDate(d, locale));
738
+ setOpen(false);
739
+ }
740
+ } else if (mode === 'range') {
741
+ if (rangePicking === 'start') {
742
+ const newRange: [Date, null] = [d, null];
743
+ setPendingRange(newRange);
744
+ if (!isRangeControlled) setInternalRange(newRange);
745
+ onRangeChange?.(newRange);
746
+ setRangePicking('end');
747
+ } else {
748
+ const start = pendingRange[0] ?? selectedRange[0];
749
+ const ordered: [Date | null, Date | null] =
750
+ start && d < start ? [d, start] : [start, d];
751
+ setPendingRange(ordered);
752
+ if (!needsApply) {
753
+ if (!isRangeControlled) setInternalRange(ordered);
754
+ onRangeChange?.(ordered);
755
+ setRangePicking('start');
756
+ setOpen(false);
757
+ } else {
758
+ setRangePicking('start');
759
+ }
760
+ }
761
+ } else if (mode === 'multiple') {
762
+ const exists = multiDates.some((md) => isSameDay(md, d));
763
+ const next = exists
764
+ ? multiDates.filter((md) => !isSameDay(md, d))
765
+ : [...multiDates, d];
766
+ if (!isMultiControlled) setInternalMulti(next);
767
+ onMultiChange?.(next);
768
+ }
769
+
770
+ forceUpdate();
771
+ },
772
+ // eslint-disable-next-line react-hooks/exhaustive-deps
773
+ [mode, isDateDisabled, isSingleControlled, isRangeControlled, isMultiControlled,
774
+ onChange, onRangeChange, onMultiChange, rangePicking, pendingRange, selectedRange,
775
+ multiDates, needsApply, locale]
776
+ );
777
+
778
+ // ── Apply / Cancel ────────────────────────────────────────────────────────────
779
+
780
+ const handleApply = useCallback(() => {
781
+ if (mode === 'single') {
782
+ const d = pendingDate ?? selectedDate;
783
+ if (!isSingleControlled) setInternalDate(d);
784
+ onChange?.(d);
785
+ } else if (mode === 'range') {
786
+ if (!isRangeControlled) setInternalRange(pendingRange);
787
+ onRangeChange?.(pendingRange);
788
+ }
789
+ setOpen(false);
790
+ setRangePicking('start');
791
+ }, [mode, pendingDate, pendingRange, selectedDate, isSingleControlled, isRangeControlled,
792
+ onChange, onRangeChange]);
793
+
794
+ const handleCancel = useCallback(() => {
795
+ setPendingDate(null);
796
+ setPendingRange([null, null]);
797
+ setRangePicking('start');
798
+ setOpen(false);
799
+ }, []);
800
+
801
+ // ── Navigation ────────────────────────────────────────────────────────────────
802
+
803
+ const prevMonth = () => {
804
+ if (viewMonth === 0) { setViewMonth(11); setViewYear((y) => y - 1); }
805
+ else setViewMonth((m) => m - 1);
806
+ setCalView('day');
807
+ };
808
+ const nextMonth = () => {
809
+ if (viewMonth === 11) { setViewMonth(0); setViewYear((y) => y + 1); }
810
+ else setViewMonth((m) => m + 1);
811
+ setCalView('day');
812
+ };
813
+ const prevYear = () => setViewYear((y) => y - 1);
814
+ const nextYear = () => setViewYear((y) => y + 1);
815
+ const prevDecade = () => setViewYear((y) => y - 10);
816
+ const nextDecade = () => setViewYear((y) => y + 10);
817
+
818
+ // ── Input manual typing ───────────────────────────────────────────────────────
819
+
820
+ const handleInputChange = (v: string) => {
821
+ setInputValue(v);
822
+ if (mode === 'single') {
823
+ const parsed = parseDate(v);
824
+ if (parsed && !isDateDisabled(parsed)) {
825
+ if (!isSingleControlled) setInternalDate(parsed);
826
+ onChange?.(parsed);
827
+ setViewYear(parsed.getFullYear());
828
+ setViewMonth(parsed.getMonth());
829
+ }
830
+ }
831
+ };
832
+
833
+ // ── Clear ─────────────────────────────────────────────────────────────────────
834
+
835
+ const handleClear = (e: React.MouseEvent) => {
836
+ e.stopPropagation();
837
+ if (mode === 'single') {
838
+ if (!isSingleControlled) setInternalDate(null);
839
+ onChange?.(null);
840
+ setInputValue('');
841
+ } else if (mode === 'range') {
842
+ const empty: [null, null] = [null, null];
843
+ if (!isRangeControlled) setInternalRange(empty);
844
+ onRangeChange?.(empty);
845
+ setPendingRange(empty);
846
+ setInputValue('');
847
+ setRangePicking('start');
848
+ } else if (mode === 'multiple') {
849
+ if (!isMultiControlled) setInternalMulti([]);
850
+ onMultiChange?.([]);
851
+ setInputValue('');
852
+ }
853
+ };
854
+
855
+ // ── Keyboard navigation in calendar ──────────────────────────────────────────
856
+
857
+ const handleCalendarKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
858
+ if (calView !== 'day') return;
859
+ const base = focusedDate ?? selectedDate ?? today;
860
+ let next: Date | null = null;
861
+
862
+ if (e.key === 'ArrowRight') next = new Date(base.getFullYear(), base.getMonth(), base.getDate() + 1);
863
+ else if (e.key === 'ArrowLeft') next = new Date(base.getFullYear(), base.getMonth(), base.getDate() - 1);
864
+ else if (e.key === 'ArrowDown') next = new Date(base.getFullYear(), base.getMonth(), base.getDate() + 7);
865
+ else if (e.key === 'ArrowUp') next = new Date(base.getFullYear(), base.getMonth(), base.getDate() - 7);
866
+ else if (e.key === 'Enter' && focusedDate) { selectDate(focusedDate); return; }
867
+
868
+ if (next) {
869
+ e.preventDefault();
870
+ setFocusedDate(next);
871
+ if (next.getMonth() !== viewMonth || next.getFullYear() !== viewYear) {
872
+ setViewMonth(next.getMonth());
873
+ setViewYear(next.getFullYear());
874
+ }
875
+ }
876
+ };
877
+
878
+ // ── Today button ──────────────────────────────────────────────────────────────
879
+
880
+ const goToToday = () => {
881
+ setViewYear(today.getFullYear());
882
+ setViewMonth(today.getMonth());
883
+ setCalView('day');
884
+ selectDate(today);
885
+ };
886
+
887
+ // ── Preset selection ──────────────────────────────────────────────────────────
888
+
889
+ const allPresets = customPresets ?? buildBuiltinPresets();
890
+
891
+ const applyPreset = (preset: DatePreset) => {
892
+ const [start, end] = preset.getValue();
893
+ const range: [Date, Date] = [start, end];
894
+ setPendingRange(range);
895
+ if (!isRangeControlled) setInternalRange(range);
896
+ onRangeChange?.(range);
897
+ setViewYear(start.getFullYear());
898
+ setViewMonth(start.getMonth());
899
+ setRangePicking('start');
900
+ if (!needsApply) setOpen(false);
901
+ };
902
+
903
+ // ── Second month for dual view ─────────────────────────────────────────────
904
+
905
+ const month2 = viewMonth === 11 ? 0 : viewMonth + 1;
906
+ const year2 = viewMonth === 11 ? viewYear + 1 : viewYear;
907
+
908
+ const dualView = numberOfMonths >= 2;
909
+
910
+ // ── Sanitize strings ──────────────────────────────────────────────────────────
911
+
912
+ const safeLabel = label ? sanitizeString(label) : undefined;
913
+ const safeHint = hint ? sanitizeString(hint) : undefined;
914
+ const safeError = errorMessage ? sanitizeString(errorMessage) : undefined;
915
+ const safeHolder = sanitizeString(placeholder);
916
+
917
+ // ── Has value? ────────────────────────────────────────────────────────────────
918
+
919
+ const hasValue =
920
+ (mode === 'single' && selectedDate != null) ||
921
+ (mode === 'range' && (selectedRange[0] != null || selectedRange[1] != null)) ||
922
+ (mode === 'multiple' && multiDates.length > 0);
923
+
924
+ // ── Display range for calendar ────────────────────────────────────────────────
925
+
926
+ const displayRange: [Date | null, Date | null] =
927
+ mode === 'range'
928
+ ? (needsApply ? pendingRange : selectedRange)
929
+ : [null, null];
930
+
931
+ // ── Render ────────────────────────────────────────────────────────────────────
932
+
933
+ const borderColor = isInvalid ? theme.danger : open ? theme.primary : theme.border;
934
+
935
+ const hours = Array.from({ length: 24 }, (_, i) => i);
936
+ const minutes = Array.from({ length: 60 }, (_, i) => i);
937
+
938
+ const popupContent = (
939
+ <div
940
+ ref={popupRef}
941
+ role="dialog"
942
+ aria-label="Date picker"
943
+ aria-modal="false"
944
+ onKeyDown={handleCalendarKeyDown}
945
+ style={{
946
+ position: 'absolute',
947
+ top: popupPos.top,
948
+ left: popupPos.left,
949
+ zIndex: 9999,
950
+ backgroundColor: theme.surface,
951
+ border: `1px solid ${theme.border}`,
952
+ borderRadius: '14px',
953
+ boxShadow: `0 8px 30px rgba(0,0,0,0.15), 0 2px 8px rgba(0,0,0,0.1)`,
954
+ display: 'flex',
955
+ overflow: 'hidden',
956
+ minWidth: dualView ? '580px' : '300px',
957
+ maxWidth: dualView && showPresets ? '780px' : dualView ? '620px' : showPresets ? '520px' : '300px',
958
+ opacity: open ? 1 : 0,
959
+ transform: open
960
+ ? 'translateY(0) scale(1)'
961
+ : popupPos.flipUp
962
+ ? 'translateY(4px) scale(0.98)'
963
+ : 'translateY(-4px) scale(0.98)',
964
+ transition: reduced ? 'none' : 'opacity 120ms ease, transform 120ms ease',
965
+ fontFamily: 'inherit',
966
+ }}
967
+ >
968
+ {/* Presets sidebar */}
969
+ {showPresets && mode === 'range' && (
970
+ <div
971
+ style={{
972
+ width: '148px',
973
+ flexShrink: 0,
974
+ borderRight: `1px solid ${theme.border}`,
975
+ padding: '8px 6px',
976
+ overflowY: 'auto',
977
+ display: 'flex',
978
+ flexDirection: 'column',
979
+ gap: '1px',
980
+ }}
981
+ >
982
+ <div
983
+ style={{
984
+ fontSize: '11px',
985
+ fontWeight: 600,
986
+ color: theme.textMuted,
987
+ textTransform: 'uppercase',
988
+ letterSpacing: '0.07em',
989
+ padding: '4px 8px 8px',
990
+ }}
991
+ >
992
+ Quick select
993
+ </div>
994
+ {allPresets.map((preset) => {
995
+ const [pStart, pEnd] = preset.getValue();
996
+ const isActive =
997
+ displayRange[0] && displayRange[1] &&
998
+ isSameDay(displayRange[0], pStart) &&
999
+ isSameDay(displayRange[1], pEnd);
1000
+ return (
1001
+ <button
1002
+ key={preset.label}
1003
+ type="button"
1004
+ onClick={() => applyPreset(preset)}
1005
+ style={{
1006
+ width: '100%',
1007
+ textAlign: 'left',
1008
+ padding: '7px 10px',
1009
+ borderRadius: '7px',
1010
+ border: 'none',
1011
+ cursor: 'pointer',
1012
+ fontSize: '13px',
1013
+ backgroundColor: isActive ? `${theme.primary}20` : 'transparent',
1014
+ color: isActive ? theme.primary : theme.text,
1015
+ fontWeight: isActive ? 600 : 400,
1016
+ fontFamily: 'inherit',
1017
+ transition: 'background-color 80ms ease',
1018
+ }}
1019
+ onMouseEnter={(e) => {
1020
+ if (!isActive) (e.currentTarget as HTMLButtonElement).style.backgroundColor = `${theme.surfaceAlt}`;
1021
+ }}
1022
+ onMouseLeave={(e) => {
1023
+ if (!isActive) (e.currentTarget as HTMLButtonElement).style.backgroundColor = 'transparent';
1024
+ }}
1025
+ >
1026
+ {preset.label}
1027
+ </button>
1028
+ );
1029
+ })}
1030
+ </div>
1031
+ )}
1032
+
1033
+ {/* Calendar area */}
1034
+ <div style={{ flex: 1, padding: '16px', display: 'flex', flexDirection: 'column', gap: '12px', minWidth: 0 }}>
1035
+ {/* Header navigation */}
1036
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
1037
+ {/* Prev button */}
1038
+ <button
1039
+ type="button"
1040
+ aria-label={calView === 'day' ? 'Previous month' : calView === 'month' ? 'Previous year' : 'Previous decade'}
1041
+ onClick={() => {
1042
+ if (calView === 'day') prevMonth();
1043
+ else if (calView === 'month') prevYear();
1044
+ else prevDecade();
1045
+ }}
1046
+ style={{
1047
+ border: 'none',
1048
+ background: 'transparent',
1049
+ cursor: 'pointer',
1050
+ color: theme.textMuted,
1051
+ padding: '6px',
1052
+ borderRadius: '6px',
1053
+ display: 'flex',
1054
+ alignItems: 'center',
1055
+ }}
1056
+ >
1057
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
1058
+ <polyline points="15 18 9 12 15 6" />
1059
+ </svg>
1060
+ </button>
1061
+
1062
+ {/* Breadcrumb: "April 2026" */}
1063
+ <div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
1064
+ {calView === 'day' && (
1065
+ <>
1066
+ <button
1067
+ type="button"
1068
+ onClick={() => setCalView('month')}
1069
+ style={{
1070
+ border: 'none',
1071
+ background: 'transparent',
1072
+ cursor: 'pointer',
1073
+ fontSize: '14px',
1074
+ fontWeight: 600,
1075
+ color: theme.text,
1076
+ padding: '4px 6px',
1077
+ borderRadius: '6px',
1078
+ fontFamily: 'inherit',
1079
+ }}
1080
+ >
1081
+ {MONTH_NAMES[viewMonth]}
1082
+ </button>
1083
+ <button
1084
+ type="button"
1085
+ onClick={() => setCalView('year')}
1086
+ style={{
1087
+ border: 'none',
1088
+ background: 'transparent',
1089
+ cursor: 'pointer',
1090
+ fontSize: '14px',
1091
+ fontWeight: 600,
1092
+ color: theme.text,
1093
+ padding: '4px 6px',
1094
+ borderRadius: '6px',
1095
+ fontFamily: 'inherit',
1096
+ }}
1097
+ >
1098
+ {viewYear}
1099
+ </button>
1100
+ {dualView && (
1101
+ <>
1102
+ <span style={{ color: theme.textMuted, fontSize: '14px' }}>–</span>
1103
+ <span style={{ fontSize: '14px', fontWeight: 600, color: theme.text }}>
1104
+ {MONTH_NAMES[month2]} {year2}
1105
+ </span>
1106
+ </>
1107
+ )}
1108
+ </>
1109
+ )}
1110
+ {calView === 'month' && (
1111
+ <button
1112
+ type="button"
1113
+ onClick={() => setCalView('year')}
1114
+ style={{
1115
+ border: 'none',
1116
+ background: 'transparent',
1117
+ cursor: 'pointer',
1118
+ fontSize: '14px',
1119
+ fontWeight: 600,
1120
+ color: theme.text,
1121
+ padding: '4px 6px',
1122
+ borderRadius: '6px',
1123
+ fontFamily: 'inherit',
1124
+ }}
1125
+ >
1126
+ {viewYear}
1127
+ </button>
1128
+ )}
1129
+ {calView === 'year' && (
1130
+ <span style={{ fontSize: '14px', fontWeight: 600, color: theme.text }}>
1131
+ {getDecadeStart(viewYear)}–{getDecadeStart(viewYear) + 9}
1132
+ </span>
1133
+ )}
1134
+ </div>
1135
+
1136
+ {/* Next button */}
1137
+ <button
1138
+ type="button"
1139
+ aria-label={calView === 'day' ? 'Next month' : calView === 'month' ? 'Next year' : 'Next decade'}
1140
+ onClick={() => {
1141
+ if (calView === 'day') nextMonth();
1142
+ else if (calView === 'month') nextYear();
1143
+ else nextDecade();
1144
+ }}
1145
+ style={{
1146
+ border: 'none',
1147
+ background: 'transparent',
1148
+ cursor: 'pointer',
1149
+ color: theme.textMuted,
1150
+ padding: '6px',
1151
+ borderRadius: '6px',
1152
+ display: 'flex',
1153
+ alignItems: 'center',
1154
+ }}
1155
+ >
1156
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
1157
+ <polyline points="9 18 15 12 9 6" />
1158
+ </svg>
1159
+ </button>
1160
+ </div>
1161
+
1162
+ {/* View: Day */}
1163
+ {calView === 'day' && (
1164
+ <div style={{ display: 'flex', gap: '20px' }}>
1165
+ <CalendarMonth
1166
+ year={viewYear}
1167
+ month={viewMonth}
1168
+ today={today}
1169
+ mode={mode}
1170
+ selectedDate={selectedDate}
1171
+ selectedRange={displayRange}
1172
+ multiDates={multiDates}
1173
+ hoverDate={hoverDate}
1174
+ focusedDate={focusedDate}
1175
+ isDateDisabled={isDateDisabled}
1176
+ onSelectDate={selectDate}
1177
+ onHoverDate={setHoverDate}
1178
+ onSetFocused={setFocusedDate}
1179
+ theme={theme}
1180
+ locale={locale}
1181
+ />
1182
+ {dualView && (
1183
+ <CalendarMonth
1184
+ year={year2}
1185
+ month={month2}
1186
+ today={today}
1187
+ mode={mode}
1188
+ selectedDate={selectedDate}
1189
+ selectedRange={displayRange}
1190
+ multiDates={multiDates}
1191
+ hoverDate={hoverDate}
1192
+ focusedDate={focusedDate}
1193
+ isDateDisabled={isDateDisabled}
1194
+ onSelectDate={selectDate}
1195
+ onHoverDate={setHoverDate}
1196
+ onSetFocused={setFocusedDate}
1197
+ theme={theme}
1198
+ locale={locale}
1199
+ />
1200
+ )}
1201
+ </div>
1202
+ )}
1203
+
1204
+ {/* View: Month picker */}
1205
+ {calView === 'month' && (
1206
+ <div
1207
+ style={{
1208
+ display: 'grid',
1209
+ gridTemplateColumns: 'repeat(3, 1fr)',
1210
+ gap: '6px',
1211
+ }}
1212
+ >
1213
+ {MONTH_ABBR.map((name, idx) => {
1214
+ const isCurrentViewMonth = idx === viewMonth;
1215
+ const isTodayMonth = idx === today.getMonth() && viewYear === today.getFullYear();
1216
+ return (
1217
+ <button
1218
+ key={name}
1219
+ type="button"
1220
+ onClick={() => {
1221
+ setViewMonth(idx);
1222
+ setCalView('day');
1223
+ }}
1224
+ style={{
1225
+ padding: '10px',
1226
+ borderRadius: '8px',
1227
+ border: isTodayMonth && !isCurrentViewMonth ? `2px solid ${theme.primary}` : 'none',
1228
+ cursor: 'pointer',
1229
+ fontSize: '13px',
1230
+ fontWeight: isCurrentViewMonth ? 600 : 400,
1231
+ backgroundColor: isCurrentViewMonth ? theme.primary : 'transparent',
1232
+ color: isCurrentViewMonth ? theme.bg : theme.text,
1233
+ fontFamily: 'inherit',
1234
+ transition: 'background-color 80ms ease',
1235
+ }}
1236
+ >
1237
+ {name}
1238
+ </button>
1239
+ );
1240
+ })}
1241
+ </div>
1242
+ )}
1243
+
1244
+ {/* View: Year picker (decade grid) */}
1245
+ {calView === 'year' && (
1246
+ <div
1247
+ style={{
1248
+ display: 'grid',
1249
+ gridTemplateColumns: 'repeat(4, 1fr)',
1250
+ gap: '6px',
1251
+ }}
1252
+ >
1253
+ {Array.from({ length: 12 }, (_, i) => {
1254
+ const yr = getDecadeStart(viewYear) - 1 + i;
1255
+ const isCurrentYear = yr === viewYear;
1256
+ const isTodayYear = yr === today.getFullYear();
1257
+ const isOutsideDecade = i === 0 || i === 11;
1258
+ return (
1259
+ <button
1260
+ key={yr}
1261
+ type="button"
1262
+ onClick={() => {
1263
+ setViewYear(yr);
1264
+ setCalView('month');
1265
+ }}
1266
+ style={{
1267
+ padding: '10px 6px',
1268
+ borderRadius: '8px',
1269
+ border: isTodayYear && !isCurrentYear ? `2px solid ${theme.primary}` : 'none',
1270
+ cursor: 'pointer',
1271
+ fontSize: '13px',
1272
+ fontWeight: isCurrentYear ? 600 : 400,
1273
+ backgroundColor: isCurrentYear ? theme.primary : 'transparent',
1274
+ color: isCurrentYear ? theme.bg : isOutsideDecade ? theme.textMuted : theme.text,
1275
+ fontFamily: 'inherit',
1276
+ transition: 'background-color 80ms ease',
1277
+ opacity: isOutsideDecade ? 0.5 : 1,
1278
+ }}
1279
+ >
1280
+ {yr}
1281
+ </button>
1282
+ );
1283
+ })}
1284
+ </div>
1285
+ )}
1286
+
1287
+ {/* Time picker */}
1288
+ {showTime && calView === 'day' && (
1289
+ <div
1290
+ style={{
1291
+ borderTop: `1px solid ${theme.border}`,
1292
+ paddingTop: '12px',
1293
+ display: 'flex',
1294
+ alignItems: 'flex-start',
1295
+ gap: '8px',
1296
+ justifyContent: 'center',
1297
+ }}
1298
+ >
1299
+ <TimeColumn
1300
+ values={hours}
1301
+ selected={currentTime.h}
1302
+ onSelect={(h) => {
1303
+ if (!timeValue) setInternalTime((t) => ({ ...t, h }));
1304
+ onTimeChange?.({ ...currentTime, h });
1305
+ }}
1306
+ label="Hour"
1307
+ theme={theme}
1308
+ />
1309
+ <div style={{ display: 'flex', alignItems: 'center', height: '40px', marginTop: '28px', fontSize: '16px', fontWeight: 700, color: theme.textMuted }}>
1310
+ :
1311
+ </div>
1312
+ <TimeColumn
1313
+ values={minutes}
1314
+ selected={currentTime.m}
1315
+ onSelect={(m) => {
1316
+ if (!timeValue) setInternalTime((t) => ({ ...t, m }));
1317
+ onTimeChange?.({ ...currentTime, m });
1318
+ }}
1319
+ label="Min"
1320
+ theme={theme}
1321
+ />
1322
+ </div>
1323
+ )}
1324
+
1325
+ {/* Multiple selection count */}
1326
+ {mode === 'multiple' && calView === 'day' && multiDates.length > 0 && (
1327
+ <div
1328
+ style={{
1329
+ borderTop: `1px solid ${theme.border}`,
1330
+ paddingTop: '8px',
1331
+ fontSize: '12px',
1332
+ color: theme.textMuted,
1333
+ textAlign: 'center',
1334
+ }}
1335
+ >
1336
+ {multiDates.length} date{multiDates.length !== 1 ? 's' : ''} selected
1337
+ </div>
1338
+ )}
1339
+
1340
+ {/* Footer */}
1341
+ <div
1342
+ style={{
1343
+ borderTop: `1px solid ${theme.border}`,
1344
+ paddingTop: '10px',
1345
+ display: 'flex',
1346
+ alignItems: 'center',
1347
+ justifyContent: needsApply ? 'space-between' : 'flex-start',
1348
+ gap: '8px',
1349
+ }}
1350
+ >
1351
+ {/* Today button */}
1352
+ <button
1353
+ type="button"
1354
+ onClick={goToToday}
1355
+ style={{
1356
+ border: 'none',
1357
+ background: 'transparent',
1358
+ cursor: 'pointer',
1359
+ fontSize: '13px',
1360
+ color: theme.primary,
1361
+ fontWeight: 500,
1362
+ padding: '6px 10px',
1363
+ borderRadius: '6px',
1364
+ fontFamily: 'inherit',
1365
+ }}
1366
+ >
1367
+ Today
1368
+ </button>
1369
+
1370
+ {/* Apply / Cancel */}
1371
+ {needsApply && (
1372
+ <div style={{ display: 'flex', gap: '6px' }}>
1373
+ <button
1374
+ type="button"
1375
+ onClick={handleCancel}
1376
+ style={{
1377
+ padding: '6px 14px',
1378
+ borderRadius: '7px',
1379
+ border: `1px solid ${theme.border}`,
1380
+ cursor: 'pointer',
1381
+ fontSize: '13px',
1382
+ fontWeight: 500,
1383
+ backgroundColor: 'transparent',
1384
+ color: theme.text,
1385
+ fontFamily: 'inherit',
1386
+ }}
1387
+ >
1388
+ Cancel
1389
+ </button>
1390
+ <button
1391
+ type="button"
1392
+ onClick={handleApply}
1393
+ style={{
1394
+ padding: '6px 14px',
1395
+ borderRadius: '7px',
1396
+ border: 'none',
1397
+ cursor: 'pointer',
1398
+ fontSize: '13px',
1399
+ fontWeight: 600,
1400
+ backgroundColor: theme.primary,
1401
+ color: theme.bg,
1402
+ fontFamily: 'inherit',
1403
+ }}
1404
+ >
1405
+ Apply
1406
+ </button>
1407
+ </div>
1408
+ )}
1409
+ </div>
1410
+ </div>
1411
+ </div>
1412
+ );
1413
+
1414
+ return (
1415
+ <div
1416
+ className={className}
1417
+ style={{
1418
+ display: 'flex',
1419
+ flexDirection: 'column',
1420
+ gap: '4px',
1421
+ width: '100%',
1422
+ position: 'relative',
1423
+ ...style,
1424
+ }}
1425
+ >
1426
+ {/* Label */}
1427
+ {safeLabel && (
1428
+ <label
1429
+ htmlFor={id}
1430
+ style={{ fontSize: '13px', fontWeight: 500, color: theme.text, marginBottom: '2px' }}
1431
+ >
1432
+ {safeLabel}
1433
+ </label>
1434
+ )}
1435
+
1436
+ {/* Input field */}
1437
+ <div
1438
+ ref={anchorRef}
1439
+ style={{
1440
+ display: 'flex',
1441
+ alignItems: 'center',
1442
+ border: `1.5px solid ${borderColor}`,
1443
+ borderRadius: '8px',
1444
+ backgroundColor: theme.surface,
1445
+ overflow: 'hidden',
1446
+ transition: reduced ? 'none' : 'border-color 120ms ease',
1447
+ opacity: isDisabled ? 0.5 : 1,
1448
+ }}
1449
+ >
1450
+ {/* Calendar icon */}
1451
+ <span
1452
+ style={{
1453
+ display: 'flex',
1454
+ alignItems: 'center',
1455
+ paddingLeft: '10px',
1456
+ color: theme.textMuted,
1457
+ flexShrink: 0,
1458
+ }}
1459
+ >
1460
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
1461
+ <rect x="3" y="4" width="18" height="18" rx="2" /><line x1="16" y1="2" x2="16" y2="6" /><line x1="8" y1="2" x2="8" y2="6" /><line x1="3" y1="10" x2="21" y2="10" />
1462
+ </svg>
1463
+ </span>
1464
+
1465
+ <input
1466
+ ref={inputRef}
1467
+ id={id}
1468
+ type="text"
1469
+ value={inputValue}
1470
+ placeholder={safeHolder}
1471
+ disabled={isDisabled}
1472
+ aria-invalid={isInvalid}
1473
+ aria-haspopup="dialog"
1474
+ aria-expanded={open}
1475
+ readOnly={mode === 'range' || mode === 'multiple'}
1476
+ onChange={(e) => handleInputChange(e.target.value)}
1477
+ onFocus={() => {
1478
+ if (!isDisabled) {
1479
+ setOpen(true);
1480
+ }
1481
+ }}
1482
+ style={{
1483
+ flex: 1,
1484
+ backgroundColor: 'transparent',
1485
+ border: 'none',
1486
+ outline: 'none',
1487
+ fontSize: '14px',
1488
+ padding: '9px 8px',
1489
+ color: theme.text,
1490
+ cursor: mode !== 'single' ? 'pointer' : 'text',
1491
+ fontFamily: 'inherit',
1492
+ minWidth: 0,
1493
+ }}
1494
+ />
1495
+
1496
+ {/* Clear button */}
1497
+ {hasValue && !isDisabled && (
1498
+ <button
1499
+ type="button"
1500
+ aria-label="Clear selection"
1501
+ onClick={handleClear}
1502
+ style={{
1503
+ display: 'flex',
1504
+ alignItems: 'center',
1505
+ justifyContent: 'center',
1506
+ width: '28px',
1507
+ height: '28px',
1508
+ border: 'none',
1509
+ background: 'transparent',
1510
+ cursor: 'pointer',
1511
+ color: theme.textMuted,
1512
+ flexShrink: 0,
1513
+ borderRadius: '4px',
1514
+ marginRight: '4px',
1515
+ }}
1516
+ >
1517
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
1518
+ <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
1519
+ </svg>
1520
+ </button>
1521
+ )}
1522
+
1523
+ {/* Toggle button */}
1524
+ <button
1525
+ type="button"
1526
+ aria-label={open ? 'Close calendar' : 'Open calendar'}
1527
+ tabIndex={-1}
1528
+ disabled={isDisabled}
1529
+ onClick={() => {
1530
+ if (!isDisabled) {
1531
+ setOpen((o) => !o);
1532
+ }
1533
+ }}
1534
+ style={{
1535
+ display: 'flex',
1536
+ alignItems: 'center',
1537
+ justifyContent: 'center',
1538
+ width: '34px',
1539
+ height: '36px',
1540
+ border: 'none',
1541
+ background: 'transparent',
1542
+ cursor: isDisabled ? 'not-allowed' : 'pointer',
1543
+ color: theme.textMuted,
1544
+ flexShrink: 0,
1545
+ }}
1546
+ >
1547
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" style={{ transform: open ? 'rotate(180deg)' : 'rotate(0deg)', transition: reduced ? 'none' : 'transform 160ms ease' }}>
1548
+ <polyline points="6 9 12 15 18 9" />
1549
+ </svg>
1550
+ </button>
1551
+ </div>
1552
+
1553
+ {/* Hint / Error */}
1554
+ {safeHint && !isInvalid && (
1555
+ <span style={{ fontSize: '12px', color: theme.textMuted }}>{safeHint}</span>
1556
+ )}
1557
+ {isInvalid && safeError && (
1558
+ <span role="alert" style={{ fontSize: '12px', color: theme.danger }}>{safeError}</span>
1559
+ )}
1560
+
1561
+ {/* Portal popup */}
1562
+ {open && typeof document !== 'undefined' && createPortal(popupContent, document.body)}
1563
+ </div>
1564
+ );
1565
+ }
1566
+
1567
+ TkxDatePicker.displayName = 'TkxDatePicker';