esseal-date-picker 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.
- package/README.md +413 -0
- package/index.js +375 -0
- package/package.json +16 -0
package/README.md
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
# EssealDatePicker
|
|
2
|
+
|
|
3
|
+
A lightweight, dependency-free date picker that works seamlessly with vanilla HTML, React, Vue, Angular, and other JavaScript frameworks.
|
|
4
|
+
|
|
5
|
+
## ✨ Features
|
|
6
|
+
|
|
7
|
+
- **Zero Dependencies** - Pure JavaScript, no external libraries required
|
|
8
|
+
- **Framework Agnostic** - Works with React, Vue, Angular, and vanilla JS
|
|
9
|
+
- **React-Safe** - Properly triggers React's synthetic event system
|
|
10
|
+
- **Lightweight** - ~8KB minified
|
|
11
|
+
- **Range Selection** - Support for both single date and date range selection
|
|
12
|
+
- **Customizable** - Easy theming with color options
|
|
13
|
+
- **Accessible** - ARIA labels and keyboard support
|
|
14
|
+
- **Localized** - Automatic locale detection for date formatting
|
|
15
|
+
|
|
16
|
+
## 📦 Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install esseal-date-picker
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Or use via CDN:
|
|
23
|
+
|
|
24
|
+
```html
|
|
25
|
+
<script type="module">
|
|
26
|
+
import EssealDatePicker from "https://cdn.jsdelivr.net/npm/esseal-date-picker/dist/esseal-date-picker.esm.js";
|
|
27
|
+
</script>
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## 🚀 Quick Start
|
|
31
|
+
|
|
32
|
+
### Vanilla HTML/JavaScript
|
|
33
|
+
|
|
34
|
+
```html
|
|
35
|
+
<!DOCTYPE html>
|
|
36
|
+
<html>
|
|
37
|
+
<head>
|
|
38
|
+
<title>Date Picker Demo</title>
|
|
39
|
+
</head>
|
|
40
|
+
<body>
|
|
41
|
+
<input type="text" id="myDatePicker" placeholder="Select a date" />
|
|
42
|
+
|
|
43
|
+
<script type="module">
|
|
44
|
+
import EssealDatePicker from "./esseal-date-picker.js";
|
|
45
|
+
|
|
46
|
+
new EssealDatePicker("#myDatePicker", {
|
|
47
|
+
onChange: (date) => {
|
|
48
|
+
console.log("Selected date:", date);
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
</script>
|
|
52
|
+
</body>
|
|
53
|
+
</html>
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### React
|
|
57
|
+
|
|
58
|
+
```jsx
|
|
59
|
+
import { useEffect, useRef } from "react";
|
|
60
|
+
import EssealDatePicker from "esseal-date-picker";
|
|
61
|
+
|
|
62
|
+
function DatePickerComponent() {
|
|
63
|
+
const inputRef = useRef(null);
|
|
64
|
+
const pickerRef = useRef(null);
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
// Initialize the date picker
|
|
68
|
+
pickerRef.current = new EssealDatePicker(inputRef.current, {
|
|
69
|
+
primaryColor: "#3b82f6",
|
|
70
|
+
onChange: (date) => {
|
|
71
|
+
console.log("Selected:", date);
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Cleanup on unmount
|
|
76
|
+
return () => {
|
|
77
|
+
if (pickerRef.current) {
|
|
78
|
+
pickerRef.current.destroy();
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
}, []);
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div>
|
|
85
|
+
<label htmlFor="date-picker">Select Date:</label>
|
|
86
|
+
<input
|
|
87
|
+
ref={inputRef}
|
|
88
|
+
type="text"
|
|
89
|
+
id="date-picker"
|
|
90
|
+
placeholder="Click to select date"
|
|
91
|
+
readOnly
|
|
92
|
+
/>
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export default DatePickerComponent;
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Vue 3
|
|
101
|
+
|
|
102
|
+
```vue
|
|
103
|
+
<template>
|
|
104
|
+
<div>
|
|
105
|
+
<label for="date-picker">Select Date:</label>
|
|
106
|
+
<input
|
|
107
|
+
ref="dateInput"
|
|
108
|
+
type="text"
|
|
109
|
+
id="date-picker"
|
|
110
|
+
placeholder="Click to select date"
|
|
111
|
+
readonly
|
|
112
|
+
/>
|
|
113
|
+
</div>
|
|
114
|
+
</template>
|
|
115
|
+
|
|
116
|
+
<script setup>
|
|
117
|
+
import { ref, onMounted, onUnmounted } from "vue";
|
|
118
|
+
import EssealDatePicker from "esseal-date-picker";
|
|
119
|
+
|
|
120
|
+
const dateInput = ref(null);
|
|
121
|
+
let picker = null;
|
|
122
|
+
|
|
123
|
+
onMounted(() => {
|
|
124
|
+
picker = new EssealDatePicker(dateInput.value, {
|
|
125
|
+
onChange: (date) => {
|
|
126
|
+
console.log("Selected:", date);
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
onUnmounted(() => {
|
|
132
|
+
if (picker) {
|
|
133
|
+
picker.destroy();
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
</script>
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Angular
|
|
140
|
+
|
|
141
|
+
```typescript
|
|
142
|
+
import {
|
|
143
|
+
Component,
|
|
144
|
+
ElementRef,
|
|
145
|
+
ViewChild,
|
|
146
|
+
OnInit,
|
|
147
|
+
OnDestroy,
|
|
148
|
+
} from "@angular/core";
|
|
149
|
+
import EssealDatePicker from "esseal-date-picker";
|
|
150
|
+
|
|
151
|
+
@Component({
|
|
152
|
+
selector: "app-date-picker",
|
|
153
|
+
template: `
|
|
154
|
+
<div>
|
|
155
|
+
<label for="date-picker">Select Date:</label>
|
|
156
|
+
<input
|
|
157
|
+
#dateInput
|
|
158
|
+
type="text"
|
|
159
|
+
id="date-picker"
|
|
160
|
+
placeholder="Click to select date"
|
|
161
|
+
readonly
|
|
162
|
+
/>
|
|
163
|
+
</div>
|
|
164
|
+
`,
|
|
165
|
+
})
|
|
166
|
+
export class DatePickerComponent implements OnInit, OnDestroy {
|
|
167
|
+
@ViewChild("dateInput", { static: true }) dateInput!: ElementRef;
|
|
168
|
+
private picker: any;
|
|
169
|
+
|
|
170
|
+
ngOnInit() {
|
|
171
|
+
this.picker = new EssealDatePicker(this.dateInput.nativeElement, {
|
|
172
|
+
onChange: (date: Date) => {
|
|
173
|
+
console.log("Selected:", date);
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
ngOnDestroy() {
|
|
179
|
+
if (this.picker) {
|
|
180
|
+
this.picker.destroy();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## 📖 API Reference
|
|
187
|
+
|
|
188
|
+
### Constructor
|
|
189
|
+
|
|
190
|
+
```javascript
|
|
191
|
+
new EssealDatePicker(target, options);
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
**Parameters:**
|
|
195
|
+
|
|
196
|
+
- `target` (string | HTMLInputElement) - CSS selector or DOM element
|
|
197
|
+
- `options` (object) - Configuration options
|
|
198
|
+
|
|
199
|
+
### Options
|
|
200
|
+
|
|
201
|
+
| Option | Type | Default | Description |
|
|
202
|
+
| -------------- | ---------------------------- | -------------------------------------------- | --------------------------------------------------- |
|
|
203
|
+
| `mode` | `'single'` \| `'range'` | `'single'` | Selection mode |
|
|
204
|
+
| `locale` | `string` | `navigator.language` | Locale for date formatting (e.g., 'en-US', 'fr-FR') |
|
|
205
|
+
| `minDate` | `Date` \| `string` \| `null` | `null` | Minimum selectable date |
|
|
206
|
+
| `maxDate` | `Date` \| `string` \| `null` | `null` | Maximum selectable date |
|
|
207
|
+
| `primaryColor` | `string` | `'#3b82f6'` | Primary color for selections (must be hex format) |
|
|
208
|
+
| `textColor` | `string` | `'#1f2937'` | Text color for the calendar |
|
|
209
|
+
| `zIndex` | `number` | `9999` | Z-index for the calendar popup |
|
|
210
|
+
| `format` | `function` | `(date) => date.toLocaleDateString('en-CA')` | Custom date formatter function |
|
|
211
|
+
| `onChange` | `function` \| `null` | `null` | Callback when date is selected |
|
|
212
|
+
|
|
213
|
+
### Methods
|
|
214
|
+
|
|
215
|
+
#### `open()`
|
|
216
|
+
|
|
217
|
+
Opens the date picker calendar.
|
|
218
|
+
|
|
219
|
+
```javascript
|
|
220
|
+
picker.open();
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
#### `close()`
|
|
224
|
+
|
|
225
|
+
Closes the date picker calendar.
|
|
226
|
+
|
|
227
|
+
```javascript
|
|
228
|
+
picker.close();
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
#### `destroy()`
|
|
232
|
+
|
|
233
|
+
Removes all event listeners and DOM elements. **Always call this when removing the picker** (especially important in React/Vue/Angular).
|
|
234
|
+
|
|
235
|
+
```javascript
|
|
236
|
+
picker.destroy();
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
## 🎨 Customization Examples
|
|
240
|
+
|
|
241
|
+
### Custom Color Theme
|
|
242
|
+
|
|
243
|
+
```javascript
|
|
244
|
+
new EssealDatePicker("#date-picker", {
|
|
245
|
+
primaryColor: "#10b981", // Green theme
|
|
246
|
+
textColor: "#374151",
|
|
247
|
+
});
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### Date Range Selection
|
|
251
|
+
|
|
252
|
+
```javascript
|
|
253
|
+
new EssealDatePicker("#dateRange", {
|
|
254
|
+
mode: "range",
|
|
255
|
+
onChange: (range) => {
|
|
256
|
+
console.log("Start:", range.start);
|
|
257
|
+
console.log("End:", range.end);
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
### Date Restrictions
|
|
263
|
+
|
|
264
|
+
```javascript
|
|
265
|
+
new EssealDatePicker("#date-picker", {
|
|
266
|
+
minDate: new Date(2024, 0, 1), // January 1, 2024
|
|
267
|
+
maxDate: new Date(2024, 11, 31), // December 31, 2024
|
|
268
|
+
onChange: (date) => {
|
|
269
|
+
console.log("Selected date within 2024:", date);
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
### Custom Date Format
|
|
275
|
+
|
|
276
|
+
```javascript
|
|
277
|
+
new EssealDatePicker("#date-picker", {
|
|
278
|
+
format: (date) => {
|
|
279
|
+
const day = date.getDate().toString().padStart(2, "0");
|
|
280
|
+
const month = (date.getMonth() + 1).toString().padStart(2, "0");
|
|
281
|
+
const year = date.getFullYear();
|
|
282
|
+
return `${month}/${day}/${year}`; // MM/DD/YYYY
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
### Localized Calendar
|
|
288
|
+
|
|
289
|
+
```javascript
|
|
290
|
+
new EssealDatePicker("#date-picker", {
|
|
291
|
+
locale: "fr-FR", // French locale
|
|
292
|
+
format: (date) => date.toLocaleDateString("fr-FR"),
|
|
293
|
+
});
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
## 🔧 Advanced Usage
|
|
297
|
+
|
|
298
|
+
### Multiple Pickers on One Page
|
|
299
|
+
|
|
300
|
+
```javascript
|
|
301
|
+
// Each instance is independent
|
|
302
|
+
const picker1 = new EssealDatePicker("#date1", {
|
|
303
|
+
primaryColor: "#3b82f6",
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
const picker2 = new EssealDatePicker("#date2", {
|
|
307
|
+
primaryColor: "#ef4444",
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const picker3 = new EssealDatePicker("#date3", {
|
|
311
|
+
mode: "range",
|
|
312
|
+
primaryColor: "#10b981",
|
|
313
|
+
});
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
### Programmatic Control
|
|
317
|
+
|
|
318
|
+
```javascript
|
|
319
|
+
const picker = new EssealDatePicker("#date-picker");
|
|
320
|
+
|
|
321
|
+
// Open programmatically
|
|
322
|
+
document.querySelector("#openBtn").addEventListener("click", () => {
|
|
323
|
+
picker.open();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// Close programmatically
|
|
327
|
+
document.querySelector("#closeBtn").addEventListener("click", () => {
|
|
328
|
+
picker.close();
|
|
329
|
+
});
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
### Working with Forms
|
|
333
|
+
|
|
334
|
+
```javascript
|
|
335
|
+
const form = document.querySelector("#myForm");
|
|
336
|
+
const picker = new EssealDatePicker("#dateInput", {
|
|
337
|
+
onChange: (date) => {
|
|
338
|
+
// The input value is automatically updated
|
|
339
|
+
// Form submission will include the formatted date
|
|
340
|
+
console.log("Form will submit:", form.dateInput.value);
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
form.addEventListener("submit", (e) => {
|
|
345
|
+
e.preventDefault();
|
|
346
|
+
const formData = new FormData(form);
|
|
347
|
+
console.log("Date submitted:", formData.get("dateInput"));
|
|
348
|
+
});
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
## ⚠️ Important Notes
|
|
352
|
+
|
|
353
|
+
### Color Format
|
|
354
|
+
|
|
355
|
+
The `primaryColor` option **must be in hexadecimal format** (`#RRGGBB` or `#RGB`). RGB and HSL formats are not currently supported.
|
|
356
|
+
|
|
357
|
+
✅ Valid:
|
|
358
|
+
|
|
359
|
+
- `#3b82f6`
|
|
360
|
+
- `#f00`
|
|
361
|
+
- `#FF5733`
|
|
362
|
+
|
|
363
|
+
❌ Invalid:
|
|
364
|
+
|
|
365
|
+
- `rgb(59, 130, 246)`
|
|
366
|
+
- `hsl(217, 91%, 60%)`
|
|
367
|
+
|
|
368
|
+
### React Integration
|
|
369
|
+
|
|
370
|
+
Always use the `destroy()` method in cleanup functions to prevent memory leaks:
|
|
371
|
+
|
|
372
|
+
```jsx
|
|
373
|
+
useEffect(() => {
|
|
374
|
+
const picker = new EssealDatePicker(inputRef.current);
|
|
375
|
+
|
|
376
|
+
return () => picker.destroy(); // ✅ Critical for preventing memory leaks
|
|
377
|
+
}, []);
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
### Read-Only Input Recommendation
|
|
381
|
+
|
|
382
|
+
It's recommended to set the input as `readonly` to prevent manual typing:
|
|
383
|
+
|
|
384
|
+
```html
|
|
385
|
+
<input type="text" id="date-picker" readonly />
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
## 🎯 Browser Support
|
|
389
|
+
|
|
390
|
+
- Chrome/Edge (latest)
|
|
391
|
+
- Firefox (latest)
|
|
392
|
+
- Safari (latest)
|
|
393
|
+
- Opera (latest)
|
|
394
|
+
|
|
395
|
+
**IE11 is not supported** (uses modern JavaScript features like `classList`, `dataset`, etc.)
|
|
396
|
+
|
|
397
|
+
## 📄 License
|
|
398
|
+
|
|
399
|
+
MIT License - feel free to use in personal and commercial projects.
|
|
400
|
+
|
|
401
|
+
## 📝 Changelog
|
|
402
|
+
|
|
403
|
+
### v1.0.0
|
|
404
|
+
|
|
405
|
+
- Complete rewrite with React-safe event handling
|
|
406
|
+
- Added range selection mode
|
|
407
|
+
- Improved performance with DocumentFragment rendering
|
|
408
|
+
- Better accessibility support
|
|
409
|
+
- TypeScript version (coming soon)
|
|
410
|
+
|
|
411
|
+
---
|
|
412
|
+
|
|
413
|
+
**Made with ❤️ for developers who need a simple, reliable date picker**
|
package/index.js
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EssealDatePicker v2.0.0
|
|
3
|
+
* A dependency-free, React-safe date picker.
|
|
4
|
+
*/
|
|
5
|
+
class EssealDatePicker {
|
|
6
|
+
constructor(target, options = {}) {
|
|
7
|
+
this.input = typeof target === 'string' ? document.querySelector(target) : target;
|
|
8
|
+
if (!this.input) throw new Error('EssealDatePicker: Target input not found.');
|
|
9
|
+
|
|
10
|
+
this.options = {
|
|
11
|
+
mode: 'single',
|
|
12
|
+
locale: navigator.language || 'en-US',
|
|
13
|
+
minDate: null,
|
|
14
|
+
maxDate: null,
|
|
15
|
+
primaryColor: '#3b82f6',
|
|
16
|
+
textColor: '#1f2937',
|
|
17
|
+
zIndex: 9999,
|
|
18
|
+
format: (date) => date.toLocaleDateString('en-CA'),
|
|
19
|
+
onChange: null,
|
|
20
|
+
...options,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
this.state = {
|
|
24
|
+
viewDate: new Date(),
|
|
25
|
+
selectedDate: null,
|
|
26
|
+
rangeStart: null,
|
|
27
|
+
rangeEnd: null,
|
|
28
|
+
isVisible: false,
|
|
29
|
+
view: 'day',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
if (this.options.minDate) this.options.minDate = this._normalizeDate(this.options.minDate);
|
|
33
|
+
if (this.options.maxDate) this.options.maxDate = this._normalizeDate(this.options.maxDate);
|
|
34
|
+
|
|
35
|
+
this._handleInputClick = this._handleInputClick.bind(this);
|
|
36
|
+
this._handleDocumentClick = this._handleDocumentClick.bind(this);
|
|
37
|
+
this._handleResize = this._handleResize.bind(this);
|
|
38
|
+
|
|
39
|
+
this._init();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
_init() {
|
|
43
|
+
this._injectStyles();
|
|
44
|
+
this._createDOM();
|
|
45
|
+
this._attachListeners();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
_injectStyles() {
|
|
49
|
+
const styleId = 'esseal-datepicker-styles';
|
|
50
|
+
if (document.getElementById(styleId)) return;
|
|
51
|
+
|
|
52
|
+
const css = `
|
|
53
|
+
.dp-container {
|
|
54
|
+
position: fixed;
|
|
55
|
+
background: #fff;
|
|
56
|
+
border: 1px solid #e5e7eb;
|
|
57
|
+
border-radius: 8px;
|
|
58
|
+
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
|
59
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
60
|
+
width: 280px;
|
|
61
|
+
padding: 16px;
|
|
62
|
+
display: none;
|
|
63
|
+
z-index: ${this.options.zIndex};
|
|
64
|
+
color: ${this.options.textColor};
|
|
65
|
+
user-select: none;
|
|
66
|
+
}
|
|
67
|
+
.dp-container.dp-visible { display: block; }
|
|
68
|
+
.dp-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
|
69
|
+
.dp-nav-btn { background: none; border: none; cursor: pointer; padding: 4px; border-radius: 4px; color: inherit; }
|
|
70
|
+
.dp-nav-btn:hover { background: #f3f4f6; }
|
|
71
|
+
.dp-title { font-weight: 600; cursor: pointer; padding: 4px 8px; border-radius: 4px; }
|
|
72
|
+
.dp-title:hover { background: #f3f4f6; }
|
|
73
|
+
.dp-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; }
|
|
74
|
+
.dp-grid-wide { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
|
75
|
+
.dp-cell {
|
|
76
|
+
height: 36px; display: flex; align-items: center; justify-content: center;
|
|
77
|
+
font-size: 0.875rem; cursor: pointer; border-radius: 4px;
|
|
78
|
+
}
|
|
79
|
+
.dp-label { font-size: 0.75rem; font-weight: 500; color: #9ca3af; cursor: default; }
|
|
80
|
+
.dp-cell:not(.dp-label):not(.dp-disabled):hover { background-color: #f3f4f6; }
|
|
81
|
+
.dp-other-month { color: #d1d5db; }
|
|
82
|
+
.dp-disabled { opacity: 0.3; cursor: not-allowed; text-decoration: line-through; }
|
|
83
|
+
.dp-selected, .dp-range-start, .dp-range-end { color: #fff !important; }
|
|
84
|
+
.dp-in-range { border-radius: 0; }
|
|
85
|
+
.dp-today { border: 1px solid ${this.options.primaryColor}; }
|
|
86
|
+
`;
|
|
87
|
+
|
|
88
|
+
const style = document.createElement('style');
|
|
89
|
+
style.id = styleId;
|
|
90
|
+
style.textContent = css;
|
|
91
|
+
document.head.appendChild(style);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
_createDOM() {
|
|
95
|
+
this.root = document.createElement('div');
|
|
96
|
+
this.root.className = 'dp-container';
|
|
97
|
+
this.root.setAttribute('role', 'dialog');
|
|
98
|
+
|
|
99
|
+
// Construct Header
|
|
100
|
+
const header = document.createElement('div');
|
|
101
|
+
header.className = 'dp-header';
|
|
102
|
+
|
|
103
|
+
const prevBtn = document.createElement('button');
|
|
104
|
+
prevBtn.className = 'dp-nav-btn';
|
|
105
|
+
prevBtn.dataset.action = 'prev';
|
|
106
|
+
prevBtn.innerHTML = '<';
|
|
107
|
+
|
|
108
|
+
const title = document.createElement('span');
|
|
109
|
+
title.className = 'dp-title';
|
|
110
|
+
title.dataset.action = 'switch-view';
|
|
111
|
+
|
|
112
|
+
const nextBtn = document.createElement('button');
|
|
113
|
+
nextBtn.className = 'dp-nav-btn';
|
|
114
|
+
nextBtn.dataset.action = 'next';
|
|
115
|
+
nextBtn.innerHTML = '>';
|
|
116
|
+
|
|
117
|
+
header.append(prevBtn, title, nextBtn);
|
|
118
|
+
|
|
119
|
+
const body = document.createElement('div');
|
|
120
|
+
body.className = 'dp-body';
|
|
121
|
+
|
|
122
|
+
this.root.append(header, body);
|
|
123
|
+
document.body.appendChild(this.root);
|
|
124
|
+
|
|
125
|
+
// Event Delegation
|
|
126
|
+
this.root.addEventListener('click', (e) => {
|
|
127
|
+
e.stopPropagation();
|
|
128
|
+
const target = e.target.closest('[data-action]') || e.target.closest('.dp-cell');
|
|
129
|
+
if (!target) return;
|
|
130
|
+
|
|
131
|
+
if (target.dataset.action) {
|
|
132
|
+
this._handleNavigation(target.dataset.action);
|
|
133
|
+
} else if (target.classList.contains('dp-cell') && !target.classList.contains('dp-disabled') && !target.classList.contains('dp-label')) {
|
|
134
|
+
this._handleSelection(target);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
_attachListeners() {
|
|
140
|
+
this.input.addEventListener('click', this._handleInputClick);
|
|
141
|
+
this.input.addEventListener('focus', this._handleInputClick);
|
|
142
|
+
document.addEventListener('click', this._handleDocumentClick);
|
|
143
|
+
window.addEventListener('resize', this._handleResize);
|
|
144
|
+
window.addEventListener('scroll', this._handleResize, true);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
destroy() {
|
|
148
|
+
this.root.remove();
|
|
149
|
+
this.input.removeEventListener('click', this._handleInputClick);
|
|
150
|
+
this.input.removeEventListener('focus', this._handleInputClick);
|
|
151
|
+
document.removeEventListener('click', this._handleDocumentClick);
|
|
152
|
+
window.removeEventListener('resize', this._handleResize);
|
|
153
|
+
window.removeEventListener('scroll', this._handleResize, true);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/* ================= Rendering ================= */
|
|
157
|
+
|
|
158
|
+
_render() {
|
|
159
|
+
const body = this.root.querySelector('.dp-body');
|
|
160
|
+
const title = this.root.querySelector('.dp-title');
|
|
161
|
+
body.replaceChildren(); // Safe and fast clearing
|
|
162
|
+
|
|
163
|
+
if (this.state.view === 'day') this._renderDays(body, title);
|
|
164
|
+
else if (this.state.view === 'month') this._renderMonths(body, title);
|
|
165
|
+
else this._renderYears(body, title);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
_renderDays(container, titleEl) {
|
|
169
|
+
container.className = 'dp-body dp-grid';
|
|
170
|
+
const year = this.state.viewDate.getFullYear();
|
|
171
|
+
const month = this.state.viewDate.getMonth();
|
|
172
|
+
titleEl.textContent = this.state.viewDate.toLocaleString(this.options.locale, { month: 'long', year: 'numeric' });
|
|
173
|
+
|
|
174
|
+
const frag = document.createDocumentFragment();
|
|
175
|
+
|
|
176
|
+
['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].forEach(d => {
|
|
177
|
+
const el = document.createElement('div');
|
|
178
|
+
el.className = 'dp-cell dp-label';
|
|
179
|
+
el.textContent = d;
|
|
180
|
+
frag.appendChild(el);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const firstDay = new Date(year, month, 1).getDay();
|
|
184
|
+
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
|
185
|
+
|
|
186
|
+
for (let i = 0; i < firstDay; i++) {
|
|
187
|
+
const el = document.createElement('div');
|
|
188
|
+
el.className = 'dp-cell dp-other-month';
|
|
189
|
+
frag.appendChild(el);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
for (let i = 1; i <= daysInMonth; i++) {
|
|
193
|
+
const date = new Date(year, month, i);
|
|
194
|
+
const ts = date.getTime();
|
|
195
|
+
const el = document.createElement('div');
|
|
196
|
+
el.className = 'dp-cell';
|
|
197
|
+
el.dataset.ts = ts;
|
|
198
|
+
el.textContent = i;
|
|
199
|
+
|
|
200
|
+
if ((this.options.minDate && ts < this.options.minDate.getTime()) ||
|
|
201
|
+
(this.options.maxDate && ts > this.options.maxDate.getTime())) {
|
|
202
|
+
el.classList.add('dp-disabled');
|
|
203
|
+
} else {
|
|
204
|
+
if (this.options.mode === 'single' && this.state.selectedDate && ts === this.state.selectedDate.getTime()) {
|
|
205
|
+
el.classList.add('dp-selected');
|
|
206
|
+
el.style.background = this.options.primaryColor;
|
|
207
|
+
}
|
|
208
|
+
if (this.options.mode === 'range' && this.state.rangeStart) {
|
|
209
|
+
const startTs = this.state.rangeStart.getTime();
|
|
210
|
+
if (ts === startTs) {
|
|
211
|
+
el.classList.add('dp-range-start');
|
|
212
|
+
el.style.background = this.options.primaryColor;
|
|
213
|
+
}
|
|
214
|
+
if (this.state.rangeEnd) {
|
|
215
|
+
const endTs = this.state.rangeEnd.getTime();
|
|
216
|
+
if (ts === endTs) {
|
|
217
|
+
el.classList.add('dp-range-end');
|
|
218
|
+
el.style.background = this.options.primaryColor;
|
|
219
|
+
}
|
|
220
|
+
if (ts > startTs && ts < endTs) {
|
|
221
|
+
el.classList.add('dp-in-range');
|
|
222
|
+
el.style.background = `${this.options.primaryColor}20`;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
frag.appendChild(el);
|
|
228
|
+
}
|
|
229
|
+
container.appendChild(frag);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
_renderMonths(container, titleEl) {
|
|
233
|
+
container.className = 'dp-body dp-grid-wide';
|
|
234
|
+
titleEl.textContent = this.state.viewDate.getFullYear();
|
|
235
|
+
const currentMonth = new Date().getMonth();
|
|
236
|
+
const currentYear = new Date().getFullYear();
|
|
237
|
+
const frag = document.createDocumentFragment();
|
|
238
|
+
|
|
239
|
+
for (let i = 0; i < 12; i++) {
|
|
240
|
+
const date = new Date(this.state.viewDate.getFullYear(), i, 1);
|
|
241
|
+
const el = document.createElement('div');
|
|
242
|
+
el.className = 'dp-cell';
|
|
243
|
+
if (i === currentMonth && currentYear === this.state.viewDate.getFullYear()) el.classList.add('dp-today');
|
|
244
|
+
el.dataset.ts = date.getTime();
|
|
245
|
+
el.textContent = date.toLocaleString(this.options.locale, { month: 'short' });
|
|
246
|
+
frag.appendChild(el);
|
|
247
|
+
}
|
|
248
|
+
container.appendChild(frag);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
_renderYears(container, titleEl) {
|
|
252
|
+
container.className = 'dp-body dp-grid-wide';
|
|
253
|
+
const startYear = Math.floor(this.state.viewDate.getFullYear() / 10) * 10;
|
|
254
|
+
titleEl.textContent = `${startYear} - ${startYear + 9}`;
|
|
255
|
+
const currentYear = new Date().getFullYear();
|
|
256
|
+
const frag = document.createDocumentFragment();
|
|
257
|
+
|
|
258
|
+
for (let i = 0; i < 12; i++) {
|
|
259
|
+
const year = startYear - 1 + i;
|
|
260
|
+
const date = new Date(year, 0, 1);
|
|
261
|
+
const el = document.createElement('div');
|
|
262
|
+
el.className = 'dp-cell';
|
|
263
|
+
if (year === currentYear) el.classList.add('dp-today');
|
|
264
|
+
if (i === 0 || i === 11) el.classList.add('dp-other-month');
|
|
265
|
+
el.dataset.ts = date.getTime();
|
|
266
|
+
el.textContent = year;
|
|
267
|
+
frag.appendChild(el);
|
|
268
|
+
}
|
|
269
|
+
container.appendChild(frag);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/* ================= Logic & Helpers ================= */
|
|
273
|
+
|
|
274
|
+
_updateInput(value) {
|
|
275
|
+
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value").set;
|
|
276
|
+
nativeInputValueSetter.call(this.input, value);
|
|
277
|
+
this.input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
278
|
+
this.input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
_handleSelection(target) {
|
|
282
|
+
const timestamp = parseInt(target.dataset.ts);
|
|
283
|
+
if (isNaN(timestamp)) return;
|
|
284
|
+
const rawDate = new Date(timestamp);
|
|
285
|
+
|
|
286
|
+
if (this.state.view !== 'day') {
|
|
287
|
+
if (this.state.view === 'year') {
|
|
288
|
+
this.state.viewDate.setFullYear(rawDate.getFullYear());
|
|
289
|
+
this.state.view = 'month';
|
|
290
|
+
} else {
|
|
291
|
+
this.state.viewDate.setMonth(rawDate.getMonth());
|
|
292
|
+
this.state.view = 'day';
|
|
293
|
+
}
|
|
294
|
+
this._render();
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (this.options.mode === 'single') {
|
|
299
|
+
this.state.selectedDate = rawDate;
|
|
300
|
+
this._updateInput(this.options.format(rawDate));
|
|
301
|
+
if (this.options.onChange) this.options.onChange(rawDate);
|
|
302
|
+
this.close();
|
|
303
|
+
} else {
|
|
304
|
+
if (!this.state.rangeStart || (this.state.rangeStart && this.state.rangeEnd)) {
|
|
305
|
+
this.state.rangeStart = rawDate;
|
|
306
|
+
this.state.rangeEnd = null;
|
|
307
|
+
this._updateInput(`${this.options.format(rawDate)} - ...`);
|
|
308
|
+
} else if (rawDate < this.state.rangeStart) {
|
|
309
|
+
this.state.rangeStart = rawDate;
|
|
310
|
+
this._updateInput(`${this.options.format(rawDate)} - ...`);
|
|
311
|
+
} else {
|
|
312
|
+
this.state.rangeEnd = rawDate;
|
|
313
|
+
this._updateInput(`${this.options.format(this.state.rangeStart)} - ${this.options.format(this.state.rangeEnd)}`);
|
|
314
|
+
if (this.options.onChange) this.options.onChange({ start: this.state.rangeStart, end: this.state.rangeEnd });
|
|
315
|
+
this.close();
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
this._render();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
_handleNavigation(action) {
|
|
322
|
+
const { view, viewDate } = this.state;
|
|
323
|
+
if (action === 'switch-view') {
|
|
324
|
+
this.state.view = view === 'day' ? 'month' : 'year';
|
|
325
|
+
} else {
|
|
326
|
+
const dir = action === 'next' ? 1 : -1;
|
|
327
|
+
if (view === 'day') viewDate.setMonth(viewDate.getMonth() + dir);
|
|
328
|
+
if (view === 'month') viewDate.setFullYear(viewDate.getFullYear() + dir);
|
|
329
|
+
if (view === 'year') viewDate.setFullYear(viewDate.getFullYear() + (dir * 10));
|
|
330
|
+
}
|
|
331
|
+
this._render();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
_position() {
|
|
335
|
+
if (!this.state.isVisible) return;
|
|
336
|
+
const rect = this.input.getBoundingClientRect();
|
|
337
|
+
this.root.style.top = `${rect.bottom + window.scrollY + 4}px`;
|
|
338
|
+
this.root.style.left = `${rect.left + window.scrollX}px`;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
open() {
|
|
342
|
+
this.state.isVisible = true;
|
|
343
|
+
this.root.classList.add('dp-visible');
|
|
344
|
+
this._position();
|
|
345
|
+
this._render();
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
close() {
|
|
349
|
+
this.state.isVisible = false;
|
|
350
|
+
this.root.classList.remove('dp-visible');
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
_normalizeDate(d) {
|
|
354
|
+
const date = new Date(d);
|
|
355
|
+
date.setHours(0, 0, 0, 0);
|
|
356
|
+
return date;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
_handleInputClick(e) {
|
|
360
|
+
e.preventDefault();
|
|
361
|
+
this.open();
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
_handleDocumentClick(e) {
|
|
365
|
+
if (this.state.isVisible && !this.root.contains(e.target) && e.target !== this.input) {
|
|
366
|
+
this.close();
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
_handleResize() {
|
|
371
|
+
if (this.state.isVisible) this._position();
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export default EssealDatePicker;
|
package/package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "esseal-date-picker",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A lightweight, dependency-free vanilla JS date picker with customisation support.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"style": "style.css",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"date-picker",
|
|
10
|
+
"calendar",
|
|
11
|
+
"vanilla-js",
|
|
12
|
+
"range-picker"
|
|
13
|
+
],
|
|
14
|
+
"author": "",
|
|
15
|
+
"license": "MIT"
|
|
16
|
+
}
|