@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,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vanduo Framework - Spotlight (Feature Discovery) Component
|
|
3
|
+
* Guided tour with overlay highlight and step-through tooltip
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
(function () {
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
const Spotlight = {
|
|
10
|
+
_active: false,
|
|
11
|
+
_steps: [],
|
|
12
|
+
_currentStep: 0,
|
|
13
|
+
_elements: {},
|
|
14
|
+
_cleanup: [],
|
|
15
|
+
_boundTriggers: new WeakMap(),
|
|
16
|
+
_triggerElement: null,
|
|
17
|
+
|
|
18
|
+
init: function () {
|
|
19
|
+
const triggers = document.querySelectorAll('[data-vd-spotlight]');
|
|
20
|
+
|
|
21
|
+
triggers.forEach(trigger => {
|
|
22
|
+
if (this._boundTriggers.has(trigger)) return;
|
|
23
|
+
|
|
24
|
+
const clickHandler = (event) => {
|
|
25
|
+
event.preventDefault();
|
|
26
|
+
|
|
27
|
+
const steps = this._parseSteps(trigger.getAttribute('data-vd-spotlight'));
|
|
28
|
+
if (steps.length === 0) return;
|
|
29
|
+
|
|
30
|
+
this.start(steps, { trigger });
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
trigger.addEventListener('click', clickHandler);
|
|
34
|
+
this._boundTriggers.set(trigger, clickHandler);
|
|
35
|
+
});
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
_parseSteps: function (raw) {
|
|
39
|
+
if (typeof raw !== 'string' || raw.trim() === '') return [];
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const parsed = JSON.parse(raw);
|
|
43
|
+
return this._normalizeSteps(parsed);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
console.error('VanduoSpotlight: invalid data-vd-spotlight payload.', error);
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
_normalizeStep: function (step) {
|
|
51
|
+
if (!step || typeof step !== 'object') return null;
|
|
52
|
+
|
|
53
|
+
const target = step.target;
|
|
54
|
+
const hasSelectorTarget = typeof target === 'string' && target.trim() !== '';
|
|
55
|
+
const hasElementTarget = typeof Element !== 'undefined' && target instanceof Element;
|
|
56
|
+
|
|
57
|
+
if (!hasSelectorTarget && !hasElementTarget) return null;
|
|
58
|
+
|
|
59
|
+
const title = typeof step.title === 'string' ? step.title : '';
|
|
60
|
+
const description = typeof step.description === 'string'
|
|
61
|
+
? step.description
|
|
62
|
+
: (typeof step.content === 'string' ? step.content : '');
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
target,
|
|
66
|
+
title,
|
|
67
|
+
description
|
|
68
|
+
};
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
_normalizeSteps: function (steps) {
|
|
72
|
+
if (!Array.isArray(steps)) return [];
|
|
73
|
+
|
|
74
|
+
return steps
|
|
75
|
+
.map(step => this._normalizeStep(step))
|
|
76
|
+
.filter(Boolean);
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
start: function (steps, options) {
|
|
80
|
+
if (this._active) this.stop();
|
|
81
|
+
|
|
82
|
+
const normalizedSteps = this._normalizeSteps(steps);
|
|
83
|
+
if (normalizedSteps.length === 0) return;
|
|
84
|
+
|
|
85
|
+
const startOptions = options || {};
|
|
86
|
+
|
|
87
|
+
this._steps = normalizedSteps;
|
|
88
|
+
this._currentStep = 0;
|
|
89
|
+
this._active = true;
|
|
90
|
+
this._triggerElement = startOptions.trigger || (document.activeElement instanceof HTMLElement ? document.activeElement : null);
|
|
91
|
+
|
|
92
|
+
// Create overlay
|
|
93
|
+
const overlay = document.createElement('div');
|
|
94
|
+
overlay.className = 'vd-spotlight-overlay';
|
|
95
|
+
overlay.setAttribute('aria-hidden', 'true');
|
|
96
|
+
document.body.appendChild(overlay);
|
|
97
|
+
|
|
98
|
+
// Create tooltip
|
|
99
|
+
const tooltip = document.createElement('div');
|
|
100
|
+
tooltip.className = 'vd-spotlight-tooltip';
|
|
101
|
+
tooltip.setAttribute('role', 'dialog');
|
|
102
|
+
tooltip.setAttribute('aria-modal', 'true');
|
|
103
|
+
tooltip.tabIndex = -1;
|
|
104
|
+
document.body.appendChild(tooltip);
|
|
105
|
+
|
|
106
|
+
this._elements = { overlay, tooltip };
|
|
107
|
+
|
|
108
|
+
// ESC to close
|
|
109
|
+
const escHandler = (e) => { if (e.key === 'Escape') this.stop(); };
|
|
110
|
+
document.addEventListener('keydown', escHandler);
|
|
111
|
+
this._cleanup.push(() => document.removeEventListener('keydown', escHandler));
|
|
112
|
+
|
|
113
|
+
// Overlay click to close
|
|
114
|
+
overlay.addEventListener('click', () => this.stop());
|
|
115
|
+
|
|
116
|
+
this._showStep(this._currentStep);
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
_showStep: function (index) {
|
|
120
|
+
const step = this._steps[index];
|
|
121
|
+
if (!step) return;
|
|
122
|
+
|
|
123
|
+
const target = typeof step.target === 'string' ? document.querySelector(step.target) : step.target;
|
|
124
|
+
const { tooltip } = this._elements;
|
|
125
|
+
|
|
126
|
+
// Remove previous highlight
|
|
127
|
+
document.querySelectorAll('.vd-spotlight-target').forEach(el => {
|
|
128
|
+
el.classList.remove('vd-spotlight-target');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Highlight target
|
|
132
|
+
if (target) {
|
|
133
|
+
target.classList.add('vd-spotlight-target');
|
|
134
|
+
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Build tooltip content
|
|
138
|
+
const total = this._steps.length;
|
|
139
|
+
tooltip.innerHTML = '';
|
|
140
|
+
tooltip.removeAttribute('aria-labelledby');
|
|
141
|
+
tooltip.removeAttribute('aria-describedby');
|
|
142
|
+
|
|
143
|
+
if (step.title) {
|
|
144
|
+
const title = document.createElement('h4');
|
|
145
|
+
title.className = 'vd-spotlight-title';
|
|
146
|
+
title.id = 'vd-spotlight-title-' + index + '-' + Date.now();
|
|
147
|
+
title.textContent = step.title;
|
|
148
|
+
tooltip.appendChild(title);
|
|
149
|
+
tooltip.setAttribute('aria-labelledby', title.id);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (step.description) {
|
|
153
|
+
const desc = document.createElement('p');
|
|
154
|
+
desc.className = 'vd-spotlight-description';
|
|
155
|
+
desc.id = 'vd-spotlight-description-' + index + '-' + Date.now();
|
|
156
|
+
desc.textContent = step.description;
|
|
157
|
+
tooltip.appendChild(desc);
|
|
158
|
+
tooltip.setAttribute('aria-describedby', desc.id);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Footer
|
|
162
|
+
const footer = document.createElement('div');
|
|
163
|
+
footer.className = 'vd-spotlight-footer';
|
|
164
|
+
footer.setAttribute('aria-label', 'Step ' + (index + 1) + ' of ' + total);
|
|
165
|
+
|
|
166
|
+
const counter = document.createElement('span');
|
|
167
|
+
counter.className = 'vd-spotlight-counter';
|
|
168
|
+
counter.textContent = (index + 1) + ' / ' + total;
|
|
169
|
+
|
|
170
|
+
const actions = document.createElement('div');
|
|
171
|
+
actions.className = 'vd-spotlight-actions';
|
|
172
|
+
|
|
173
|
+
if (index > 0) {
|
|
174
|
+
const prevBtn = document.createElement('button');
|
|
175
|
+
prevBtn.type = 'button';
|
|
176
|
+
prevBtn.className = 'vd-spotlight-btn';
|
|
177
|
+
prevBtn.textContent = 'Back';
|
|
178
|
+
prevBtn.addEventListener('click', () => this.prev());
|
|
179
|
+
actions.appendChild(prevBtn);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const skipBtn = document.createElement('button');
|
|
183
|
+
skipBtn.type = 'button';
|
|
184
|
+
skipBtn.className = 'vd-spotlight-btn';
|
|
185
|
+
skipBtn.textContent = 'Skip';
|
|
186
|
+
skipBtn.addEventListener('click', () => this.stop());
|
|
187
|
+
actions.appendChild(skipBtn);
|
|
188
|
+
|
|
189
|
+
if (index < total - 1) {
|
|
190
|
+
const nextBtn = document.createElement('button');
|
|
191
|
+
nextBtn.type = 'button';
|
|
192
|
+
nextBtn.className = 'vd-spotlight-btn vd-spotlight-btn-primary';
|
|
193
|
+
nextBtn.textContent = 'Next';
|
|
194
|
+
nextBtn.addEventListener('click', () => this.next());
|
|
195
|
+
actions.appendChild(nextBtn);
|
|
196
|
+
} else {
|
|
197
|
+
const doneBtn = document.createElement('button');
|
|
198
|
+
doneBtn.type = 'button';
|
|
199
|
+
doneBtn.className = 'vd-spotlight-btn vd-spotlight-btn-primary';
|
|
200
|
+
doneBtn.textContent = 'Done';
|
|
201
|
+
doneBtn.addEventListener('click', () => this.stop());
|
|
202
|
+
actions.appendChild(doneBtn);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
footer.appendChild(counter);
|
|
206
|
+
footer.appendChild(actions);
|
|
207
|
+
tooltip.appendChild(footer);
|
|
208
|
+
|
|
209
|
+
// Position tooltip near target
|
|
210
|
+
if (target) {
|
|
211
|
+
requestAnimationFrame(() => {
|
|
212
|
+
const rect = target.getBoundingClientRect();
|
|
213
|
+
const tRect = tooltip.getBoundingClientRect();
|
|
214
|
+
let top = rect.bottom + 12 + window.scrollY;
|
|
215
|
+
let left = rect.left + (rect.width - tRect.width) / 2 + window.scrollX;
|
|
216
|
+
|
|
217
|
+
// Keep in viewport
|
|
218
|
+
left = Math.max(8, Math.min(left, window.innerWidth - tRect.width - 8));
|
|
219
|
+
if (top + tRect.height > window.innerHeight + window.scrollY) {
|
|
220
|
+
top = rect.top - tRect.height - 12 + window.scrollY;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
tooltip.style.top = top + 'px';
|
|
224
|
+
tooltip.style.left = left + 'px';
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
document.dispatchEvent(new CustomEvent('spotlight:step', {
|
|
229
|
+
detail: { index, step: index, total, data: step }
|
|
230
|
+
}));
|
|
231
|
+
},
|
|
232
|
+
|
|
233
|
+
next: function () {
|
|
234
|
+
if (this._currentStep < this._steps.length - 1) {
|
|
235
|
+
this._currentStep++;
|
|
236
|
+
this._showStep(this._currentStep);
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
|
|
240
|
+
prev: function () {
|
|
241
|
+
if (this._currentStep > 0) {
|
|
242
|
+
this._currentStep--;
|
|
243
|
+
this._showStep(this._currentStep);
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
stop: function () {
|
|
248
|
+
if (!this._active) return;
|
|
249
|
+
|
|
250
|
+
const total = this._steps.length;
|
|
251
|
+
const detail = {
|
|
252
|
+
completedSteps: total === 0 ? 0 : Math.min(this._currentStep + 1, total),
|
|
253
|
+
total,
|
|
254
|
+
completed: total > 0 && this._currentStep >= total - 1
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
this._active = false;
|
|
258
|
+
|
|
259
|
+
document.querySelectorAll('.vd-spotlight-target').forEach(el => {
|
|
260
|
+
el.classList.remove('vd-spotlight-target');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
if (this._elements.overlay && this._elements.overlay.parentNode) {
|
|
264
|
+
this._elements.overlay.parentNode.removeChild(this._elements.overlay);
|
|
265
|
+
}
|
|
266
|
+
if (this._elements.tooltip && this._elements.tooltip.parentNode) {
|
|
267
|
+
this._elements.tooltip.parentNode.removeChild(this._elements.tooltip);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
this._cleanup.forEach(fn => fn());
|
|
271
|
+
this._cleanup = [];
|
|
272
|
+
this._elements = {};
|
|
273
|
+
this._steps = [];
|
|
274
|
+
this._currentStep = 0;
|
|
275
|
+
|
|
276
|
+
if (this._triggerElement && this._triggerElement.isConnected && typeof this._triggerElement.focus === 'function') {
|
|
277
|
+
this._triggerElement.focus();
|
|
278
|
+
}
|
|
279
|
+
this._triggerElement = null;
|
|
280
|
+
|
|
281
|
+
document.dispatchEvent(new CustomEvent('spotlight:end', { detail }));
|
|
282
|
+
},
|
|
283
|
+
|
|
284
|
+
destroyAll: function () {
|
|
285
|
+
this.stop();
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
if (typeof window.Vanduo !== 'undefined') {
|
|
290
|
+
window.Vanduo.register('spotlight', Spotlight);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
window.VanduoSpotlight = Spotlight;
|
|
294
|
+
|
|
295
|
+
})();
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vanduo Framework - Stepper Component
|
|
3
|
+
* Multi-step progress indicator with state management
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
(function () {
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
const Stepper = {
|
|
10
|
+
instances: new Map(),
|
|
11
|
+
|
|
12
|
+
init: function () {
|
|
13
|
+
const steppers = document.querySelectorAll('.vd-stepper');
|
|
14
|
+
steppers.forEach(el => {
|
|
15
|
+
if (this.instances.has(el)) return;
|
|
16
|
+
this.initInstance(el);
|
|
17
|
+
});
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
initInstance: function (el) {
|
|
21
|
+
const cleanup = [];
|
|
22
|
+
const items = Array.from(el.querySelectorAll('.vd-stepper-item'));
|
|
23
|
+
const isClickable = el.classList.contains('vd-stepper-clickable');
|
|
24
|
+
let currentIndex = items.findIndex(i => i.classList.contains('is-active'));
|
|
25
|
+
if (currentIndex === -1) currentIndex = 0;
|
|
26
|
+
|
|
27
|
+
const setStep = (index) => {
|
|
28
|
+
if (index < 0 || index >= items.length) return;
|
|
29
|
+
const prev = currentIndex;
|
|
30
|
+
currentIndex = index;
|
|
31
|
+
|
|
32
|
+
items.forEach((item, i) => {
|
|
33
|
+
item.classList.remove('is-active', 'is-completed');
|
|
34
|
+
if (i < index) item.classList.add('is-completed');
|
|
35
|
+
else if (i === index) item.classList.add('is-active');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
el.dispatchEvent(new CustomEvent('stepper:change', {
|
|
39
|
+
detail: { current: index, previous: prev, total: items.length },
|
|
40
|
+
bubbles: true
|
|
41
|
+
}));
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
if (isClickable) {
|
|
45
|
+
items.forEach((item, i) => {
|
|
46
|
+
const handler = () => setStep(i);
|
|
47
|
+
item.addEventListener('click', handler);
|
|
48
|
+
cleanup.push(() => item.removeEventListener('click', handler));
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Initialize current state
|
|
53
|
+
setStep(currentIndex);
|
|
54
|
+
|
|
55
|
+
this.instances.set(el, {
|
|
56
|
+
cleanup,
|
|
57
|
+
setStep,
|
|
58
|
+
next: () => setStep(currentIndex + 1),
|
|
59
|
+
prev: () => setStep(currentIndex - 1),
|
|
60
|
+
getCurrent: () => currentIndex
|
|
61
|
+
});
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
setStep: function (el, index) {
|
|
65
|
+
const inst = this.instances.get(el);
|
|
66
|
+
if (inst) inst.setStep(index);
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
next: function (el) {
|
|
70
|
+
const inst = this.instances.get(el);
|
|
71
|
+
if (inst) inst.next();
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
prev: function (el) {
|
|
75
|
+
const inst = this.instances.get(el);
|
|
76
|
+
if (inst) inst.prev();
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
destroy: function (el) {
|
|
80
|
+
const inst = this.instances.get(el);
|
|
81
|
+
if (!inst) return;
|
|
82
|
+
inst.cleanup.forEach(fn => fn());
|
|
83
|
+
this.instances.delete(el);
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
destroyAll: function () {
|
|
87
|
+
this.instances.forEach((_, el) => this.destroy(el));
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
if (typeof window.Vanduo !== 'undefined') {
|
|
92
|
+
window.Vanduo.register('stepper', Stepper);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
window.VanduoStepper = Stepper;
|
|
96
|
+
|
|
97
|
+
})();
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vanduo Framework - Suggest (Autocomplete) Component
|
|
3
|
+
* Dropdown suggestion list with keyboard navigation, static/async data sources
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
(function () {
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
const Suggest = {
|
|
10
|
+
instances: new Map(),
|
|
11
|
+
|
|
12
|
+
init: function () {
|
|
13
|
+
const inputs = document.querySelectorAll('[data-vd-suggest], [data-vd-autocomplete]');
|
|
14
|
+
inputs.forEach(el => {
|
|
15
|
+
if (this.instances.has(el)) return;
|
|
16
|
+
this.initInstance(el);
|
|
17
|
+
});
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
initInstance: function (input) {
|
|
21
|
+
const cleanup = [];
|
|
22
|
+
const minChars = parseInt(input.getAttribute('data-vd-suggest-min-chars') || '1', 10);
|
|
23
|
+
const url = input.getAttribute('data-vd-suggest-url') || '';
|
|
24
|
+
const staticData = input.getAttribute('data-vd-suggest') || input.getAttribute('data-vd-autocomplete') || '';
|
|
25
|
+
let items = [];
|
|
26
|
+
|
|
27
|
+
try { items = JSON.parse(staticData); } catch (_e) {
|
|
28
|
+
items = staticData.split(',').map(s => s.trim()).filter(Boolean);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Wrap input if not already wrapped
|
|
32
|
+
let wrapper = input.closest('.vd-suggest-wrapper, .vd-autocomplete-wrapper');
|
|
33
|
+
if (!wrapper) {
|
|
34
|
+
wrapper = document.createElement('div');
|
|
35
|
+
wrapper.className = 'vd-suggest-wrapper';
|
|
36
|
+
input.parentNode.insertBefore(wrapper, input);
|
|
37
|
+
wrapper.appendChild(input);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Create dropdown list
|
|
41
|
+
const list = document.createElement('ul');
|
|
42
|
+
list.className = 'vd-suggest-list';
|
|
43
|
+
list.setAttribute('role', 'listbox');
|
|
44
|
+
const listId = 'vd-suggest-' + Math.random().toString(36).slice(2, 9);
|
|
45
|
+
list.id = listId;
|
|
46
|
+
wrapper.appendChild(list);
|
|
47
|
+
|
|
48
|
+
// ARIA
|
|
49
|
+
input.setAttribute('role', 'combobox');
|
|
50
|
+
input.setAttribute('aria-autocomplete', 'list');
|
|
51
|
+
input.setAttribute('aria-expanded', 'false');
|
|
52
|
+
input.setAttribute('aria-controls', listId);
|
|
53
|
+
input.setAttribute('autocomplete', 'off');
|
|
54
|
+
|
|
55
|
+
let highlighted = -1;
|
|
56
|
+
let currentItems = [];
|
|
57
|
+
let debounceTimer = null;
|
|
58
|
+
|
|
59
|
+
const renderItems = (filtered, query) => {
|
|
60
|
+
list.innerHTML = '';
|
|
61
|
+
currentItems = filtered;
|
|
62
|
+
highlighted = -1;
|
|
63
|
+
|
|
64
|
+
if (filtered.length === 0) {
|
|
65
|
+
const empty = document.createElement('li');
|
|
66
|
+
empty.className = 'vd-suggest-empty';
|
|
67
|
+
empty.textContent = 'No results';
|
|
68
|
+
list.appendChild(empty);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
filtered.forEach((item, i) => {
|
|
73
|
+
const li = document.createElement('li');
|
|
74
|
+
li.className = 'vd-suggest-item';
|
|
75
|
+
li.setAttribute('role', 'option');
|
|
76
|
+
li.id = listId + '-item-' + i;
|
|
77
|
+
|
|
78
|
+
const text = typeof item === 'object' ? (item.label || item.text || String(item)) : String(item);
|
|
79
|
+
if (query) {
|
|
80
|
+
const re = new RegExp('(' + query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi');
|
|
81
|
+
li.innerHTML = text.replace(re, '<span class="vd-suggest-match">$1</span>');
|
|
82
|
+
} else {
|
|
83
|
+
li.textContent = text;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
li.addEventListener('click', () => selectItem(i));
|
|
87
|
+
list.appendChild(li);
|
|
88
|
+
});
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const open = () => {
|
|
92
|
+
list.classList.add('is-open');
|
|
93
|
+
input.setAttribute('aria-expanded', 'true');
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const close = () => {
|
|
97
|
+
list.classList.remove('is-open');
|
|
98
|
+
input.setAttribute('aria-expanded', 'false');
|
|
99
|
+
highlighted = -1;
|
|
100
|
+
input.removeAttribute('aria-activedescendant');
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const selectItem = (index) => {
|
|
104
|
+
const item = currentItems[index];
|
|
105
|
+
const value = typeof item === 'object' ? (item.value || item.label || String(item)) : String(item);
|
|
106
|
+
input.value = value;
|
|
107
|
+
close();
|
|
108
|
+
input.dispatchEvent(new CustomEvent('suggest:select', {
|
|
109
|
+
detail: { value, item, index },
|
|
110
|
+
bubbles: true
|
|
111
|
+
}));
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const highlight = (index) => {
|
|
115
|
+
const listItems = list.querySelectorAll('.vd-suggest-item');
|
|
116
|
+
listItems.forEach(li => li.classList.remove('is-highlighted'));
|
|
117
|
+
if (index >= 0 && index < listItems.length) {
|
|
118
|
+
highlighted = index;
|
|
119
|
+
listItems[index].classList.add('is-highlighted');
|
|
120
|
+
input.setAttribute('aria-activedescendant', listItems[index].id);
|
|
121
|
+
listItems[index].scrollIntoView({ block: 'nearest' });
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const doSearch = async (query) => {
|
|
126
|
+
if (query.length < minChars) { close(); return; }
|
|
127
|
+
|
|
128
|
+
let filtered;
|
|
129
|
+
if (url) {
|
|
130
|
+
try {
|
|
131
|
+
const separator = url.includes('?') ? '&' : '?';
|
|
132
|
+
const res = await window.fetch(url + separator + 'q=' + encodeURIComponent(query));
|
|
133
|
+
filtered = await res.json();
|
|
134
|
+
} catch (_e) {
|
|
135
|
+
filtered = [];
|
|
136
|
+
}
|
|
137
|
+
} else {
|
|
138
|
+
const lower = query.toLowerCase();
|
|
139
|
+
filtered = items.filter(item => {
|
|
140
|
+
const text = typeof item === 'object' ? (item.label || item.text || String(item)) : String(item);
|
|
141
|
+
return text.toLowerCase().includes(lower);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
renderItems(filtered, query);
|
|
146
|
+
if (filtered.length > 0) open();
|
|
147
|
+
else open(); // show "no results"
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const inputHandler = () => {
|
|
151
|
+
clearTimeout(debounceTimer);
|
|
152
|
+
debounceTimer = setTimeout(() => doSearch(input.value), 200);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const keyHandler = (e) => {
|
|
156
|
+
if (!list.classList.contains('is-open')) {
|
|
157
|
+
if (e.key === 'ArrowDown') { doSearch(input.value); e.preventDefault(); }
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const total = currentItems.length;
|
|
162
|
+
switch (e.key) {
|
|
163
|
+
case 'ArrowDown':
|
|
164
|
+
e.preventDefault();
|
|
165
|
+
highlight(highlighted < total - 1 ? highlighted + 1 : 0);
|
|
166
|
+
break;
|
|
167
|
+
case 'ArrowUp':
|
|
168
|
+
e.preventDefault();
|
|
169
|
+
highlight(highlighted > 0 ? highlighted - 1 : total - 1);
|
|
170
|
+
break;
|
|
171
|
+
case 'Enter':
|
|
172
|
+
e.preventDefault();
|
|
173
|
+
if (highlighted >= 0) selectItem(highlighted);
|
|
174
|
+
break;
|
|
175
|
+
case 'Escape':
|
|
176
|
+
close();
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const blurHandler = () => {
|
|
182
|
+
setTimeout(close, 200);
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
input.addEventListener('input', inputHandler);
|
|
186
|
+
input.addEventListener('keydown', keyHandler);
|
|
187
|
+
input.addEventListener('blur', blurHandler);
|
|
188
|
+
input.addEventListener('focus', () => { if (input.value.length >= minChars) doSearch(input.value); });
|
|
189
|
+
|
|
190
|
+
cleanup.push(
|
|
191
|
+
() => input.removeEventListener('input', inputHandler),
|
|
192
|
+
() => input.removeEventListener('keydown', keyHandler),
|
|
193
|
+
() => input.removeEventListener('blur', blurHandler),
|
|
194
|
+
() => clearTimeout(debounceTimer),
|
|
195
|
+
() => { if (list.parentNode) list.parentNode.removeChild(list); }
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
this.instances.set(input, { cleanup, list, close });
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
destroy: function (el) {
|
|
202
|
+
const instance = this.instances.get(el);
|
|
203
|
+
if (!instance) return;
|
|
204
|
+
instance.cleanup.forEach(fn => fn());
|
|
205
|
+
this.instances.delete(el);
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
destroyAll: function () {
|
|
209
|
+
this.instances.forEach((_, el) => this.destroy(el));
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
if (typeof window.Vanduo !== 'undefined') {
|
|
214
|
+
window.Vanduo.register('suggest', Suggest);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
window.VanduoSuggest = Suggest;
|
|
218
|
+
|
|
219
|
+
})();
|
|
@@ -251,8 +251,14 @@
|
|
|
251
251
|
mode = this.DEFAULTS.THEME;
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
+
// Prevent circular updates
|
|
255
|
+
this._isApplying = true;
|
|
256
|
+
|
|
254
257
|
// Check if we should switch primary color (if using default)
|
|
255
|
-
|
|
258
|
+
// Use the incoming mode for both old and new default checks since
|
|
259
|
+
// ThemeSwitcher calls this with the new mode before updating its own state
|
|
260
|
+
const currentMode = this.state.theme;
|
|
261
|
+
const oldDefault = this.getDefaultPrimary(currentMode);
|
|
256
262
|
if (this.state.primary === oldDefault) {
|
|
257
263
|
const newDefault = this.getDefaultPrimary(mode);
|
|
258
264
|
if (newDefault !== this.state.primary) {
|
|
@@ -271,13 +277,16 @@
|
|
|
271
277
|
this.savePreference(this.STORAGE_KEYS.THEME, mode);
|
|
272
278
|
|
|
273
279
|
// Also update the existing ThemeSwitcher if available
|
|
280
|
+
// Only update state, don't call setPreference to avoid circular calls
|
|
274
281
|
if (window.Vanduo && window.Vanduo.components.themeSwitcher) {
|
|
275
282
|
const themeSwitcher = window.Vanduo.components.themeSwitcher;
|
|
276
|
-
if (themeSwitcher.state) {
|
|
283
|
+
if (themeSwitcher.state && themeSwitcher.state.preference !== mode) {
|
|
277
284
|
themeSwitcher.state.preference = mode;
|
|
285
|
+
themeSwitcher.savePreference(themeSwitcher.STORAGE_KEY, mode);
|
|
278
286
|
}
|
|
279
287
|
}
|
|
280
288
|
|
|
289
|
+
this._isApplying = false;
|
|
281
290
|
this.dispatchEvent('mode-change', { mode: mode });
|
|
282
291
|
},
|
|
283
292
|
|
|
@@ -41,6 +41,13 @@
|
|
|
41
41
|
this.state.preference = pref;
|
|
42
42
|
this.setStorageValue(this.STORAGE_KEY, pref);
|
|
43
43
|
this.applyTheme();
|
|
44
|
+
|
|
45
|
+
// Coordinate with ThemeCustomizer for primary color swap if available
|
|
46
|
+
// Check _isApplying flag to prevent circular updates
|
|
47
|
+
if (window.ThemeCustomizer && window.ThemeCustomizer.applyTheme && !window.ThemeCustomizer._isApplying) {
|
|
48
|
+
window.ThemeCustomizer.applyTheme(pref);
|
|
49
|
+
}
|
|
50
|
+
|
|
44
51
|
this.updateUI();
|
|
45
52
|
},
|
|
46
53
|
|