@vanduo-oss/framework 1.2.6 → 1.2.7

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 (52) hide show
  1. package/README.md +31 -5
  2. package/css/components/affix.css +53 -0
  3. package/css/components/bubble.css +165 -0
  4. package/css/components/datepicker.css +216 -0
  5. package/css/components/fab.css +225 -0
  6. package/css/components/flow.css +265 -0
  7. package/css/components/rating.css +112 -0
  8. package/css/components/ripple.css +63 -0
  9. package/css/components/sidenav.css +70 -0
  10. package/css/components/spotlight.css +119 -0
  11. package/css/components/stepper.css +176 -0
  12. package/css/components/suggest.css +119 -0
  13. package/css/components/timeline.css +201 -0
  14. package/css/components/timepicker.css +80 -0
  15. package/css/components/transfer.css +165 -0
  16. package/css/components/tree.css +173 -0
  17. package/css/components/waypoint.css +59 -0
  18. package/css/vanduo.css +17 -0
  19. package/dist/build-info.json +3 -3
  20. package/dist/vanduo.cjs.js +2152 -4
  21. package/dist/vanduo.cjs.js.map +4 -4
  22. package/dist/vanduo.cjs.min.js +5 -5
  23. package/dist/vanduo.cjs.min.js.map +4 -4
  24. package/dist/vanduo.css +1943 -1
  25. package/dist/vanduo.css.map +1 -1
  26. package/dist/vanduo.esm.js +2152 -4
  27. package/dist/vanduo.esm.js.map +4 -4
  28. package/dist/vanduo.esm.min.js +5 -5
  29. package/dist/vanduo.esm.min.js.map +4 -4
  30. package/dist/vanduo.js +2152 -4
  31. package/dist/vanduo.js.map +4 -4
  32. package/dist/vanduo.min.css +2 -2
  33. package/dist/vanduo.min.css.map +1 -1
  34. package/dist/vanduo.min.js +5 -5
  35. package/dist/vanduo.min.js.map +4 -4
  36. package/js/components/affix.js +129 -0
  37. package/js/components/bubble.js +203 -0
  38. package/js/components/datepicker.js +287 -0
  39. package/js/components/flow.js +264 -0
  40. package/js/components/rating.js +160 -0
  41. package/js/components/ripple.js +74 -0
  42. package/js/components/sidenav.js +9 -2
  43. package/js/components/spotlight.js +295 -0
  44. package/js/components/stepper.js +97 -0
  45. package/js/components/suggest.js +219 -0
  46. package/js/components/timepicker.js +142 -0
  47. package/js/components/transfer.js +206 -0
  48. package/js/components/tree.js +191 -0
  49. package/js/components/validate.js +185 -0
  50. package/js/components/waypoint.js +120 -0
  51. package/js/index.js +16 -0
  52. package/package.json +1 -1
@@ -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 = '&#8249;';
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 = '&#8250;';
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
+ })();