@vanduo-oss/framework 1.2.6 → 1.2.8
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 +39 -5
- package/css/components/affix.css +53 -0
- package/css/components/bubble.css +165 -0
- package/css/components/datepicker.css +216 -0
- package/css/components/fab.css +225 -0
- package/css/components/flow.css +265 -0
- package/css/components/rating.css +112 -0
- package/css/components/ripple.css +63 -0
- package/css/components/sidenav.css +70 -0
- package/css/components/spotlight.css +119 -0
- package/css/components/stepper.css +176 -0
- package/css/components/suggest.css +119 -0
- package/css/components/timeline.css +201 -0
- package/css/components/timepicker.css +80 -0
- package/css/components/transfer.css +165 -0
- package/css/components/tree.css +173 -0
- package/css/components/waypoint.css +59 -0
- package/css/vanduo.css +17 -0
- package/dist/build-info.json +3 -3
- package/dist/vanduo.cjs.js +2161 -6
- package/dist/vanduo.cjs.js.map +4 -4
- package/dist/vanduo.cjs.min.js +5 -5
- package/dist/vanduo.cjs.min.js.map +4 -4
- package/dist/vanduo.css +1947 -5
- package/dist/vanduo.css.map +1 -1
- package/dist/vanduo.esm.js +2161 -6
- package/dist/vanduo.esm.js.map +4 -4
- package/dist/vanduo.esm.min.js +5 -5
- package/dist/vanduo.esm.min.js.map +4 -4
- package/dist/vanduo.js +2161 -6
- package/dist/vanduo.js.map +4 -4
- package/dist/vanduo.min.css +2 -2
- package/dist/vanduo.min.css.map +1 -1
- package/dist/vanduo.min.js +5 -5
- package/dist/vanduo.min.js.map +4 -4
- package/js/components/affix.js +129 -0
- package/js/components/bubble.js +203 -0
- package/js/components/datepicker.js +287 -0
- package/js/components/flow.js +264 -0
- package/js/components/rating.js +160 -0
- package/js/components/ripple.js +74 -0
- package/js/components/sidenav.js +9 -2
- package/js/components/spotlight.js +295 -0
- package/js/components/stepper.js +97 -0
- package/js/components/suggest.js +219 -0
- package/js/components/theme-customizer.js +11 -2
- package/js/components/theme-switcher.js +7 -0
- package/js/components/timepicker.js +142 -0
- package/js/components/transfer.js +206 -0
- package/js/components/tree.js +191 -0
- package/js/components/validate.js +185 -0
- package/js/components/waypoint.js +120 -0
- package/js/index.js +16 -0
- package/package.json +4 -4
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vanduo Framework - Affix (Sticky) Component
|
|
3
|
+
* Uses IntersectionObserver to toggle .is-stuck within the nearest scrollable parent
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
(function () {
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
function isScrollable(element) {
|
|
10
|
+
if (!element || element === document.body) return false;
|
|
11
|
+
|
|
12
|
+
const style = window.getComputedStyle(element);
|
|
13
|
+
const overflowY = style.overflowY;
|
|
14
|
+
const overflowX = style.overflowX;
|
|
15
|
+
const canScrollY = /(auto|scroll|overlay)/.test(overflowY) && element.scrollHeight > element.clientHeight;
|
|
16
|
+
const canScrollX = /(auto|scroll|overlay)/.test(overflowX) && element.scrollWidth > element.clientWidth;
|
|
17
|
+
|
|
18
|
+
return canScrollY || canScrollX;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function getScrollParent(element) {
|
|
22
|
+
let parent = element.parentElement;
|
|
23
|
+
|
|
24
|
+
while (parent && parent !== document.body && parent !== document.documentElement) {
|
|
25
|
+
if (isScrollable(parent)) return parent;
|
|
26
|
+
parent = parent.parentElement;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const Affix = {
|
|
33
|
+
instances: new Map(),
|
|
34
|
+
|
|
35
|
+
init: function () {
|
|
36
|
+
const elements = document.querySelectorAll('.vd-affix, .vd-sticky, [data-vd-affix]');
|
|
37
|
+
elements.forEach(el => {
|
|
38
|
+
if (this.instances.has(el)) return;
|
|
39
|
+
this.initInstance(el);
|
|
40
|
+
});
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
initInstance: function (el) {
|
|
44
|
+
const cleanup = [];
|
|
45
|
+
const parsedOffset = parseInt(el.getAttribute('data-vd-affix-offset') || '0', 10);
|
|
46
|
+
const offset = Number.isNaN(parsedOffset) ? 0 : parsedOffset;
|
|
47
|
+
const scrollParent = getScrollParent(el);
|
|
48
|
+
let isStuck = false;
|
|
49
|
+
|
|
50
|
+
const sentinel = document.createElement('div');
|
|
51
|
+
sentinel.style.cssText = 'display:block;height:1px;margin-bottom:-1px;visibility:hidden;pointer-events:none;';
|
|
52
|
+
el.parentNode.insertBefore(sentinel, el);
|
|
53
|
+
|
|
54
|
+
el.style.setProperty('--affix-top-offset', offset + 'px');
|
|
55
|
+
|
|
56
|
+
function stick() {
|
|
57
|
+
if (isStuck) return;
|
|
58
|
+
isStuck = true;
|
|
59
|
+
el.classList.add('is-stuck');
|
|
60
|
+
el.dispatchEvent(new CustomEvent('affix:stuck', {
|
|
61
|
+
bubbles: true,
|
|
62
|
+
detail: {
|
|
63
|
+
offset: offset,
|
|
64
|
+
root: scrollParent || window
|
|
65
|
+
}
|
|
66
|
+
}));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function unstick() {
|
|
70
|
+
if (!isStuck) return;
|
|
71
|
+
isStuck = false;
|
|
72
|
+
el.classList.remove('is-stuck');
|
|
73
|
+
el.dispatchEvent(new CustomEvent('affix:unstuck', {
|
|
74
|
+
bubbles: true,
|
|
75
|
+
detail: {
|
|
76
|
+
offset: offset,
|
|
77
|
+
root: scrollParent || window
|
|
78
|
+
}
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const observer = new IntersectionObserver(function (entries) {
|
|
83
|
+
entries.forEach(entry => {
|
|
84
|
+
if (!entry.isIntersecting) {
|
|
85
|
+
stick();
|
|
86
|
+
} else {
|
|
87
|
+
unstick();
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}, {
|
|
91
|
+
root: scrollParent,
|
|
92
|
+
rootMargin: '-' + offset + 'px 0px 0px 0px',
|
|
93
|
+
threshold: 0
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
observer.observe(sentinel);
|
|
97
|
+
|
|
98
|
+
cleanup.push(
|
|
99
|
+
() => observer.disconnect(),
|
|
100
|
+
() => { if (sentinel.parentNode) sentinel.parentNode.removeChild(sentinel); },
|
|
101
|
+
() => {
|
|
102
|
+
el.classList.remove('is-stuck');
|
|
103
|
+
el.style.removeProperty('--affix-top-offset');
|
|
104
|
+
}
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
this.instances.set(el, { cleanup, observer, sentinel, scrollParent });
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
destroy: function (el) {
|
|
111
|
+
const instance = this.instances.get(el);
|
|
112
|
+
if (!instance) return;
|
|
113
|
+
instance.cleanup.forEach(fn => fn());
|
|
114
|
+
el.classList.remove('is-stuck');
|
|
115
|
+
this.instances.delete(el);
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
destroyAll: function () {
|
|
119
|
+
this.instances.forEach((_, el) => this.destroy(el));
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
if (typeof window.Vanduo !== 'undefined') {
|
|
124
|
+
window.Vanduo.register('affix', Affix);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
window.VanduoAffix = Affix;
|
|
128
|
+
|
|
129
|
+
})();
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vanduo Framework - Bubble (Popover) Component
|
|
3
|
+
* Click-triggered rich HTML popover, reuses tooltip positioning concepts
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
(function () {
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
const Bubble = {
|
|
10
|
+
instances: new Map(),
|
|
11
|
+
_globalCleanups: [],
|
|
12
|
+
|
|
13
|
+
init: function () {
|
|
14
|
+
const triggers = document.querySelectorAll('[data-vd-bubble], [data-vd-popover]');
|
|
15
|
+
triggers.forEach(el => {
|
|
16
|
+
if (this.instances.has(el)) return;
|
|
17
|
+
this.initInstance(el);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
if (this._globalCleanups.length === 0) {
|
|
21
|
+
const outsideClick = (e) => {
|
|
22
|
+
this.instances.forEach((inst, trigger) => {
|
|
23
|
+
if (!inst.popover.contains(e.target) && !trigger.contains(e.target)) {
|
|
24
|
+
this.hide(trigger);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
};
|
|
28
|
+
const escHandler = (e) => {
|
|
29
|
+
if (e.key === 'Escape') {
|
|
30
|
+
this.instances.forEach((_, trigger) => this.hide(trigger));
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
document.addEventListener('click', outsideClick, true);
|
|
34
|
+
document.addEventListener('keydown', escHandler);
|
|
35
|
+
this._globalCleanups.push(
|
|
36
|
+
() => document.removeEventListener('click', outsideClick, true),
|
|
37
|
+
() => document.removeEventListener('keydown', escHandler)
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
initInstance: function (trigger) {
|
|
43
|
+
const cleanup = [];
|
|
44
|
+
const placement = trigger.getAttribute('data-vd-bubble-placement') ||
|
|
45
|
+
trigger.getAttribute('data-vd-popover-placement') || 'bottom';
|
|
46
|
+
|
|
47
|
+
// Build popover element
|
|
48
|
+
const popover = document.createElement('div');
|
|
49
|
+
popover.className = 'vd-bubble-content';
|
|
50
|
+
popover.setAttribute('role', 'dialog');
|
|
51
|
+
popover.setAttribute('aria-modal', 'false');
|
|
52
|
+
popover.setAttribute('data-placement', placement);
|
|
53
|
+
|
|
54
|
+
const title = trigger.getAttribute('data-vd-bubble-title') ||
|
|
55
|
+
trigger.getAttribute('data-vd-popover-title');
|
|
56
|
+
const content = trigger.getAttribute('data-vd-bubble') ||
|
|
57
|
+
trigger.getAttribute('data-vd-popover') || '';
|
|
58
|
+
const htmlContent = trigger.getAttribute('data-vd-bubble-html') ||
|
|
59
|
+
trigger.getAttribute('data-vd-popover-html');
|
|
60
|
+
|
|
61
|
+
if (title) {
|
|
62
|
+
const header = document.createElement('div');
|
|
63
|
+
header.className = 'vd-bubble-header';
|
|
64
|
+
const titleSpan = document.createElement('span');
|
|
65
|
+
titleSpan.textContent = title;
|
|
66
|
+
const closeBtn = document.createElement('button');
|
|
67
|
+
closeBtn.className = 'vd-bubble-close';
|
|
68
|
+
closeBtn.setAttribute('aria-label', 'Close');
|
|
69
|
+
closeBtn.innerHTML = '×';
|
|
70
|
+
header.appendChild(titleSpan);
|
|
71
|
+
header.appendChild(closeBtn);
|
|
72
|
+
popover.appendChild(header);
|
|
73
|
+
|
|
74
|
+
const closeHandler = (e) => { e.stopPropagation(); this.hide(trigger); };
|
|
75
|
+
closeBtn.addEventListener('click', closeHandler);
|
|
76
|
+
cleanup.push(() => closeBtn.removeEventListener('click', closeHandler));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const body = document.createElement('div');
|
|
80
|
+
body.className = 'vd-bubble-body';
|
|
81
|
+
if (htmlContent) {
|
|
82
|
+
if (typeof sanitizeHtml === 'function') {
|
|
83
|
+
body.innerHTML = sanitizeHtml(htmlContent);
|
|
84
|
+
} else {
|
|
85
|
+
body.textContent = htmlContent;
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
body.textContent = content;
|
|
89
|
+
}
|
|
90
|
+
popover.appendChild(body);
|
|
91
|
+
|
|
92
|
+
document.body.appendChild(popover);
|
|
93
|
+
|
|
94
|
+
// ARIA on trigger
|
|
95
|
+
const popId = 'vd-bubble-' + Math.random().toString(36).slice(2, 9);
|
|
96
|
+
popover.id = popId;
|
|
97
|
+
trigger.setAttribute('aria-haspopup', 'dialog');
|
|
98
|
+
trigger.setAttribute('aria-expanded', 'false');
|
|
99
|
+
trigger.setAttribute('aria-controls', popId);
|
|
100
|
+
|
|
101
|
+
// Toggle on click
|
|
102
|
+
const toggleHandler = (e) => {
|
|
103
|
+
e.stopPropagation();
|
|
104
|
+
if (popover.classList.contains('is-visible')) {
|
|
105
|
+
this.hide(trigger);
|
|
106
|
+
} else {
|
|
107
|
+
this.hideAll();
|
|
108
|
+
this.show(trigger);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
trigger.addEventListener('click', toggleHandler);
|
|
112
|
+
cleanup.push(() => trigger.removeEventListener('click', toggleHandler));
|
|
113
|
+
|
|
114
|
+
this.instances.set(trigger, { popover, cleanup, placement });
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
position: function (trigger, popover, placement) {
|
|
118
|
+
const rect = trigger.getBoundingClientRect();
|
|
119
|
+
const popRect = popover.getBoundingClientRect();
|
|
120
|
+
const gap = 10;
|
|
121
|
+
let top, left;
|
|
122
|
+
|
|
123
|
+
switch (placement) {
|
|
124
|
+
case 'top':
|
|
125
|
+
top = rect.top - popRect.height - gap + window.scrollY;
|
|
126
|
+
left = rect.left + (rect.width - popRect.width) / 2 + window.scrollX;
|
|
127
|
+
break;
|
|
128
|
+
case 'left':
|
|
129
|
+
top = rect.top + (rect.height - popRect.height) / 2 + window.scrollY;
|
|
130
|
+
left = rect.left - popRect.width - gap + window.scrollX;
|
|
131
|
+
break;
|
|
132
|
+
case 'right':
|
|
133
|
+
top = rect.top + (rect.height - popRect.height) / 2 + window.scrollY;
|
|
134
|
+
left = rect.right + gap + window.scrollX;
|
|
135
|
+
break;
|
|
136
|
+
default: // bottom
|
|
137
|
+
top = rect.bottom + gap + window.scrollY;
|
|
138
|
+
left = rect.left + (rect.width - popRect.width) / 2 + window.scrollX;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Clamp to viewport
|
|
142
|
+
left = Math.max(8, Math.min(left, window.innerWidth - popRect.width - 8));
|
|
143
|
+
top = Math.max(8, top);
|
|
144
|
+
|
|
145
|
+
popover.style.top = top + 'px';
|
|
146
|
+
popover.style.left = left + 'px';
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
show: function (trigger) {
|
|
150
|
+
const instance = this.instances.get(trigger);
|
|
151
|
+
if (!instance) return;
|
|
152
|
+
const { popover, placement } = instance;
|
|
153
|
+
|
|
154
|
+
popover.style.display = 'block';
|
|
155
|
+
popover.classList.add('is-visible');
|
|
156
|
+
trigger.setAttribute('aria-expanded', 'true');
|
|
157
|
+
|
|
158
|
+
requestAnimationFrame(() => {
|
|
159
|
+
this.position(trigger, popover, placement);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
trigger.dispatchEvent(new CustomEvent('bubble:show', { bubbles: true }));
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
hide: function (trigger) {
|
|
166
|
+
const instance = this.instances.get(trigger);
|
|
167
|
+
if (!instance) return;
|
|
168
|
+
instance.popover.classList.remove('is-visible');
|
|
169
|
+
trigger.setAttribute('aria-expanded', 'false');
|
|
170
|
+
trigger.dispatchEvent(new CustomEvent('bubble:hide', { bubbles: true }));
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
hideAll: function () {
|
|
174
|
+
this.instances.forEach((_, trigger) => this.hide(trigger));
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
destroy: function (trigger) {
|
|
178
|
+
const instance = this.instances.get(trigger);
|
|
179
|
+
if (!instance) return;
|
|
180
|
+
instance.cleanup.forEach(fn => fn());
|
|
181
|
+
if (instance.popover.parentNode) {
|
|
182
|
+
instance.popover.parentNode.removeChild(instance.popover);
|
|
183
|
+
}
|
|
184
|
+
trigger.removeAttribute('aria-haspopup');
|
|
185
|
+
trigger.removeAttribute('aria-expanded');
|
|
186
|
+
trigger.removeAttribute('aria-controls');
|
|
187
|
+
this.instances.delete(trigger);
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
destroyAll: function () {
|
|
191
|
+
this.instances.forEach((_, trigger) => this.destroy(trigger));
|
|
192
|
+
this._globalCleanups.forEach(fn => fn());
|
|
193
|
+
this._globalCleanups = [];
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
if (typeof window.Vanduo !== 'undefined') {
|
|
198
|
+
window.Vanduo.register('bubble', Bubble);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
window.VanduoBubble = Bubble;
|
|
202
|
+
|
|
203
|
+
})();
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vanduo Framework - Datepicker Component
|
|
3
|
+
* Calendar popup attached to input field with month/year navigation
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
(function () {
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
const DAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
|
|
10
|
+
const MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
|
11
|
+
|
|
12
|
+
const Datepicker = {
|
|
13
|
+
instances: new Map(),
|
|
14
|
+
|
|
15
|
+
init: function () {
|
|
16
|
+
const inputs = document.querySelectorAll('[data-vd-datepicker]');
|
|
17
|
+
inputs.forEach(el => {
|
|
18
|
+
if (this.instances.has(el)) return;
|
|
19
|
+
this.initInstance(el);
|
|
20
|
+
});
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
initInstance: function (input) {
|
|
24
|
+
const cleanup = [];
|
|
25
|
+
const format = input.getAttribute('data-vd-datepicker-format') || 'yyyy-mm-dd';
|
|
26
|
+
const minStr = input.getAttribute('data-vd-datepicker-min');
|
|
27
|
+
const maxStr = input.getAttribute('data-vd-datepicker-max');
|
|
28
|
+
const minDate = minStr ? new Date(minStr) : null;
|
|
29
|
+
const maxDate = maxStr ? new Date(maxStr) : null;
|
|
30
|
+
|
|
31
|
+
const today = new Date();
|
|
32
|
+
let viewYear = today.getFullYear();
|
|
33
|
+
let viewMonth = today.getMonth();
|
|
34
|
+
let selectedDate = null;
|
|
35
|
+
let viewMode = 'days'; // days | months | years
|
|
36
|
+
|
|
37
|
+
// Parse existing value
|
|
38
|
+
if (input.value) {
|
|
39
|
+
const parsed = new Date(input.value);
|
|
40
|
+
if (!isNaN(parsed.getTime())) {
|
|
41
|
+
selectedDate = parsed;
|
|
42
|
+
viewYear = parsed.getFullYear();
|
|
43
|
+
viewMonth = parsed.getMonth();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Create popup
|
|
48
|
+
const popup = document.createElement('div');
|
|
49
|
+
popup.className = 'vd-datepicker-popup';
|
|
50
|
+
popup.setAttribute('role', 'dialog');
|
|
51
|
+
popup.setAttribute('aria-label', 'Choose date');
|
|
52
|
+
|
|
53
|
+
const wrapper = document.createElement('div');
|
|
54
|
+
wrapper.className = 'vd-suggest-wrapper';
|
|
55
|
+
wrapper.style.position = 'relative';
|
|
56
|
+
wrapper.style.display = 'inline-block';
|
|
57
|
+
input.parentNode.insertBefore(wrapper, input);
|
|
58
|
+
wrapper.appendChild(input);
|
|
59
|
+
wrapper.appendChild(popup);
|
|
60
|
+
|
|
61
|
+
const formatDate = (d) => {
|
|
62
|
+
const yyyy = d.getFullYear();
|
|
63
|
+
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
|
64
|
+
const dd = String(d.getDate()).padStart(2, '0');
|
|
65
|
+
return format.replace('yyyy', yyyy).replace('mm', mm).replace('dd', dd);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const isDisabled = (d) => {
|
|
69
|
+
if (minDate && d < minDate) return true;
|
|
70
|
+
if (maxDate && d > maxDate) return true;
|
|
71
|
+
return false;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const isSameDay = (a, b) => a && b &&
|
|
75
|
+
a.getFullYear() === b.getFullYear() &&
|
|
76
|
+
a.getMonth() === b.getMonth() &&
|
|
77
|
+
a.getDate() === b.getDate();
|
|
78
|
+
|
|
79
|
+
const render = () => {
|
|
80
|
+
popup.innerHTML = '';
|
|
81
|
+
|
|
82
|
+
// Header
|
|
83
|
+
const header = document.createElement('div');
|
|
84
|
+
header.className = 'vd-datepicker-header';
|
|
85
|
+
|
|
86
|
+
const prevBtn = document.createElement('button');
|
|
87
|
+
prevBtn.type = 'button';
|
|
88
|
+
prevBtn.className = 'vd-datepicker-prev';
|
|
89
|
+
prevBtn.innerHTML = '‹';
|
|
90
|
+
prevBtn.setAttribute('aria-label', 'Previous');
|
|
91
|
+
|
|
92
|
+
const nextBtn = document.createElement('button');
|
|
93
|
+
nextBtn.type = 'button';
|
|
94
|
+
nextBtn.className = 'vd-datepicker-next';
|
|
95
|
+
nextBtn.innerHTML = '›';
|
|
96
|
+
nextBtn.setAttribute('aria-label', 'Next');
|
|
97
|
+
|
|
98
|
+
const title = document.createElement('span');
|
|
99
|
+
title.className = 'vd-datepicker-title';
|
|
100
|
+
|
|
101
|
+
if (viewMode === 'days') {
|
|
102
|
+
title.textContent = MONTHS[viewMonth] + ' ' + viewYear;
|
|
103
|
+
title.addEventListener('click', () => { viewMode = 'months'; render(); });
|
|
104
|
+
prevBtn.addEventListener('click', () => { viewMonth--; if (viewMonth < 0) { viewMonth = 11; viewYear--; } render(); });
|
|
105
|
+
nextBtn.addEventListener('click', () => { viewMonth++; if (viewMonth > 11) { viewMonth = 0; viewYear++; } render(); });
|
|
106
|
+
} else if (viewMode === 'months') {
|
|
107
|
+
title.textContent = String(viewYear);
|
|
108
|
+
title.addEventListener('click', () => { viewMode = 'years'; render(); });
|
|
109
|
+
prevBtn.addEventListener('click', () => { viewYear--; render(); });
|
|
110
|
+
nextBtn.addEventListener('click', () => { viewYear++; render(); });
|
|
111
|
+
} else {
|
|
112
|
+
const decadeStart = Math.floor(viewYear / 10) * 10;
|
|
113
|
+
title.textContent = decadeStart + ' - ' + (decadeStart + 9);
|
|
114
|
+
prevBtn.addEventListener('click', () => { viewYear -= 10; render(); });
|
|
115
|
+
nextBtn.addEventListener('click', () => { viewYear += 10; render(); });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
header.appendChild(prevBtn);
|
|
119
|
+
header.appendChild(title);
|
|
120
|
+
header.appendChild(nextBtn);
|
|
121
|
+
popup.appendChild(header);
|
|
122
|
+
|
|
123
|
+
if (viewMode === 'days') {
|
|
124
|
+
// Weekday headers
|
|
125
|
+
const weekdays = document.createElement('div');
|
|
126
|
+
weekdays.className = 'vd-datepicker-weekdays';
|
|
127
|
+
DAYS.forEach(d => {
|
|
128
|
+
const span = document.createElement('span');
|
|
129
|
+
span.textContent = d;
|
|
130
|
+
weekdays.appendChild(span);
|
|
131
|
+
});
|
|
132
|
+
popup.appendChild(weekdays);
|
|
133
|
+
|
|
134
|
+
// Days grid
|
|
135
|
+
const grid = document.createElement('div');
|
|
136
|
+
grid.className = 'vd-datepicker-days';
|
|
137
|
+
|
|
138
|
+
const firstDay = new Date(viewYear, viewMonth, 1).getDay();
|
|
139
|
+
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
|
|
140
|
+
const daysInPrev = new Date(viewYear, viewMonth, 0).getDate();
|
|
141
|
+
|
|
142
|
+
// Previous month padding
|
|
143
|
+
for (let i = firstDay - 1; i >= 0; i--) {
|
|
144
|
+
const btn = createDayBtn(daysInPrev - i, true);
|
|
145
|
+
grid.appendChild(btn);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Current month
|
|
149
|
+
for (let d = 1; d <= daysInMonth; d++) {
|
|
150
|
+
const date = new Date(viewYear, viewMonth, d);
|
|
151
|
+
const btn = createDayBtn(d, false, date);
|
|
152
|
+
grid.appendChild(btn);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Next month padding
|
|
156
|
+
const totalCells = firstDay + daysInMonth;
|
|
157
|
+
const remaining = totalCells % 7 === 0 ? 0 : 7 - (totalCells % 7);
|
|
158
|
+
for (let i = 1; i <= remaining; i++) {
|
|
159
|
+
const btn = createDayBtn(i, true);
|
|
160
|
+
grid.appendChild(btn);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
popup.appendChild(grid);
|
|
164
|
+
} else if (viewMode === 'months') {
|
|
165
|
+
const grid = document.createElement('div');
|
|
166
|
+
grid.className = 'vd-datepicker-months';
|
|
167
|
+
MONTHS.forEach((name, i) => {
|
|
168
|
+
const btn = document.createElement('button');
|
|
169
|
+
btn.type = 'button';
|
|
170
|
+
btn.className = 'vd-datepicker-month-btn';
|
|
171
|
+
btn.textContent = name.slice(0, 3);
|
|
172
|
+
if (selectedDate && selectedDate.getFullYear() === viewYear && selectedDate.getMonth() === i) {
|
|
173
|
+
btn.classList.add('is-selected');
|
|
174
|
+
}
|
|
175
|
+
btn.addEventListener('click', () => { viewMonth = i; viewMode = 'days'; render(); });
|
|
176
|
+
grid.appendChild(btn);
|
|
177
|
+
});
|
|
178
|
+
popup.appendChild(grid);
|
|
179
|
+
} else {
|
|
180
|
+
const grid = document.createElement('div');
|
|
181
|
+
grid.className = 'vd-datepicker-years';
|
|
182
|
+
const decadeStart = Math.floor(viewYear / 10) * 10;
|
|
183
|
+
for (let y = decadeStart - 1; y <= decadeStart + 10; y++) {
|
|
184
|
+
const btn = document.createElement('button');
|
|
185
|
+
btn.type = 'button';
|
|
186
|
+
btn.className = 'vd-datepicker-year-btn';
|
|
187
|
+
btn.textContent = y;
|
|
188
|
+
if (selectedDate && selectedDate.getFullYear() === y) btn.classList.add('is-selected');
|
|
189
|
+
if (y < decadeStart || y > decadeStart + 9) btn.style.opacity = '0.4';
|
|
190
|
+
btn.addEventListener('click', () => { viewYear = y; viewMode = 'months'; render(); });
|
|
191
|
+
grid.appendChild(btn);
|
|
192
|
+
}
|
|
193
|
+
popup.appendChild(grid);
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const createDayBtn = (day, outside, date) => {
|
|
198
|
+
const btn = document.createElement('button');
|
|
199
|
+
btn.type = 'button';
|
|
200
|
+
btn.className = 'vd-datepicker-day';
|
|
201
|
+
btn.textContent = day;
|
|
202
|
+
|
|
203
|
+
if (outside) {
|
|
204
|
+
btn.classList.add('is-outside');
|
|
205
|
+
btn.tabIndex = -1;
|
|
206
|
+
return btn;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (date && isSameDay(date, today)) btn.classList.add('is-today');
|
|
210
|
+
if (date && isSameDay(date, selectedDate)) btn.classList.add('is-selected');
|
|
211
|
+
if (date && isDisabled(date)) {
|
|
212
|
+
btn.classList.add('is-disabled');
|
|
213
|
+
return btn;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (date) {
|
|
217
|
+
btn.addEventListener('click', () => {
|
|
218
|
+
selectedDate = date;
|
|
219
|
+
viewYear = date.getFullYear();
|
|
220
|
+
viewMonth = date.getMonth();
|
|
221
|
+
input.value = formatDate(date);
|
|
222
|
+
close();
|
|
223
|
+
input.dispatchEvent(new CustomEvent('datepicker:select', {
|
|
224
|
+
detail: { date, formatted: input.value },
|
|
225
|
+
bubbles: true
|
|
226
|
+
}));
|
|
227
|
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return btn;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const open = () => {
|
|
235
|
+
render();
|
|
236
|
+
popup.classList.add('is-open');
|
|
237
|
+
input.setAttribute('aria-expanded', 'true');
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const close = () => {
|
|
241
|
+
popup.classList.remove('is-open');
|
|
242
|
+
input.setAttribute('aria-expanded', 'false');
|
|
243
|
+
viewMode = 'days';
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
// Events
|
|
247
|
+
const focusHandler = () => open();
|
|
248
|
+
const outsideHandler = (e) => {
|
|
249
|
+
if (!wrapper.contains(e.target)) close();
|
|
250
|
+
};
|
|
251
|
+
const escHandler = (e) => { if (e.key === 'Escape') close(); };
|
|
252
|
+
|
|
253
|
+
input.addEventListener('focus', focusHandler);
|
|
254
|
+
document.addEventListener('click', outsideHandler, true);
|
|
255
|
+
document.addEventListener('keydown', escHandler);
|
|
256
|
+
input.setAttribute('aria-haspopup', 'dialog');
|
|
257
|
+
input.setAttribute('aria-expanded', 'false');
|
|
258
|
+
input.setAttribute('autocomplete', 'off');
|
|
259
|
+
|
|
260
|
+
cleanup.push(
|
|
261
|
+
() => input.removeEventListener('focus', focusHandler),
|
|
262
|
+
() => document.removeEventListener('click', outsideHandler, true),
|
|
263
|
+
() => document.removeEventListener('keydown', escHandler)
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
this.instances.set(input, { cleanup, open, close, popup });
|
|
267
|
+
},
|
|
268
|
+
|
|
269
|
+
destroy: function (el) {
|
|
270
|
+
const instance = this.instances.get(el);
|
|
271
|
+
if (!instance) return;
|
|
272
|
+
instance.cleanup.forEach(fn => fn());
|
|
273
|
+
this.instances.delete(el);
|
|
274
|
+
},
|
|
275
|
+
|
|
276
|
+
destroyAll: function () {
|
|
277
|
+
this.instances.forEach((_, el) => this.destroy(el));
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
if (typeof window.Vanduo !== 'undefined') {
|
|
282
|
+
window.Vanduo.register('datepicker', Datepicker);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
window.VanduoDatepicker = Datepicker;
|
|
286
|
+
|
|
287
|
+
})();
|