@vanduo-oss/framework 1.2.5 → 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.
- package/README.md +31 -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/utilities/color-utilities.css +352 -0
- package/css/vanduo.css +20 -0
- package/dist/build-info.json +3 -3
- package/dist/vanduo.cjs.js +2152 -4
- 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 +3253 -271
- package/dist/vanduo.css.map +1 -1
- package/dist/vanduo.esm.js +2152 -4
- 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 +2152 -4
- 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/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 +1 -1
|
@@ -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
|
+
})();
|