react-bootstrap-plugins 1.0.5 → 2.1.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.
- package/README.md +14 -7
- package/dist/AutoTextarea.cjs +2 -0
- package/dist/AutoTextarea.d.cts +29 -0
- package/dist/AutoTextarea.d.ts +29 -0
- package/dist/AutoTextarea.js +2 -0
- package/dist/DatePicker.cjs +2 -2
- package/dist/DatePicker.d.cts +57 -0
- package/dist/DatePicker.d.ts +54 -42
- package/dist/DatePicker.js +2 -2
- package/dist/Label.cjs +2 -2
- package/dist/Label.d.cts +15 -0
- package/dist/Label.d.ts +12 -13
- package/dist/Label.js +2 -2
- package/dist/SearchSelect.cjs +2 -2
- package/dist/SearchSelect.d.cts +40 -0
- package/dist/SearchSelect.d.ts +37 -28
- package/dist/SearchSelect.js +2 -2
- package/dist/TableLoading.cjs +2 -2
- package/dist/TableLoading.d.cts +13 -0
- package/dist/TableLoading.d.ts +10 -10
- package/dist/TableLoading.js +2 -2
- package/dist/chunk-2Q4NZKMX.cjs +2 -0
- package/dist/chunk-5IOLZUVD.cjs +2 -0
- package/dist/chunk-7LXLWNUR.js +2 -0
- package/dist/chunk-AWU5XGMI.cjs +2 -0
- package/dist/chunk-HYB5IGJK.js +2 -0
- package/dist/chunk-KKWFIJKN.js +2 -0
- package/dist/chunk-M3OUUXZG.cjs +2 -0
- package/dist/chunk-NPQQDBFB.cjs +2 -0
- package/dist/chunk-OEFG7PNW.cjs +2 -0
- package/dist/chunk-RR7TBRGI.js +2 -0
- package/dist/chunk-WLMCWTO6.js +2 -0
- package/dist/chunk-ZCAQ7S2A.js +2 -0
- package/dist/css/datepicker.css +1 -299
- package/dist/css/plugins.css +1 -0
- package/dist/index.cjs +2 -2
- package/dist/index.d.cts +6 -0
- package/dist/index.d.ts +6 -13
- package/dist/index.js +2 -2
- package/docs/AUTOTEXTAREA.md +106 -0
- package/docs/DATEPICKER.md +3 -3
- package/package.json +25 -8
- package/dist/DatePicker.cjs.map +0 -1
- package/dist/DatePicker.js.map +0 -1
- package/dist/Label.cjs.map +0 -1
- package/dist/Label.js.map +0 -1
- package/dist/SearchSelect.cjs.map +0 -1
- package/dist/SearchSelect.js.map +0 -1
- package/dist/TableLoading.cjs.map +0 -1
- package/dist/TableLoading.js.map +0 -1
- package/dist/chunk-2JC4VIKL.cjs +0 -2
- package/dist/chunk-2JC4VIKL.cjs.map +0 -1
- package/dist/chunk-46AJ2Q5H.js +0 -2
- package/dist/chunk-46AJ2Q5H.js.map +0 -1
- package/dist/chunk-J4TRPVD4.cjs +0 -2
- package/dist/chunk-J4TRPVD4.cjs.map +0 -1
- package/dist/chunk-KZEOI42M.cjs +0 -2
- package/dist/chunk-KZEOI42M.cjs.map +0 -1
- package/dist/chunk-MQRJBL7S.cjs +0 -2
- package/dist/chunk-MQRJBL7S.cjs.map +0 -1
- package/dist/chunk-MTRPXJ3C.js +0 -2
- package/dist/chunk-MTRPXJ3C.js.map +0 -1
- package/dist/chunk-QQNPKA7A.cjs +0 -2
- package/dist/chunk-QQNPKA7A.cjs.map +0 -1
- package/dist/chunk-W6UBCMKG.js +0 -2
- package/dist/chunk-W6UBCMKG.js.map +0 -1
- package/dist/chunk-WPTHY5AJ.js +0 -2
- package/dist/chunk-WPTHY5AJ.js.map +0 -1
- package/dist/chunk-XFHLZ7I3.js +0 -2
- package/dist/chunk-XFHLZ7I3.js.map +0 -1
- package/dist/index.cjs.map +0 -1
- package/dist/index.js.map +0 -1
- package/src/components/DatePicker.d.ts +0 -45
- package/src/components/DatePicker.jsx +0 -691
- package/src/components/Label.d.ts +0 -16
- package/src/components/Label.jsx +0 -22
- package/src/components/SearchSelect.d.ts +0 -31
- package/src/components/SearchSelect.jsx +0 -155
- package/src/components/TableLoading.d.ts +0 -13
- package/src/components/TableLoading.jsx +0 -43
- package/src/css/datepicker.css +0 -299
- package/src/index.js +0 -16
- package/src/lib/cn.js +0 -35
|
@@ -1,691 +0,0 @@
|
|
|
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">‹</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">›</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">‹</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">›</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"> </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
|
-
×
|
|
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 }
|
|
691
|
-
export default DatePicker
|