react-bootstrap-plugins 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 (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +426 -0
  3. package/dist/DatePicker.cjs +2 -0
  4. package/dist/DatePicker.cjs.map +1 -0
  5. package/dist/DatePicker.d.ts +45 -0
  6. package/dist/DatePicker.js +2 -0
  7. package/dist/DatePicker.js.map +1 -0
  8. package/dist/Label.cjs +2 -0
  9. package/dist/Label.cjs.map +1 -0
  10. package/dist/Label.d.ts +16 -0
  11. package/dist/Label.js +2 -0
  12. package/dist/Label.js.map +1 -0
  13. package/dist/SearchSelect.cjs +2 -0
  14. package/dist/SearchSelect.cjs.map +1 -0
  15. package/dist/SearchSelect.d.ts +28 -0
  16. package/dist/SearchSelect.js +2 -0
  17. package/dist/SearchSelect.js.map +1 -0
  18. package/dist/chunk-46AJ2Q5H.js +2 -0
  19. package/dist/chunk-46AJ2Q5H.js.map +1 -0
  20. package/dist/chunk-4ISYCPRB.cjs +2 -0
  21. package/dist/chunk-4ISYCPRB.cjs.map +1 -0
  22. package/dist/chunk-CZV7QBHA.cjs +2 -0
  23. package/dist/chunk-CZV7QBHA.cjs.map +1 -0
  24. package/dist/chunk-DIUGH74M.js +2 -0
  25. package/dist/chunk-DIUGH74M.js.map +1 -0
  26. package/dist/chunk-F3KMQZXU.js +2 -0
  27. package/dist/chunk-F3KMQZXU.js.map +1 -0
  28. package/dist/chunk-HBS6VOGV.cjs +2 -0
  29. package/dist/chunk-HBS6VOGV.cjs.map +1 -0
  30. package/dist/chunk-NOGRIWSU.js +2 -0
  31. package/dist/chunk-NOGRIWSU.js.map +1 -0
  32. package/dist/chunk-QQNPKA7A.cjs +2 -0
  33. package/dist/chunk-QQNPKA7A.cjs.map +1 -0
  34. package/dist/css/css/datepicker-bootstrap.css +299 -0
  35. package/dist/index.cjs +2 -0
  36. package/dist/index.cjs.map +1 -0
  37. package/dist/index.d.ts +10 -0
  38. package/dist/index.js +2 -0
  39. package/dist/index.js.map +1 -0
  40. package/package.json +125 -0
  41. package/src/components/DatePicker.d.ts +45 -0
  42. package/src/components/DatePicker.jsx +690 -0
  43. package/src/components/Label.d.ts +16 -0
  44. package/src/components/Label.jsx +21 -0
  45. package/src/components/SearchSelect.d.ts +28 -0
  46. package/src/components/SearchSelect.jsx +152 -0
  47. package/src/css/datepicker-bootstrap.css +299 -0
  48. package/src/index.js +15 -0
  49. package/src/lib/cn.js +35 -0
@@ -0,0 +1,690 @@
1
+ import * as React from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { cn } from '../lib/cn.js'
4
+
5
+ /* ------------------------------------------------------------------ */
6
+ /* Constants */
7
+ /* ------------------------------------------------------------------ */
8
+
9
+ const DAYS_OF_WEEK = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
10
+ const MONTHS = [
11
+ 'January', 'February', 'March', 'April', 'May', 'June',
12
+ 'July', 'August', 'September', 'October', 'November', 'December',
13
+ ]
14
+ const MINUTES = Array.from({ length: 60 }, (_, i) => i)
15
+ const YEARS_PER_PAGE = 12
16
+
17
+ /* ------------------------------------------------------------------ */
18
+ /* Helpers */
19
+ /* ------------------------------------------------------------------ */
20
+
21
+ const daysInMonth = (year, month) => new Date(year, month + 1, 0).getDate()
22
+
23
+ const isSameDay = (a, b) =>
24
+ a && b && a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
25
+
26
+ const isDateInRange = (date, min, max) => {
27
+ if (min && date < new Date(min.getFullYear(), min.getMonth(), min.getDate())) return false
28
+ if (max && date > new Date(max.getFullYear(), max.getMonth(), max.getDate())) return false
29
+ return true
30
+ }
31
+
32
+ const buildCalendarGrid = (year, month) => {
33
+ const firstDay = new Date(year, month, 1).getDay()
34
+ const totalDays = daysInMonth(year, month)
35
+ const cells = []
36
+
37
+ const prevMonthDays = daysInMonth(year, month - 1)
38
+ for (let i = firstDay - 1; i >= 0; i--) {
39
+ const day = prevMonthDays - i
40
+ cells.push({ day, date: new Date(year, month - 1, day), isOutside: true })
41
+ }
42
+
43
+ for (let d = 1; d <= totalDays; d++) {
44
+ cells.push({ day: d, date: new Date(year, month, d), isOutside: false })
45
+ }
46
+
47
+ const remaining = 42 - cells.length
48
+ for (let d = 1; d <= remaining; d++) {
49
+ cells.push({ day: d, date: new Date(year, month + 1, d), isOutside: true })
50
+ }
51
+
52
+ return cells
53
+ }
54
+
55
+ const formatValue = (date, mode, fmt) => {
56
+ if (!date || isNaN(date.getTime())) return ''
57
+
58
+ const y = date.getFullYear()
59
+ const M = String(date.getMonth() + 1).padStart(2, '0')
60
+ const d = String(date.getDate()).padStart(2, '0')
61
+ let h = date.getHours()
62
+ const m = String(date.getMinutes()).padStart(2, '0')
63
+ const a = h >= 12 ? 'PM' : 'AM'
64
+ h = h % 12 || 12
65
+ const hh = String(h).padStart(2, '0')
66
+
67
+ if (fmt) {
68
+ return fmt.replace('yyyy', y).replace('MM', M).replace('dd', d).replace('hh', hh).replace('mm', m).replace('aa', a)
69
+ }
70
+
71
+ switch (mode) {
72
+ case 'time': return `${hh}:${m} ${a}`
73
+ case 'datetime': return `${y}-${M}-${d} ${hh}:${m} ${a}`
74
+ default: return `${y}-${M}-${d}`
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Build a synthetic event object like a native input's onChange.
80
+ * { target: { value, name, type }, preventDefault, stopPropagation }
81
+ */
82
+ const makeEvent = (value, inputName, type = 'date') => ({
83
+ target: {
84
+ value,
85
+ name: inputName,
86
+ type,
87
+ },
88
+ preventDefault: () => {},
89
+ stopPropagation: () => {},
90
+ nativeEvent: null,
91
+ })
92
+
93
+ /* ------------------------------------------------------------------ */
94
+ /* Popover positioning */
95
+ /* ------------------------------------------------------------------ */
96
+
97
+ /** Approximate heights — used as a first guess before the real DOM height is measured. */
98
+ const POPOVER_GUESS = { date: 300, time: 300, datetime: 400, year: 250 }
99
+
100
+ /**
101
+ * Find the nearest scrollable ancestor (for capturing scroll events inside
102
+ * modals / side panels / custom containers).
103
+ */
104
+ const getScrollParent = (el) => {
105
+ if (!el) return document.documentElement
106
+ let parent = el.parentElement
107
+ while (parent) {
108
+ const style = window.getComputedStyle(parent)
109
+ const overflowY = style.overflowY || style.overflow
110
+ if (overflowY === 'auto' || overflowY === 'scroll') return parent
111
+ parent = parent.parentElement
112
+ }
113
+ return document.documentElement
114
+ }
115
+
116
+ const calcPopoverStyle = (inputEl, popoverHeight) => {
117
+ if (!inputEl) return {}
118
+ const rect = inputEl.getBoundingClientRect()
119
+ const h = popoverHeight || 300
120
+
121
+ const spaceBelow = window.innerHeight - rect.bottom
122
+ const spaceAbove = rect.top
123
+
124
+ /* Only flip above when below is truly too tight AND above offers more room */
125
+ const placeAbove = spaceBelow < h && spaceAbove > spaceBelow
126
+
127
+ /* Responsive: use smaller min-width on mobile to prevent overflow */
128
+ const minW = Math.max(rect.width, window.innerWidth < 576 ? 220 : 260)
129
+
130
+ return {
131
+ position: 'fixed',
132
+ top: placeAbove ? Math.max(4, rect.top - h - 4) : rect.bottom + 4,
133
+ left: Math.max(4, Math.min(rect.left, window.innerWidth - minW - 4)),
134
+ minWidth: minW,
135
+ maxWidth: window.innerWidth - 8,
136
+ zIndex: 1070,
137
+ }
138
+ }
139
+
140
+ /* ------------------------------------------------------------------ */
141
+ /* DatePicker */
142
+ /* ------------------------------------------------------------------ */
143
+
144
+ /**
145
+ * Custom Bootstrap-styled date / time / datetime picker. Zero dependencies.
146
+ *
147
+ * **Important:** The accompanying CSS **must** be imported for the picker to render correctly:
148
+ * ```js
149
+ * import 'react-bootstrap-plugins/css/datepicker.css'
150
+ * ```
151
+ *
152
+ * @param {'date'|'time'|'datetime'} mode - Picker mode (default 'date')
153
+ * @param {Date|null} value - Currently selected Date (aliases: selected)
154
+ * @param {(e: { target: { value: string|null, name: string, type: string } }) => void} onChange
155
+ * - Synthetic event handler — e.target.value is a pre-formatted string
156
+ * (date → "YYYY-MM-DD", time → "hh:mm AA", datetime → "YYYY-MM-DD hh:mm AA")
157
+ * @param {string} [dateFormat] - Display format (yyyy, MM, dd, hh, mm, aa)
158
+ * @param {string} [placeholderText] - Placeholder when empty
159
+ * @param {'sm'|'lg'} [size] - Bootstrap size variant
160
+ * @param {boolean} [isClearable=false] - Show a clear button on the input
161
+ * @param {boolean} [disabled=false] - Disable the input
162
+ * @param {Date} [minDate] - Earliest selectable date
163
+ * @param {Date} [maxDate] - Latest selectable date
164
+ * @param {number} [timeIntervals=5] - Minute step in the time list
165
+ * @param {string} [timezone='Kampala'] - Timezone identifier for the picker
166
+ * @param {string} [className] - Additional classes on the input
167
+ */
168
+ const DatePicker = React.forwardRef(({
169
+ className,
170
+ mode = 'date',
171
+ selected,
172
+ value,
173
+ onChange,
174
+ dateFormat,
175
+ placeholderText,
176
+ size,
177
+ isClearable = false,
178
+ disabled = false,
179
+ minDate,
180
+ maxDate,
181
+ timeIntervals = 5,
182
+ timezone = 'Kampala',
183
+ id,
184
+ name,
185
+ ...props
186
+ }, ref) => {
187
+ /* ---- derived ---- */
188
+ const resolveValue = (v) => {
189
+ if (!v) return null
190
+ if (v instanceof Date) return isNaN(v.getTime()) ? null : v
191
+ if (typeof v === 'string' || typeof v === 'number') {
192
+ // Standard Date parsing (works for ISO 8601, "YYYY-MM-DD", etc.)
193
+ const d = new Date(v)
194
+ if (!isNaN(d.getTime())) return d
195
+
196
+ // Handle time-only strings from formatValue: "hh:mm AA"
197
+ if (typeof v === 'string') {
198
+ const timeMatch = v.match(/^(\d{1,2}):(\d{2})\s*(AM|PM)$/i)
199
+ if (timeMatch) {
200
+ let h = parseInt(timeMatch[1], 10)
201
+ const m = parseInt(timeMatch[2], 10)
202
+ const ap = timeMatch[3].toUpperCase()
203
+ if (h === 12 && ap === 'AM') h = 0
204
+ if (h !== 12 && ap === 'PM') h += 12
205
+ const now = new Date()
206
+ return new Date(now.getFullYear(), now.getMonth(), now.getDate(), h, m, 0, 0)
207
+ }
208
+
209
+ // Handle datetime strings from formatValue: "YYYY-MM-DD hh:mm AA"
210
+ const dtMatch = v.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{1,2}):(\d{2})\s*(AM|PM)$/i)
211
+ if (dtMatch) {
212
+ let h = parseInt(dtMatch[4], 10)
213
+ const m = parseInt(dtMatch[5], 10)
214
+ const ap = dtMatch[6].toUpperCase()
215
+ if (h === 12 && ap === 'AM') h = 0
216
+ if (h !== 12 && ap === 'PM') h += 12
217
+ return new Date(parseInt(dtMatch[1]), parseInt(dtMatch[2]) - 1, parseInt(dtMatch[3]), h, m, 0, 0)
218
+ }
219
+ }
220
+ return null
221
+ }
222
+ return null
223
+ }
224
+ const resolvedValue = React.useMemo(
225
+ () => resolveValue(selected ?? value),
226
+ [
227
+ selected instanceof Date ? selected.getTime() : selected,
228
+ value instanceof Date ? value.getTime() : value,
229
+ ],
230
+ )
231
+ const showCalendar = mode === 'date' || mode === 'datetime'
232
+ const showTime = mode === 'time' || mode === 'datetime'
233
+
234
+ /* ---- internal state ---- */
235
+ const [isOpen, setIsOpen] = React.useState(false)
236
+ const [viewYear, setViewYear] = React.useState(() =>
237
+ resolvedValue ? resolvedValue.getFullYear() : new Date().getFullYear())
238
+ const [viewMonth, setViewMonth] = React.useState(() =>
239
+ resolvedValue ? resolvedValue.getMonth() : new Date().getMonth())
240
+ const [tempDate, setTempDate] = React.useState(resolvedValue)
241
+ const [tempHours, setTempHours] = React.useState(() =>
242
+ resolvedValue ? (resolvedValue.getHours() % 12 || 12) : 12)
243
+ const [tempMins, setTempMins] = React.useState(() =>
244
+ resolvedValue ? resolvedValue.getMinutes() : 0)
245
+ const [tempAmPm, setTempAmPm] = React.useState(() =>
246
+ resolvedValue ? (resolvedValue.getHours() >= 12 ? 'PM' : 'AM') : 'AM')
247
+ const [showYearPicker, setShowYearPicker] = React.useState(false)
248
+ const [yearPage, setYearPage] = React.useState(() =>
249
+ Math.floor((resolvedValue ? resolvedValue.getFullYear() : new Date().getFullYear()) / YEARS_PER_PAGE) * YEARS_PER_PAGE
250
+ )
251
+ const [popoverStyle, setPopoverStyle] = React.useState({})
252
+
253
+ const inputRef = React.useRef(null)
254
+ const popoverRef = React.useRef(null)
255
+ const portalRef = React.useRef(null)
256
+
257
+ /* ---- portal container ---- */
258
+ React.useEffect(() => {
259
+ const el = document.createElement('div')
260
+ el.className = 'datepicker-portal-root'
261
+ document.body.appendChild(el)
262
+ portalRef.current = el
263
+ return () => {
264
+ if (el.parentNode) document.body.removeChild(el)
265
+ portalRef.current = null
266
+ }
267
+ }, [])
268
+
269
+ /* ---- sync temp state when value changes externally ---- */
270
+ React.useEffect(() => {
271
+ if (resolvedValue && !isNaN(resolvedValue.getTime())) {
272
+ setTempDate(resolvedValue)
273
+ setViewYear(resolvedValue.getFullYear())
274
+ setViewMonth(resolvedValue.getMonth())
275
+ setTempHours(resolvedValue.getHours() % 12 || 12)
276
+ setTempMins(resolvedValue.getMinutes())
277
+ setTempAmPm(resolvedValue.getHours() >= 12 ? 'PM' : 'AM')
278
+ setYearPage(Math.floor(resolvedValue.getFullYear() / YEARS_PER_PAGE) * YEARS_PER_PAGE)
279
+ } else {
280
+ setTempDate(null)
281
+ }
282
+ }, [resolvedValue])
283
+
284
+ /* ---- reposition popover on open / scroll / resize ---- */
285
+ React.useEffect(() => {
286
+ if (!isOpen) return
287
+
288
+ /* Measure actual popover height so we position with no wasted space */
289
+ const getHeight = () => {
290
+ if (popoverRef.current) {
291
+ return popoverRef.current.getBoundingClientRect().height
292
+ }
293
+ return POPOVER_GUESS[showYearPicker ? 'year' : mode] || 300
294
+ }
295
+
296
+ const scrollParent = getScrollParent(inputRef.current)
297
+
298
+ const update = () => {
299
+ /* Force a paint frame so the popover has its real size */
300
+ requestAnimationFrame(() => {
301
+ setPopoverStyle(calcPopoverStyle(inputRef.current, getHeight()))
302
+ })
303
+ }
304
+ update()
305
+
306
+ /* Listen on the nearest scrollable ancestor (e.g. modal body) AND the window */
307
+ scrollParent.addEventListener('scroll', update, { passive: true })
308
+ window.addEventListener('resize', update)
309
+ return () => {
310
+ scrollParent.removeEventListener('scroll', update)
311
+ window.removeEventListener('resize', update)
312
+ }
313
+ }, [isOpen, mode, showYearPicker])
314
+
315
+ /* ---- click outside to close ---- */
316
+ React.useEffect(() => {
317
+ if (!isOpen) return
318
+ const handler = (e) => {
319
+ if (popoverRef.current && !popoverRef.current.contains(e.target) &&
320
+ inputRef.current && !inputRef.current.contains(e.target)) {
321
+ setIsOpen(false)
322
+ setShowYearPicker(false)
323
+ }
324
+ }
325
+ document.addEventListener('mousedown', handler)
326
+ return () => document.removeEventListener('mousedown', handler)
327
+ }, [isOpen])
328
+
329
+ /* ---- commit helpers ---- */
330
+ const buildDate = (base, h12, mins, ampm) => {
331
+ const d = new Date(base)
332
+ let h = h12 % 12
333
+ if (ampm === 'PM') h += 12
334
+ if (h === 12 && ampm === 'AM') h = 0
335
+ d.setHours(h, mins, 0, 0)
336
+ return d
337
+ }
338
+
339
+ /**
340
+ * Fire onChange with a pre-built date without closing the popover.
341
+ * Callers must build the complete date (including time) before passing it in.
342
+ */
343
+ const fireChange = (date) => {
344
+ if (!date || isNaN(date.getTime())) return
345
+ const formatted = formatValue(date, mode, dateFormat)
346
+ onChange?.(makeEvent(formatted, name, mode))
347
+ }
348
+
349
+ const commit = (date) => {
350
+ if (!date) {
351
+ onChange?.(makeEvent(null, name, mode))
352
+ setIsOpen(false)
353
+ setShowYearPicker(false)
354
+ return
355
+ }
356
+ fireChange(date)
357
+ if (mode === 'date') { setIsOpen(false); setShowYearPicker(false) }
358
+ }
359
+
360
+ const commitDateOnly = (date) => {
361
+ setTempDate(date)
362
+ if (mode === 'date') {
363
+ commit(date)
364
+ } else if (mode === 'datetime') {
365
+ // Build full datetime (date + current time selection) and fire
366
+ const d = buildDate(date, tempHours, tempMins, tempAmPm)
367
+ fireChange(d)
368
+ }
369
+ }
370
+
371
+ /* ---- calendar grid ---- */
372
+ const grid = buildCalendarGrid(viewYear, viewMonth)
373
+
374
+ const handlePrevMonth = () => {
375
+ let newY = viewYear, newM = viewMonth
376
+ if (viewMonth === 0) { newY = viewYear - 1; newM = 11 }
377
+ else newM = viewMonth - 1
378
+ setViewYear(newY); setViewMonth(newM)
379
+ const base = tempDate || new Date()
380
+ const maxD = daysInMonth(newY, newM)
381
+ const newDate = new Date(newY, newM, Math.min(base.getDate(), maxD))
382
+ setTempDate(newDate)
383
+ fireChange(showTime ? buildDate(newDate, tempHours, tempMins, tempAmPm) : newDate)
384
+ }
385
+ const handleNextMonth = () => {
386
+ let newY = viewYear, newM = viewMonth
387
+ if (viewMonth === 11) { newY = viewYear + 1; newM = 0 }
388
+ else newM = viewMonth + 1
389
+ setViewYear(newY); setViewMonth(newM)
390
+ const base = tempDate || new Date()
391
+ const maxD = daysInMonth(newY, newM)
392
+ const newDate = new Date(newY, newM, Math.min(base.getDate(), maxD))
393
+ setTempDate(newDate)
394
+ fireChange(showTime ? buildDate(newDate, tempHours, tempMins, tempAmPm) : newDate)
395
+ }
396
+
397
+ const today = new Date()
398
+ const minDt = minDate ? new Date(minDate.getFullYear(), minDate.getMonth(), minDate.getDate()) : null
399
+ const maxDt = maxDate ? new Date(maxDate.getFullYear(), maxDate.getMonth(), maxDate.getDate()) : null
400
+
401
+ /* ---- time change handlers ---- */
402
+ const handleHourChange = (h) => {
403
+ setTempHours(h)
404
+ if (showTime) {
405
+ const d = buildDate(tempDate || new Date(), h, tempMins, tempAmPm)
406
+ onChange?.(makeEvent(formatValue(d, mode, dateFormat), name, mode))
407
+ }
408
+ }
409
+ const handleMinChange = (m) => {
410
+ setTempMins(m)
411
+ if (showTime) {
412
+ const d = buildDate(tempDate || new Date(), tempHours, m, tempAmPm)
413
+ onChange?.(makeEvent(formatValue(d, mode, dateFormat), name, mode))
414
+ }
415
+ }
416
+ const handleAmPmChange = (ap) => {
417
+ setTempAmPm(ap)
418
+ if (showTime) {
419
+ const d = buildDate(tempDate || new Date(), tempHours, tempMins, ap)
420
+ onChange?.(makeEvent(formatValue(d, mode, dateFormat), name, mode))
421
+ }
422
+ }
423
+
424
+ /* ---- year picker ---- */
425
+ const openYearPicker = () => {
426
+ setYearPage(Math.floor(viewYear / YEARS_PER_PAGE) * YEARS_PER_PAGE)
427
+ setShowYearPicker(true)
428
+ }
429
+
430
+ const handleYearSelect = (y) => {
431
+ setViewYear(y)
432
+ setShowYearPicker(false)
433
+ const base = tempDate || new Date()
434
+ const maxD = daysInMonth(y, viewMonth)
435
+ const newDate = new Date(y, viewMonth, Math.min(base.getDate(), maxD))
436
+ setTempDate(newDate)
437
+ fireChange(showTime ? buildDate(newDate, tempHours, tempMins, tempAmPm) : newDate)
438
+ }
439
+
440
+ const yearPageStart = Math.floor(yearPage / YEARS_PER_PAGE) * YEARS_PER_PAGE
441
+ const years = Array.from({ length: YEARS_PER_PAGE }, (_, i) => yearPageStart + i)
442
+
443
+ /* ---- clear ---- */
444
+ const handleClear = (e) => {
445
+ e.stopPropagation()
446
+ setTempDate(null)
447
+ onChange?.(makeEvent(null, name))
448
+ }
449
+
450
+ /* ---- keyboard ---- */
451
+ const handleInputKeyDown = (e) => {
452
+ if (e.key === 'Escape') { setIsOpen(false); setShowYearPicker(false); inputRef.current?.blur() }
453
+ if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
454
+ e.preventDefault()
455
+ setIsOpen(true)
456
+ }
457
+ }
458
+
459
+ /* ---- merged ref ---- */
460
+ const mergeInputRef = React.useCallback((node) => {
461
+ inputRef.current = node
462
+ if (typeof ref === 'function') ref(node)
463
+ else if (ref) ref.current = node
464
+ }, [ref])
465
+
466
+ /* ---- render ---- */
467
+ const placeholder = placeholderText
468
+ ?? (mode === 'time' ? 'Select time' : mode === 'datetime' ? 'Select date & time' : 'Select date')
469
+
470
+ const popover = (
471
+ <div
472
+ ref={popoverRef}
473
+ className={cn(
474
+ 'datepicker-popover card border shadow-sm',
475
+ mode === 'datetime' && 'datepicker-popover--wide'
476
+ )}
477
+ style={popoverStyle}
478
+ >
479
+ {/* ============== YEAR PICKER ============== */}
480
+ {showYearPicker && showCalendar && (
481
+ <div className="datepicker-year-picker p-3">
482
+ <div className="d-flex align-items-center justify-content-between mb-2">
483
+ <button type="button" className="btn btn-sm btn-outline-secondary border-0 px-1" onClick={() => setYearPage(yearPage - YEARS_PER_PAGE)} aria-label="Previous years">&lsaquo;</button>
484
+ <span className="fw-semibold small">{yearPageStart} – {yearPageStart + YEARS_PER_PAGE - 1}</span>
485
+ <button type="button" className="btn btn-sm btn-outline-secondary border-0 px-1" onClick={() => setYearPage(yearPage + YEARS_PER_PAGE)} aria-label="Next years">&rsaquo;</button>
486
+ </div>
487
+ <div className="datepicker-year-grid">
488
+ {years.map((y) => {
489
+ const isCurrentYear = y === new Date().getFullYear()
490
+ const isSelected = tempDate && y === tempDate.getFullYear()
491
+ return (
492
+ <button
493
+ key={y}
494
+ type="button"
495
+ className={cn(
496
+ 'datepicker-year-cell btn btn-sm',
497
+ isSelected && 'btn-primary',
498
+ !isSelected && isCurrentYear && 'btn-outline-primary',
499
+ !isSelected && !isCurrentYear && 'btn-light border'
500
+ )}
501
+ onClick={() => handleYearSelect(y)}
502
+ >
503
+ {y}
504
+ </button>
505
+ )
506
+ })}
507
+ </div>
508
+ </div>
509
+ )}
510
+
511
+ {/* ============== CALENDAR + TIME ============== */}
512
+ {!showYearPicker && (
513
+ <>
514
+
515
+ {/* ---- CALENDAR + TIME CONTAINER ---- */}
516
+ <div className={cn('d-flex', mode === 'datetime' ? 'flex-row' : 'flex-column')}>
517
+
518
+ {/* ---- CALENDAR ---- */}
519
+ {showCalendar && (
520
+ <div className="datepicker-calendar p-2">
521
+ {/* Month / Year header */}
522
+ <div className="d-flex align-items-center justify-content-between mb-2">
523
+ <button type="button" className="btn btn-sm btn-outline-secondary border-0 px-1" onClick={handlePrevMonth} aria-label="Previous month">&lsaquo;</button>
524
+ <div className="d-flex align-items-center gap-1">
525
+ <span className="fw-semibold small">{MONTHS[viewMonth]}</span>
526
+ <button
527
+ type="button"
528
+ className="btn btn-sm btn-link text-decoration-none text-primary fw-semibold small p-0"
529
+ onClick={openYearPicker}
530
+ aria-label="Choose year"
531
+ >{viewYear}</button>
532
+ </div>
533
+ <button type="button" className="btn btn-sm btn-outline-secondary border-0 px-1" onClick={handleNextMonth} aria-label="Next month">&rsaquo;</button>
534
+ </div>
535
+
536
+ {/* Day-of-week headers */}
537
+ <div className="datepicker-days-header">
538
+ {DAYS_OF_WEEK.map(d => (
539
+ <div key={d} className="datepicker-day-label">{d}</div>
540
+ ))}
541
+ </div>
542
+
543
+ {/* Day grid */}
544
+ <div className="datepicker-days-grid">
545
+ {grid.map((cell, i) => {
546
+ const isToday = isSameDay(cell.date, today)
547
+ const isSelected = tempDate && isSameDay(cell.date, tempDate)
548
+ const inRange = isDateInRange(cell.date, minDt, maxDt)
549
+ const isDisabledDay = cell.isOutside || !inRange
550
+
551
+ return (
552
+ <button
553
+ key={i}
554
+ type="button"
555
+ disabled={isDisabledDay}
556
+ className={cn(
557
+ 'datepicker-day',
558
+ isToday && 'datepicker-day--today',
559
+ isSelected && 'datepicker-day--selected',
560
+ cell.isOutside && 'datepicker-day--outside',
561
+ !inRange && 'datepicker-day--disabled'
562
+ )}
563
+ onClick={() => commitDateOnly(cell.date)}
564
+ tabIndex={cell.isOutside ? -1 : 0}
565
+ >
566
+ {cell.day}
567
+ </button>
568
+ )
569
+ })}
570
+ </div>
571
+ </div>
572
+ )}
573
+
574
+ {/* ---- DIVIDER (datetime mode) ---- */}
575
+ {mode === 'datetime' && <div className="vr mx-1 my-2" />}
576
+
577
+ {/* ---- TIME ---- */}
578
+ {showTime && (
579
+ <div className="datepicker-time p-2 d-flex flex-column" style={{ minWidth: 180 }}>
580
+ {mode === 'datetime' && <div className="small fw-semibold text-muted text-center mb-1">Time</div>}
581
+ <div className="d-flex justify-content-center gap-2">
582
+ {/* Hour column */}
583
+ <div className="datepicker-time-col">
584
+ <div className="datepicker-time-col-label">Hour</div>
585
+ <div className="datepicker-time-col-scroll">
586
+ {Array.from({ length: 12 }, (_, i) => i + 1).map(h => (
587
+ <button
588
+ key={h}
589
+ type="button"
590
+ className={cn('datepicker-time-item', h === tempHours && 'active')}
591
+ onClick={() => handleHourChange(h)}
592
+ >
593
+ {String(h).padStart(2, '0')}
594
+ </button>
595
+ ))}
596
+ </div>
597
+ </div>
598
+ <span className="datepicker-time-separator">:</span>
599
+ {/* Minute column */}
600
+ <div className="datepicker-time-col">
601
+ <div className="datepicker-time-col-label">Min</div>
602
+ <div className="datepicker-time-col-scroll">
603
+ {MINUTES.map(m => (
604
+ <button
605
+ key={m}
606
+ type="button"
607
+ className={cn('datepicker-time-item', m === tempMins && 'active')}
608
+ onClick={() => handleMinChange(m)}
609
+ >
610
+ {String(m).padStart(2, '0')}
611
+ </button>
612
+ ))}
613
+ </div>
614
+ </div>
615
+ {/* AM/PM column */}
616
+ <div className="datepicker-time-col">
617
+ <div className="datepicker-time-col-label">&nbsp;</div>
618
+ <div className="d-flex flex-column gap-1 mt-1">
619
+ {['AM', 'PM'].map(ap => (
620
+ <button
621
+ key={ap}
622
+ type="button"
623
+ className={cn('datepicker-time-item', 'datepicker-time-item--ampm', tempAmPm === ap && 'active')}
624
+ onClick={() => handleAmPmChange(ap)}
625
+ >
626
+ {ap}
627
+ </button>
628
+ ))}
629
+ </div>
630
+ </div>
631
+ </div>
632
+ </div>
633
+ )}
634
+ </div>
635
+ </>
636
+ )}
637
+ </div>
638
+ )
639
+
640
+ return (
641
+ <div className="position-relative d-inline-block w-100" style={{ minWidth: 0 }}>
642
+ {/* ---- Input ---- */}
643
+ <div className="position-relative">
644
+ <input
645
+ ref={mergeInputRef}
646
+ id={id}
647
+ name={name}
648
+ type="text"
649
+ readOnly
650
+ disabled={disabled}
651
+ className={cn(
652
+ 'form-control',
653
+ size === 'sm' && 'form-control-sm',
654
+ size === 'lg' && 'form-control-lg',
655
+ 'pe-5',
656
+ className
657
+ )}
658
+ placeholder={placeholder}
659
+ value={formatValue(resolvedValue, mode, dateFormat)}
660
+ onClick={() => !disabled && setIsOpen(!isOpen)}
661
+ onKeyDown={handleInputKeyDown}
662
+ aria-haspopup="dialog"
663
+ aria-expanded={isOpen}
664
+ autoComplete="off"
665
+ {...props}
666
+ />
667
+ {/* clear button */}
668
+ {isClearable && resolvedValue && !disabled && (
669
+ <button
670
+ type="button"
671
+ className="btn btn-sm btn-link position-absolute top-50 end-0 translate-middle-y pe-2 text-secondary"
672
+ style={{ zIndex: 2 }}
673
+ onClick={handleClear}
674
+ tabIndex={-1}
675
+ aria-label="Clear"
676
+ >
677
+ &times;
678
+ </button>
679
+ )}
680
+ </div>
681
+
682
+ {/* ---- Portal the popover to document.body ---- */}
683
+ {isOpen && !disabled && portalRef.current && createPortal(popover, portalRef.current)}
684
+ </div>
685
+ )
686
+ })
687
+
688
+ DatePicker.displayName = 'DatePicker'
689
+
690
+ export { DatePicker }