mnfst 0.5.145 → 0.5.147
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/lib/manifest.combobox.css +208 -0
- package/lib/manifest.combobox.js +570 -0
- package/lib/manifest.components.js +152 -0
- package/lib/manifest.css +209 -0
- package/lib/manifest.integrity.json +4 -2
- package/lib/manifest.js +2 -0
- package/lib/manifest.min.css +1 -1
- package/lib/manifest.virtual.js +319 -0
- package/package.json +1 -1
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
/* Manifest Combobox */
|
|
2
|
+
|
|
3
|
+
(function () {
|
|
4
|
+
|
|
5
|
+
// A select-like field with four orthogonal axes:
|
|
6
|
+
// trigger: input (default) | textarea | button (editor:none, click only)
|
|
7
|
+
// options: none (free entry) | datalist/select (generated menu) | menu (authored) | async
|
|
8
|
+
// value: single (default) | multiple (+ max cap)
|
|
9
|
+
// display: text (default) | chips
|
|
10
|
+
//
|
|
11
|
+
// Config is the directive value: a bare id string (the options source, like
|
|
12
|
+
// x-dropdown) or a { source, max, filter, separators, min, debounce } object.
|
|
13
|
+
// Modes are modifiers. The dropdown reuses Manifest's menu[popover] styles.
|
|
14
|
+
|
|
15
|
+
/* ------------------------------------------------------------------ *
|
|
16
|
+
* Shared localized-UI resolver (byte-identical to the datepicker /
|
|
17
|
+
* colorpicker copies; first plugin to load defines it).
|
|
18
|
+
* ------------------------------------------------------------------ */
|
|
19
|
+
if (!window.ManifestUI) {
|
|
20
|
+
window.ManifestUI = {
|
|
21
|
+
_loadedSourceNames() {
|
|
22
|
+
try {
|
|
23
|
+
const store = window.ManifestDataStore && window.ManifestDataStore.rawDataStore;
|
|
24
|
+
if (store && typeof store.keys === 'function') return [...store.keys()];
|
|
25
|
+
} catch (_) { }
|
|
26
|
+
return [];
|
|
27
|
+
},
|
|
28
|
+
resolve(component, fallbacks) {
|
|
29
|
+
const merged = JSON.parse(JSON.stringify(fallbacks || {}));
|
|
30
|
+
try {
|
|
31
|
+
if (!window.Alpine || typeof Alpine.evaluate !== 'function') return merged;
|
|
32
|
+
try { Alpine.evaluate(document.body, '$locale && $locale.current'); } catch (_) { }
|
|
33
|
+
for (const name of this._loadedSourceNames()) {
|
|
34
|
+
let ui;
|
|
35
|
+
try { ui = Alpine.evaluate(document.body, `$x['${name}'] && $x['${name}']._ui && $x['${name}']._ui['${component}']`); } catch (_) { ui = null; }
|
|
36
|
+
if (ui && typeof ui === 'object' && !Array.isArray(ui)) this._deepOverlay(merged, ui);
|
|
37
|
+
}
|
|
38
|
+
} catch (_) { }
|
|
39
|
+
return merged;
|
|
40
|
+
},
|
|
41
|
+
_deepOverlay(target, src) {
|
|
42
|
+
for (const k of Object.keys(src)) {
|
|
43
|
+
if (k.startsWith('$') || k === 'contentType' || k === 'valueOf' || k === 'toString') continue;
|
|
44
|
+
const v = src[k];
|
|
45
|
+
if (typeof v === 'function') continue;
|
|
46
|
+
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
|
47
|
+
if (!target[k] || typeof target[k] !== 'object') target[k] = {};
|
|
48
|
+
this._deepOverlay(target[k], v);
|
|
49
|
+
} else if (v !== undefined && v !== null && v !== '') {
|
|
50
|
+
target[k] = v;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Default English UI chrome; overridable via a data source's `_ui.combobox`.
|
|
58
|
+
const UI_FALLBACK = {
|
|
59
|
+
empty: 'No matches',
|
|
60
|
+
add: 'Add “{value}”',
|
|
61
|
+
loading: 'Searching…',
|
|
62
|
+
prompt: 'Type to search'
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
function initializeComboboxPlugin() {
|
|
66
|
+
|
|
67
|
+
function ensureAlpineContext() {
|
|
68
|
+
if (!document.body.hasAttribute('x-data')) document.body.setAttribute('x-data', '{}');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
ensureAlpineContext();
|
|
72
|
+
|
|
73
|
+
const rand = () => Math.random().toString(36).slice(2, 9);
|
|
74
|
+
const ui = () => window.ManifestUI ? window.ManifestUI.resolve('combobox', UI_FALLBACK) : UI_FALLBACK;
|
|
75
|
+
|
|
76
|
+
// Read options from a source element (datalist/select → option, menu → li)
|
|
77
|
+
function readOptions(src) {
|
|
78
|
+
if (!src) return [];
|
|
79
|
+
if (src.tagName === 'MENU') {
|
|
80
|
+
return Array.from(src.querySelectorAll('li')).map(li => ({
|
|
81
|
+
value: li.dataset.value != null ? li.dataset.value : li.textContent.trim(),
|
|
82
|
+
label: li.dataset.label || li.textContent.trim(),
|
|
83
|
+
pattern: li.dataset.pattern || null,
|
|
84
|
+
html: li.innerHTML
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
87
|
+
return Array.from(src.querySelectorAll('option')).map(o => ({
|
|
88
|
+
value: o.value || o.textContent.trim(),
|
|
89
|
+
label: o.textContent.trim() || o.value,
|
|
90
|
+
pattern: o.getAttribute('data-pattern') || null,
|
|
91
|
+
html: null
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
Alpine.directive('combobox', (el, { modifiers, expression }) => {
|
|
96
|
+
// Build after the current tick so sibling sources (datalist/menu) exist.
|
|
97
|
+
setTimeout(() => build(el, modifiers, expression || ''), 0);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
function build(el, modifiers, expression) {
|
|
101
|
+
if (el.__mnfstCombobox) return;
|
|
102
|
+
el.__mnfstCombobox = true;
|
|
103
|
+
|
|
104
|
+
// ----- Config: bare id string, or a { } object -----
|
|
105
|
+
let cfg = {};
|
|
106
|
+
const expr = expression.trim();
|
|
107
|
+
if (expr.startsWith('{')) {
|
|
108
|
+
try { cfg = window.Alpine.evaluate(el, expr) || {}; } catch (_) { cfg = {}; }
|
|
109
|
+
} else if (expr) {
|
|
110
|
+
cfg.source = expr;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const editorNone = el.tagName === 'BUTTON';
|
|
114
|
+
const multiple = modifiers.includes('multiple');
|
|
115
|
+
const chips = modifiers.includes('chips');
|
|
116
|
+
const strict = modifiers.includes('strict');
|
|
117
|
+
const create = modifiers.includes('create');
|
|
118
|
+
const isAsync = modifiers.includes('async');
|
|
119
|
+
const max = parseInt(cfg.max, 10) || (multiple ? Infinity : 1);
|
|
120
|
+
const filterMode = cfg.filter || 'includes';
|
|
121
|
+
const minChars = parseInt(cfg.min, 10) || 0;
|
|
122
|
+
const debounceMs = parseInt(cfg.debounce, 10) || 200;
|
|
123
|
+
const separators = cfg.separators != null
|
|
124
|
+
? String(cfg.separators).split('').filter(c => c.trim() || c === ' ')
|
|
125
|
+
: (multiple ? [','] : []);
|
|
126
|
+
const name = el.getAttribute('name');
|
|
127
|
+
const placeholder = el.getAttribute('placeholder') || (editorNone ? el.textContent.trim() : '');
|
|
128
|
+
|
|
129
|
+
// ----- Source / options -----
|
|
130
|
+
const sourceId = cfg.source ? String(cfg.source).replace(/^#/, '') : null;
|
|
131
|
+
const src = sourceId ? document.getElementById(sourceId) : null;
|
|
132
|
+
let options = readOptions(src);
|
|
133
|
+
const hasMenu = !!src || isAsync;
|
|
134
|
+
if (editorNone && !hasMenu) return; // a button trigger needs a list
|
|
135
|
+
|
|
136
|
+
// ----- Shell -----
|
|
137
|
+
const wrap = document.createElement('div');
|
|
138
|
+
wrap.className = 'combobox';
|
|
139
|
+
el.parentNode.insertBefore(wrap, el);
|
|
140
|
+
wrap.appendChild(el);
|
|
141
|
+
// The wrapper IS the field, so move the author's sizing (inline style) onto
|
|
142
|
+
// it. Otherwise the editor/button is constrained inside a full-width field.
|
|
143
|
+
const authorStyle = el.getAttribute('style');
|
|
144
|
+
if (authorStyle) { wrap.setAttribute('style', authorStyle); el.removeAttribute('style'); }
|
|
145
|
+
el.setAttribute('autocomplete', 'off');
|
|
146
|
+
if (!editorNone) el.removeAttribute('placeholder');
|
|
147
|
+
if (name) el.removeAttribute('name'); // hidden inputs carry the value(s)
|
|
148
|
+
|
|
149
|
+
// Live region for add/remove announcements
|
|
150
|
+
const live = document.createElement('span');
|
|
151
|
+
live.setAttribute('role', 'status');
|
|
152
|
+
wrap.appendChild(live);
|
|
153
|
+
const announce = (m) => { live.textContent = ''; live.textContent = m; };
|
|
154
|
+
|
|
155
|
+
// ----- Selection model -----
|
|
156
|
+
let selected = [];
|
|
157
|
+
const isSelected = (v) => selected.some(s => String(s.value).toLowerCase() === String(v).toLowerCase());
|
|
158
|
+
const atCap = () => multiple && selected.length >= max;
|
|
159
|
+
|
|
160
|
+
// ----- Menu -----
|
|
161
|
+
let menu = null, optionEls = [], createEl = null, emptyEl = null, activeIndex = -1;
|
|
162
|
+
|
|
163
|
+
function makeOption(o, i) {
|
|
164
|
+
const li = document.createElement('li');
|
|
165
|
+
li.id = menu.id + '-opt-' + i;
|
|
166
|
+
li.dataset.value = o.value;
|
|
167
|
+
li.dataset.label = o.label;
|
|
168
|
+
if (o.pattern) li.dataset.pattern = o.pattern;
|
|
169
|
+
if (o.html) li.innerHTML = o.html; else li.textContent = o.label;
|
|
170
|
+
li.setAttribute('role', 'option');
|
|
171
|
+
li.setAttribute('aria-selected', isSelected(o.value) ? 'true' : 'false');
|
|
172
|
+
return li;
|
|
173
|
+
}
|
|
174
|
+
function setOptions(opts) {
|
|
175
|
+
optionEls.forEach(li => li.remove());
|
|
176
|
+
optionEls = opts.map((o, i) => {
|
|
177
|
+
const li = makeOption(o, i);
|
|
178
|
+
menu.insertBefore(li, createEl || emptyEl);
|
|
179
|
+
return li;
|
|
180
|
+
});
|
|
181
|
+
activeIndex = -1;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (hasMenu) {
|
|
185
|
+
if (src && src.tagName === 'MENU') {
|
|
186
|
+
menu = src;
|
|
187
|
+
} else {
|
|
188
|
+
menu = document.createElement('menu');
|
|
189
|
+
document.body.appendChild(menu);
|
|
190
|
+
if (src) src.style.setProperty('display', 'none', 'important');
|
|
191
|
+
}
|
|
192
|
+
menu.setAttribute('popover', 'manual');
|
|
193
|
+
if (!menu.id) menu.id = 'combobox-menu-' + rand();
|
|
194
|
+
menu.setAttribute('role', 'listbox');
|
|
195
|
+
if (multiple) menu.setAttribute('aria-multiselectable', 'true');
|
|
196
|
+
|
|
197
|
+
Array.from(menu.querySelectorAll('li')).forEach(li => li.remove());
|
|
198
|
+
|
|
199
|
+
if (create) {
|
|
200
|
+
createEl = document.createElement('li');
|
|
201
|
+
createEl.className = 'combobox-create';
|
|
202
|
+
createEl.setAttribute('role', 'option');
|
|
203
|
+
createEl.hidden = true;
|
|
204
|
+
menu.appendChild(createEl);
|
|
205
|
+
}
|
|
206
|
+
emptyEl = document.createElement('div');
|
|
207
|
+
emptyEl.className = 'combobox-empty';
|
|
208
|
+
emptyEl.hidden = true;
|
|
209
|
+
menu.appendChild(emptyEl);
|
|
210
|
+
|
|
211
|
+
setOptions(options);
|
|
212
|
+
|
|
213
|
+
const anchorName = '--combobox-' + rand();
|
|
214
|
+
wrap.style.setProperty('anchor-name', anchorName);
|
|
215
|
+
menu.style.setProperty('position-anchor', anchorName);
|
|
216
|
+
|
|
217
|
+
el.setAttribute('aria-controls', menu.id);
|
|
218
|
+
el.setAttribute('aria-expanded', 'false');
|
|
219
|
+
if (editorNone) {
|
|
220
|
+
el.setAttribute('aria-haspopup', 'listbox');
|
|
221
|
+
} else {
|
|
222
|
+
el.setAttribute('role', 'combobox');
|
|
223
|
+
el.setAttribute('aria-autocomplete', 'list');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
menu.addEventListener('mousedown', (e) => e.preventDefault());
|
|
227
|
+
menu.addEventListener('click', (e) => {
|
|
228
|
+
const li = e.target.closest('li');
|
|
229
|
+
if (li && !li.hidden) selectOption(li);
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function openMenu() {
|
|
234
|
+
if (!menu || menu.matches(':popover-open') || atCap()) return;
|
|
235
|
+
menu.style.minWidth = wrap.offsetWidth + 'px';
|
|
236
|
+
menu.showPopover();
|
|
237
|
+
el.setAttribute('aria-expanded', 'true');
|
|
238
|
+
}
|
|
239
|
+
function closeMenu() {
|
|
240
|
+
if (!menu || !menu.matches(':popover-open')) return;
|
|
241
|
+
menu.hidePopover();
|
|
242
|
+
el.setAttribute('aria-expanded', 'false');
|
|
243
|
+
setActive(-1);
|
|
244
|
+
}
|
|
245
|
+
function showEmpty(text) {
|
|
246
|
+
if (emptyEl) { emptyEl.textContent = text; emptyEl.hidden = false; }
|
|
247
|
+
setActive(-1);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function setActive(i) {
|
|
251
|
+
optionEls.forEach(li => li.removeAttribute('aria-current'));
|
|
252
|
+
if (createEl) createEl.removeAttribute('aria-current');
|
|
253
|
+
activeIndex = i;
|
|
254
|
+
const li = liAt(i);
|
|
255
|
+
if (li) { li.setAttribute('aria-current', 'true'); el.setAttribute('aria-activedescendant', li.id); }
|
|
256
|
+
else el.removeAttribute('aria-activedescendant');
|
|
257
|
+
}
|
|
258
|
+
function liAt(i) {
|
|
259
|
+
if (i === -2) return createEl && !createEl.hidden ? createEl : null;
|
|
260
|
+
return optionEls[i] || null;
|
|
261
|
+
}
|
|
262
|
+
function visibleIndexes() {
|
|
263
|
+
const v = optionEls.map((li, i) => (!li.hidden ? i : -1)).filter(i => i >= 0);
|
|
264
|
+
if (createEl && !createEl.hidden) v.push(-2);
|
|
265
|
+
return v;
|
|
266
|
+
}
|
|
267
|
+
function moveActive(dir) {
|
|
268
|
+
const vis = visibleIndexes();
|
|
269
|
+
if (!vis.length) return;
|
|
270
|
+
let pos = vis.indexOf(activeIndex);
|
|
271
|
+
pos = (pos + dir + vis.length) % vis.length;
|
|
272
|
+
setActive(vis[pos]);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function testPattern(p, input) {
|
|
276
|
+
try { return new RegExp(p, 'i').test(input); } catch { return false; }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function filter() {
|
|
280
|
+
if (!menu) return;
|
|
281
|
+
const raw = (el.value || '').trim();
|
|
282
|
+
const q = raw.toLowerCase();
|
|
283
|
+
let first = -1, anyVisible = false, exact = false;
|
|
284
|
+
optionEls.forEach((li, i) => {
|
|
285
|
+
const t = String(li.dataset.label).toLowerCase();
|
|
286
|
+
let show =
|
|
287
|
+
isAsync || editorNone || !q || filterMode === 'none' ? true
|
|
288
|
+
: filterMode === 'startswith' ? t.startsWith(q)
|
|
289
|
+
: filterMode === 'pattern' ? (li.dataset.pattern ? testPattern(li.dataset.pattern, raw) : t.includes(q))
|
|
290
|
+
: t.includes(q);
|
|
291
|
+
if (show && multiple && isSelected(li.dataset.value)) show = false;
|
|
292
|
+
li.hidden = !show;
|
|
293
|
+
if (show) { anyVisible = true; if (first < 0) first = i; }
|
|
294
|
+
if (t === q) exact = true;
|
|
295
|
+
});
|
|
296
|
+
if (createEl) {
|
|
297
|
+
const show = create && q && !exact;
|
|
298
|
+
createEl.hidden = !show;
|
|
299
|
+
createEl.textContent = show ? ui().add.replace('{value}', raw) : '';
|
|
300
|
+
createEl.dataset.value = raw;
|
|
301
|
+
if (show) anyVisible = true;
|
|
302
|
+
}
|
|
303
|
+
if (emptyEl) { emptyEl.textContent = ui().empty; emptyEl.hidden = anyVisible; }
|
|
304
|
+
setActive(first >= 0 ? first : (createEl && !createEl.hidden ? -2 : -1));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Show the whole list (single field re-opened on a committed value, so the
|
|
308
|
+
// choice can be swapped — its current value shouldn't narrow the list to itself).
|
|
309
|
+
function showAll() {
|
|
310
|
+
optionEls.forEach(li => { li.hidden = false; });
|
|
311
|
+
if (createEl) createEl.hidden = true;
|
|
312
|
+
if (emptyEl) emptyEl.hidden = optionEls.length > 0;
|
|
313
|
+
const sel = optionEls.findIndex(li => isSelected(li.dataset.value));
|
|
314
|
+
setActive(sel >= 0 ? sel : (optionEls.length ? 0 : -1));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ----- Async option fetching (Open UI's beforefilter analog) -----
|
|
318
|
+
let asyncTimer, asyncSeq = 0;
|
|
319
|
+
function scheduleAsync() {
|
|
320
|
+
openMenu();
|
|
321
|
+
clearTimeout(asyncTimer);
|
|
322
|
+
setOptions([]); // drop stale results so nothing flashes
|
|
323
|
+
showEmpty(ui().loading);
|
|
324
|
+
asyncTimer = setTimeout(runAsync, debounceMs);
|
|
325
|
+
}
|
|
326
|
+
function runAsync() {
|
|
327
|
+
const seq = ++asyncSeq;
|
|
328
|
+
const value = (el.value || '').trim();
|
|
329
|
+
el.dispatchEvent(new CustomEvent('combobox-filter', {
|
|
330
|
+
bubbles: true,
|
|
331
|
+
detail: {
|
|
332
|
+
value,
|
|
333
|
+
setOptions: (opts) => {
|
|
334
|
+
if (seq !== asyncSeq) return; // ignore stale responses
|
|
335
|
+
options = (opts || []).map(o => typeof o === 'string' ? { value: o, label: o } : o);
|
|
336
|
+
setOptions(options);
|
|
337
|
+
filter();
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}));
|
|
341
|
+
}
|
|
342
|
+
// Async: open and either fetch or prompt, depending on the typed length.
|
|
343
|
+
function asyncRefresh() {
|
|
344
|
+
openMenu();
|
|
345
|
+
if ((el.value || '').trim().length >= minChars) scheduleAsync();
|
|
346
|
+
else { setOptions([]); showEmpty(ui().prompt); }
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function commitText(raw) {
|
|
350
|
+
raw = (raw || '').trim();
|
|
351
|
+
if (!raw) return false;
|
|
352
|
+
if (strict) {
|
|
353
|
+
const o = options.find(o => o.label.toLowerCase() === raw.toLowerCase());
|
|
354
|
+
if (!o) return false;
|
|
355
|
+
addValue(o.value, o.label);
|
|
356
|
+
} else {
|
|
357
|
+
addValue(raw, raw);
|
|
358
|
+
}
|
|
359
|
+
if (chips || multiple) el.value = '';
|
|
360
|
+
if (menu && !isAsync) filter();
|
|
361
|
+
return true;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function selectOption(li) {
|
|
365
|
+
if (li === createEl) { commitText(li.dataset.value); return refocus(); }
|
|
366
|
+
addValue(li.dataset.value, li.dataset.label);
|
|
367
|
+
if (!editorNone && (chips || multiple)) el.value = '';
|
|
368
|
+
if (!multiple) closeMenu(); else filter();
|
|
369
|
+
refocus();
|
|
370
|
+
}
|
|
371
|
+
// refocus keeps the caret in the field after a pick. Suppress the focus
|
|
372
|
+
// handler's auto-open for that one tick so a selection doesn't reopen the list.
|
|
373
|
+
let suppressOpen = false;
|
|
374
|
+
function refocus() { suppressOpen = true; el.focus(); setTimeout(() => { suppressOpen = false; }, 0); }
|
|
375
|
+
|
|
376
|
+
function addValue(value, label) {
|
|
377
|
+
if (!multiple) { selected = [{ value, label }]; render(); announce(label + ' selected'); return; }
|
|
378
|
+
if (isSelected(value)) return;
|
|
379
|
+
if (selected.length >= max) { announce('Maximum of ' + max + ' reached'); return; }
|
|
380
|
+
selected.push({ value, label });
|
|
381
|
+
render();
|
|
382
|
+
announce(label + ' added');
|
|
383
|
+
}
|
|
384
|
+
function removeValue(value) {
|
|
385
|
+
const i = selected.findIndex(s => String(s.value).toLowerCase() === String(value).toLowerCase());
|
|
386
|
+
if (i < 0) return;
|
|
387
|
+
const [g] = selected.splice(i, 1);
|
|
388
|
+
render();
|
|
389
|
+
announce(g.label + ' removed');
|
|
390
|
+
if (menu && !isAsync) filter();
|
|
391
|
+
refocus();
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function makeChip(s) {
|
|
395
|
+
const chip = document.createElement('span');
|
|
396
|
+
chip.className = 'combobox-chip';
|
|
397
|
+
chip.dataset.value = s.value;
|
|
398
|
+
const label = document.createElement('span');
|
|
399
|
+
label.textContent = s.label;
|
|
400
|
+
const x = document.createElement('button');
|
|
401
|
+
x.type = 'button';
|
|
402
|
+
x.setAttribute('aria-label', 'Remove ' + s.label);
|
|
403
|
+
x.textContent = '×';
|
|
404
|
+
x.addEventListener('click', () => removeValue(s.value));
|
|
405
|
+
chip.append(label, x);
|
|
406
|
+
return chip;
|
|
407
|
+
}
|
|
408
|
+
function hidden(value) {
|
|
409
|
+
const h = document.createElement('input');
|
|
410
|
+
h.type = 'hidden';
|
|
411
|
+
h.name = name;
|
|
412
|
+
h.value = value;
|
|
413
|
+
h.setAttribute('data-cb', '');
|
|
414
|
+
return h;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function render() {
|
|
418
|
+
wrap.querySelectorAll('.combobox-chip, input[data-cb]').forEach(n => n.remove());
|
|
419
|
+
if (chips) {
|
|
420
|
+
selected.forEach(s => {
|
|
421
|
+
wrap.insertBefore(makeChip(s), el);
|
|
422
|
+
if (name) wrap.appendChild(hidden(s.value));
|
|
423
|
+
});
|
|
424
|
+
if (!editorNone) el.placeholder = selected.length ? '' : placeholder;
|
|
425
|
+
} else if (!multiple) {
|
|
426
|
+
if (editorNone) el.textContent = selected[0] ? selected[0].label : placeholder;
|
|
427
|
+
else { el.value = selected[0] ? selected[0].label : ''; el.placeholder = placeholder; }
|
|
428
|
+
if (name && selected[0]) wrap.appendChild(hidden(selected[0].value));
|
|
429
|
+
}
|
|
430
|
+
optionEls.forEach(li => li.setAttribute('aria-selected', isSelected(li.dataset.value) ? 'true' : 'false'));
|
|
431
|
+
// At cap: drop the input affordance entirely; a removed chip restores it.
|
|
432
|
+
el.hidden = atCap();
|
|
433
|
+
if (el.hidden) closeMenu();
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Pull complete tokens (those ended by a separator) out of the field into
|
|
437
|
+
// chips, leaving any trailing partial. Reading the value here rather than on
|
|
438
|
+
// keydown avoids the keydown/character-insert race, and covers paste too.
|
|
439
|
+
function extractTokens() {
|
|
440
|
+
if (!separators.length) return;
|
|
441
|
+
const val = el.value;
|
|
442
|
+
if (!val || ![...val].some(ch => separators.includes(ch))) return;
|
|
443
|
+
const parts = []; let buf = '';
|
|
444
|
+
for (const ch of val) {
|
|
445
|
+
if (separators.includes(ch)) { parts.push(buf); buf = ''; }
|
|
446
|
+
else buf += ch;
|
|
447
|
+
}
|
|
448
|
+
el.value = '';
|
|
449
|
+
parts.forEach(p => commitText(p));
|
|
450
|
+
el.value = buf;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Open for fresh entry. A committed single value is shown alongside the full
|
|
454
|
+
// list so it can be swapped; selectText (keyboard focus) also selects it to type over.
|
|
455
|
+
function openForEntry(selectText) {
|
|
456
|
+
const committed = !multiple && selected.length && el.value === selected[0].label;
|
|
457
|
+
if (committed && selectText) el.select();
|
|
458
|
+
if (isAsync) {
|
|
459
|
+
if (committed) { openMenu(); setOptions([]); showEmpty(ui().prompt); }
|
|
460
|
+
else asyncRefresh();
|
|
461
|
+
} else {
|
|
462
|
+
openMenu();
|
|
463
|
+
if (committed) showAll(); else filter();
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ----- Events -----
|
|
468
|
+
if (editorNone) {
|
|
469
|
+
el.addEventListener('click', (e) => {
|
|
470
|
+
e.preventDefault();
|
|
471
|
+
if (menu.matches(':popover-open')) closeMenu();
|
|
472
|
+
else { openMenu(); filter(); }
|
|
473
|
+
});
|
|
474
|
+
el.addEventListener('keydown', (e) => {
|
|
475
|
+
const open = menu.matches(':popover-open');
|
|
476
|
+
if (!open && ['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(e.key)) {
|
|
477
|
+
e.preventDefault(); openMenu(); filter(); return;
|
|
478
|
+
}
|
|
479
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); moveActive(1); }
|
|
480
|
+
else if (e.key === 'ArrowUp') { e.preventDefault(); moveActive(-1); }
|
|
481
|
+
else if (e.key === 'Enter' || e.key === ' ') {
|
|
482
|
+
if (activeIndex >= 0 || activeIndex === -2) { e.preventDefault(); selectOption(liAt(activeIndex)); }
|
|
483
|
+
}
|
|
484
|
+
else if (e.key === 'Escape') { if (open) { e.preventDefault(); closeMenu(); } }
|
|
485
|
+
});
|
|
486
|
+
} else {
|
|
487
|
+
el.addEventListener('keydown', (e) => {
|
|
488
|
+
if (e.key === 'ArrowDown' && menu) { e.preventDefault(); openMenu(); moveActive(1); }
|
|
489
|
+
else if (e.key === 'ArrowUp' && menu) { e.preventDefault(); moveActive(-1); }
|
|
490
|
+
else if (e.key === 'Enter') {
|
|
491
|
+
if (menu && menu.matches(':popover-open') && (activeIndex >= 0 || activeIndex === -2)) {
|
|
492
|
+
e.preventDefault();
|
|
493
|
+
selectOption(liAt(activeIndex));
|
|
494
|
+
} else if (!strict || !menu) {
|
|
495
|
+
if (commitText(el.value)) e.preventDefault();
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
else if (e.key === 'Escape') { if (menu && menu.matches(':popover-open')) { e.preventDefault(); closeMenu(); } }
|
|
499
|
+
else if (e.key === 'Backspace' && el.value === '' && chips && selected.length) {
|
|
500
|
+
removeValue(selected[selected.length - 1].value);
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
// Separators (and paste) commit from here, the moment one lands in the value.
|
|
505
|
+
el.addEventListener('input', () => {
|
|
506
|
+
extractTokens();
|
|
507
|
+
if (isAsync) asyncRefresh(); else { openMenu(); filter(); }
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
el.addEventListener('focus', () => { if (!suppressOpen) openForEntry(true); });
|
|
511
|
+
// Reopen on click even when the field is already focused (focus won't re-fire).
|
|
512
|
+
el.addEventListener('mousedown', () => { if (menu && !menu.matches(':popover-open') && !suppressOpen) openForEntry(false); });
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Close when focus leaves the field entirely (Tab away).
|
|
516
|
+
el.addEventListener('blur', () => setTimeout(() => {
|
|
517
|
+
if (menu && !wrap.contains(document.activeElement) && !menu.contains(document.activeElement)) closeMenu();
|
|
518
|
+
}, 0));
|
|
519
|
+
|
|
520
|
+
// Click empty shell → focus the trigger
|
|
521
|
+
wrap.addEventListener('mousedown', (e) => {
|
|
522
|
+
if (e.target === wrap) { e.preventDefault(); refocus(); }
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
// Outside dismiss (manual popover)
|
|
526
|
+
if (menu && !menu.__mnfstCbDismiss) {
|
|
527
|
+
menu.__mnfstCbDismiss = true;
|
|
528
|
+
document.addEventListener('pointerdown', (e) => {
|
|
529
|
+
if (menu.matches(':popover-open') && !menu.contains(e.target) && !wrap.contains(e.target)) closeMenu();
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// ----- Seed initial chips from value="a, b" (input triggers only) -----
|
|
534
|
+
if (!editorNone && chips && el.value) {
|
|
535
|
+
const seeds = el.value.split(/[,\n;]+/).map(s => s.trim()).filter(Boolean);
|
|
536
|
+
el.value = '';
|
|
537
|
+
seeds.forEach(s => commitText(s));
|
|
538
|
+
}
|
|
539
|
+
render();
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// ----- Boot (mirrors the dropdowns plugin lifecycle) -----
|
|
544
|
+
let comboboxPluginInitialized = false;
|
|
545
|
+
let alpineHasWalked = false;
|
|
546
|
+
document.addEventListener('alpine:initialized', () => { alpineHasWalked = true; });
|
|
547
|
+
|
|
548
|
+
function ensureComboboxPluginInitialized() {
|
|
549
|
+
if (comboboxPluginInitialized) return;
|
|
550
|
+
if (!window.Alpine || typeof window.Alpine.directive !== 'function') return;
|
|
551
|
+
comboboxPluginInitialized = true;
|
|
552
|
+
initializeComboboxPlugin();
|
|
553
|
+
if (alpineHasWalked && typeof window.Alpine.initTree === 'function') {
|
|
554
|
+
document.querySelectorAll('[x-combobox]').forEach(el => { if (!el._x_dataStack) window.Alpine.initTree(el); });
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
window.ensureComboboxPluginInitialized = ensureComboboxPluginInitialized;
|
|
558
|
+
|
|
559
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', ensureComboboxPluginInitialized);
|
|
560
|
+
document.addEventListener('alpine:init', ensureComboboxPluginInitialized);
|
|
561
|
+
if (window.Alpine && typeof window.Alpine.directive === 'function') {
|
|
562
|
+
setTimeout(ensureComboboxPluginInitialized, 0);
|
|
563
|
+
} else {
|
|
564
|
+
const t = setInterval(() => {
|
|
565
|
+
if (window.Alpine && typeof window.Alpine.directive === 'function') { clearInterval(t); ensureComboboxPluginInitialized(); }
|
|
566
|
+
}, 10);
|
|
567
|
+
setTimeout(() => clearInterval(t), 5000);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
})();
|