mnfst 0.5.162 → 0.5.164

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.
@@ -2,15 +2,9 @@
2
2
 
3
3
  (function () {
4
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.
5
+ // Select-like field across four axes: trigger (input/textarea/button), options
6
+ // (none/datalist/menu/async), value (single/multiple), display (text/chips). Modes
7
+ // are modifiers; the value is a bare source id or a { source, max, filter, … } object.
14
8
 
15
9
  /* ------------------------------------------------------------------ *
16
10
  * Shared localized-UI resolver (byte-identical to the datepicker /
@@ -82,7 +76,12 @@ function initializeComboboxPlugin() {
82
76
  label: li.dataset.label || li.textContent.trim(),
83
77
  pattern: li.dataset.pattern || null,
84
78
  locked: li.hasAttribute('data-locked'),
85
- html: li.innerHTML
79
+ html: li.innerHTML,
80
+ // Carry the row's own attributes into the option, minus combobox-managed
81
+ // ones and Alpine bindings (x-/:/@ reference the source's scope, not the clone).
82
+ attrs: Array.from(li.attributes)
83
+ .filter(a => { const n = a.name; return n !== 'role' && n !== 'id' && n !== 'aria-selected' && n[0] !== ':' && n[0] !== '@' && n.indexOf('x-') !== 0; })
84
+ .map(a => [a.name, a.value])
86
85
  }));
87
86
  }
88
87
  return Array.from(src.querySelectorAll('option')).map(o => ({
@@ -94,10 +93,8 @@ function initializeComboboxPlugin() {
94
93
  }));
95
94
  }
96
95
 
97
- // Take ownership of x-model: capture the expression and strip the attribute so
98
- // Alpine's own model directive never binds the editor. In chips mode the editor is
99
- // a transient typing buffer — Alpine's x-model would dump the bound array into it
100
- // and write partial typing back to the model. We read/write the model ourselves.
96
+ // Capture x-model's expression and strip the attribute so Alpine never binds the
97
+ // editor (in chips mode it's a typing buffer; we own read/write ourselves).
101
98
  function captureModel(el) {
102
99
  if (el.__cbModelExpr !== undefined) return;
103
100
  let attr = null;
@@ -108,9 +105,8 @@ function initializeComboboxPlugin() {
108
105
  if (attr) el.removeAttribute(attr);
109
106
  }
110
107
 
111
- // NB: match by attribute-name prefix, not the `[x-combobox]` CSS selector the
112
- // directive is almost always written with modifiers (x-combobox.multiple.chips),
113
- // a literal attribute name that `[x-combobox]` does NOT match.
108
+ // Match by attribute-name prefix, not `[x-combobox]` modifiers make the literal
109
+ // attribute name (x-combobox.multiple.chips) that the CSS selector won't match.
114
110
  function isComboboxEl(el) {
115
111
  if (!el.attributes) return false;
116
112
  for (const a of el.attributes) if (a.name === 'x-combobox' || a.name.indexOf('x-combobox.') === 0) return true;
@@ -123,10 +119,8 @@ function initializeComboboxPlugin() {
123
119
  for (let i = 0; i < all.length; i++) if (isComboboxEl(all[i])) captureModel(all[i]);
124
120
  }
125
121
 
126
- // Strip x-model before Alpine ever binds it. The initial static DOM is handled here
127
- // (this runs on alpine:init, before the walk); subtrees mounted later — x-markdown,
128
- // components — are handled by wrapping the public initTree they call to mount, so the
129
- // attribute is gone before Alpine's model directive looks for it.
122
+ // Strip x-model before Alpine binds it: static DOM here (pre-walk), later-mounted
123
+ // subtrees (x-markdown, components) via the wrapped public initTree below.
130
124
  stripModels(document.body);
131
125
  if (typeof Alpine.initTree === 'function' && !Alpine.__cbInitTreeWrapped) {
132
126
  Alpine.__cbInitTreeWrapped = true;
@@ -144,17 +138,12 @@ function initializeComboboxPlugin() {
144
138
  if (el.__mnfstCombobox) return;
145
139
  el.__mnfstCombobox = true;
146
140
 
147
- // If a mount path bypassed the pre-emptive strip (x-if / x-for mount via Alpine's
148
- // INTERNAL initTree, which our public-initTree wrapper can't see) and Alpine's
149
- // native x-model already bound the editor, neutralize it here. Otherwise its
150
- // value-sync would bleed the raw model into the editor ("a,b") and its input
151
- // listener would write partial typing back to the model. We own read/write below,
152
- // so make both native paths no-ops. captureModel still recovered the expression.
141
+ // If a mount path bypassed the strip (x-if/x-for via Alpine's internal initTree)
142
+ // and native x-model already bound the editor, neutralize both native paths here
143
+ // (value-sync would bleed the model in; the input listener would write typing back).
153
144
  if (el._x_model) {
154
- el._x_forceModelUpdate = function () { }; // kill the model→editor value-sync (no bleed)
155
- // Remove Alpine's input/change listener that writes the editor back to the model
156
- // (a local closure — overriding _x_model.set isn't enough). _x_removeModelListeners
157
- // is Alpine's own removal hook for exactly this.
145
+ el._x_forceModelUpdate = function () { }; // kill the model→editor value-sync
146
+ // Remove Alpine's input/change listener (a closure; overriding .set isn't enough).
158
147
  try {
159
148
  const rm = el._x_removeModelListeners;
160
149
  if (rm) Object.keys(rm).forEach(k => { try { rm[k](); } catch (_) { } });
@@ -163,19 +152,15 @@ function initializeComboboxPlugin() {
163
152
  if (el.value) el.value = '';
164
153
  }
165
154
 
166
- // Sweep generated menus left orphaned by a prior render their controlling
167
- // editor is gone (a SPA x-markdown re-render) or never hydrated (duplicates
168
- // an old prerender baked into <body>). Either way, no connected editor points
169
- // at them, so they can only sit as stale, sometimes-open duplicates.
155
+ // Sweep orphaned generated menus (editor gone after a re-render, or a baked
156
+ // prerender duplicate) no connected editor points at them.
170
157
  document.querySelectorAll('body > menu[id^="combobox-menu-"]').forEach(m => {
171
158
  if (!document.querySelector('[aria-controls="' + m.id + '"]')) m.remove();
172
159
  });
173
160
 
174
161
  // ----- Prerender hydration -----
175
- // A prerendered page bakes in the wrapper this plugin generated. On load we
176
- // adopt that wrapper and re-derive the selection from the baked chips, rather
177
- // than nesting a second .combobox inside it (which shrinks the field and
178
- // truncates the chips). A fresh SPA run has no such wrapper and skips this.
162
+ // A prerendered page bakes in our wrapper; adopt it and re-derive the selection
163
+ // from the baked chips (nesting a second .combobox would shrink/truncate). SPA skips.
179
164
  const adopt = el.parentElement && el.parentElement.classList.contains('combobox') ? el.parentElement : null;
180
165
  let recoveredName = null, seedSelected = null;
181
166
  if (adopt) {
@@ -190,8 +175,8 @@ function initializeComboboxPlugin() {
190
175
  }
191
176
  adopt.querySelectorAll('.combobox-chip, input[data-cb], [role=status]').forEach(n => n.remove());
192
177
  const bakedMenuId = el.getAttribute('aria-controls');
193
- // Remove a generated (datalist/select) menu; authored <menu> sources are the
194
- // source itself and must be kept for readOptions.
178
+ // Remove the baked generated menu (id combobox-menu-…); leave the source
179
+ // element (author's own id) in place for readOptions to re-read.
195
180
  if (bakedMenuId && bakedMenuId.indexOf('combobox-menu-') === 0) {
196
181
  const m = document.getElementById(bakedMenuId);
197
182
  if (m) m.remove();
@@ -241,8 +226,7 @@ function initializeComboboxPlugin() {
241
226
  wrap.className = 'combobox';
242
227
  el.parentNode.insertBefore(wrap, el);
243
228
  wrap.appendChild(el);
244
- // The wrapper IS the field, so move the author's sizing (inline style) onto
245
- // it. Otherwise the editor/button is constrained inside a full-width field.
229
+ // The wrapper is the field, so move the author's inline sizing onto it.
246
230
  const authorStyle = el.getAttribute('style');
247
231
  if (authorStyle) { wrap.setAttribute('style', authorStyle); el.removeAttribute('style'); }
248
232
  }
@@ -266,9 +250,8 @@ function initializeComboboxPlugin() {
266
250
  };
267
251
 
268
252
  // ----- Locked values (non-removable chips) -----
269
- // From a `locked: [...]` config list (re-evaluated reactively below) and/or a
270
- // `data-locked` flag on individual options. Locked chips keep their × hidden and
271
- // refuse removal while staying selected.
253
+ // From a `locked: [...]` config list (reactive below) and/or per-option
254
+ // `data-locked`. Locked chips hide their × and refuse removal.
272
255
  const lockedFromOptions = options.filter(o => o.locked).map(o => String(o.value).toLowerCase());
273
256
  const computeLocked = (cfgObj) => {
274
257
  const set = new Set(lockedFromOptions);
@@ -280,10 +263,9 @@ function initializeComboboxPlugin() {
280
263
  let lockedSet = computeLocked();
281
264
  const isLocked = (v) => lockedSet.has(String(v).toLowerCase());
282
265
 
283
- // ----- Reactive model (x-model) — captured + stripped pre-walk, owned here -----
284
- // The bound value may be a token array or a CSV string; we preserve whichever the
285
- // author used (default array for .multiple). Chips render labels; the model carries
286
- // values/tokens. Read/effect/set are wired near mount, once render() exists.
266
+ // ----- Reactive model (x-model), owned here -----
267
+ // Preserve the author's shape (array or CSV; array default for .multiple). Chips
268
+ // show labels, the model carries values. Read/effect/set wired near mount.
287
269
  const modelExpr = el.__cbModelExpr || null;
288
270
  let modelArrayShape = null; // null until first read; true = array, false = CSV
289
271
  let modelSet = null;
@@ -301,6 +283,7 @@ function initializeComboboxPlugin() {
301
283
 
302
284
  function makeOption(o, i) {
303
285
  const li = document.createElement('li');
286
+ if (o.attrs) o.attrs.forEach(([n, v]) => { try { li.setAttribute(n, v); } catch (_) { } });
304
287
  li.id = menu.id + '-opt-' + i;
305
288
  li.dataset.value = o.value;
306
289
  li.dataset.label = o.label;
@@ -321,21 +304,18 @@ function initializeComboboxPlugin() {
321
304
  }
322
305
 
323
306
  if (hasMenu) {
324
- if (src && src.tagName === 'MENU') {
325
- menu = src;
326
- } else {
327
- menu = document.createElement('menu');
328
- document.body.appendChild(menu); // anchor positioning needs it out of overflow contexts
329
- generatedMenu = menu; // tracked so cleanup() can remove it on re-render
330
- if (src) src.style.setProperty('display', 'none', 'important');
331
- }
307
+ // Always render into a generated menu, even for an authored <menu>: the source
308
+ // stays a data layer x-for/$x can own (reread below mirrors it), the combobox
309
+ // owns the listbox. readOptions keeps each row's innerHTML, preserving markup.
310
+ menu = document.createElement('menu');
311
+ document.body.appendChild(menu); // anchor positioning needs it out of overflow contexts
312
+ generatedMenu = menu; // tracked so cleanup() can remove it on re-render
313
+ if (src) src.style.setProperty('display', 'none', 'important');
332
314
  menu.setAttribute('popover', 'manual');
333
- if (!menu.id) menu.id = 'combobox-menu-' + rand();
315
+ menu.id = 'combobox-menu-' + rand();
334
316
  menu.setAttribute('role', 'listbox');
335
317
  if (multiple) menu.setAttribute('aria-multiselectable', 'true');
336
318
 
337
- Array.from(menu.querySelectorAll('li')).forEach(li => li.remove());
338
-
339
319
  if (create) {
340
320
  createEl = document.createElement('li');
341
321
  createEl.className = 'combobox-create';
@@ -548,8 +528,8 @@ function initializeComboboxPlugin() {
548
528
  applyLock(chip, s.value, s.label);
549
529
  return chip;
550
530
  }
551
- // Add or drop the × to match the value's locked state. Re-run from render() so a
552
- // reactive `locked` change toggles the affordance on existing chips too.
531
+ // Add/drop the × to match locked state. Re-run from render() so a reactive
532
+ // `locked` change toggles existing chips too.
553
533
  function applyLock(chip, value, label) {
554
534
  const locked = isLocked(value);
555
535
  chip.toggleAttribute('data-locked', locked);
@@ -574,10 +554,8 @@ function initializeComboboxPlugin() {
574
554
 
575
555
  function render() {
576
556
  if (chips) {
577
- // Incremental: keep existing chip nodes, only add new / drop removed /
578
- // refresh locked state. Recreating every chip re-inserts them next to the
579
- // focused editor, which collapses them to an ellipsis in WebKit; untouched
580
- // nodes keep their width.
557
+ // Incremental: keep existing chip nodes, add/drop/refresh only. Recreating
558
+ // all re-inserts next to the focused editor, collapsing them in WebKit.
581
559
  const norm = v => String(v).toLowerCase();
582
560
  const have = new Map();
583
561
  wrap.querySelectorAll('.combobox-chip').forEach(c => have.set(norm(c.dataset.value), c));
@@ -590,9 +568,8 @@ function initializeComboboxPlugin() {
590
568
  if (!node) { const n = makeChip(s); have.set(key, n); wrap.insertBefore(n, el); }
591
569
  else applyLock(node, s.value, s.label);
592
570
  });
593
- // Reorder to match selection only when it actually differs — needless
594
- // re-insertion collapses chips in WebKit while the editor is focused, so the
595
- // common append path (order already matches) skips this.
571
+ // Reorder only when order differs — needless re-insertion collapses chips
572
+ // in WebKit; the common append path (order matches) skips this.
596
573
  const cur = Array.from(wrap.querySelectorAll('.combobox-chip')).map(c => norm(c.dataset.value));
597
574
  if (!sameList(cur, want)) selected.forEach(s => wrap.insertBefore(have.get(norm(s.value)), el));
598
575
  // Hidden inputs are type=hidden (no layout), so a clean rebuild is harmless.
@@ -611,9 +588,8 @@ function initializeComboboxPlugin() {
611
588
  if (el.hidden) closeMenu();
612
589
  }
613
590
 
614
- // Pull complete tokens (those ended by a separator) out of the field into
615
- // chips, leaving any trailing partial. Reading the value here rather than on
616
- // keydown avoids the keydown/character-insert race, and covers paste too.
591
+ // Pull separator-terminated tokens out of the field into chips, leaving the
592
+ // trailing partial. Reading the value (not keydown) dodges the insert race and covers paste.
617
593
  function extractTokens() {
618
594
  if (!separators.length) return;
619
595
  const val = el.value;
@@ -628,8 +604,8 @@ function initializeComboboxPlugin() {
628
604
  el.value = buf;
629
605
  }
630
606
 
631
- // Open for fresh entry. A committed single value is shown alongside the full
632
- // list so it can be swapped; selectText (keyboard focus) also selects it to type over.
607
+ // Open for fresh entry. A committed single value shows the full list (swappable);
608
+ // selectText (keyboard focus) also selects it to type over.
633
609
  function openForEntry(selectText) {
634
610
  if (menu) menu.removeAttribute('data-kbd'); // opening is mouse-mode until a key drives it
635
611
  const committed = !multiple && selected.length && el.value === selected[0].label;
@@ -702,8 +678,7 @@ function initializeComboboxPlugin() {
702
678
  if (e.target === wrap) { e.preventDefault(); refocus(); }
703
679
  });
704
680
 
705
- // Outside dismiss (manual popover). Self-removes once the menu leaves the DOM
706
- // so repeated re-renders don't leak document listeners.
681
+ // Outside dismiss (manual popover); self-removes when the menu leaves the DOM.
707
682
  if (menu && !menu.__mnfstCbDismiss) {
708
683
  menu.__mnfstCbDismiss = true;
709
684
  const onDocPointer = (e) => {
@@ -713,15 +688,13 @@ function initializeComboboxPlugin() {
713
688
  document.addEventListener('pointerdown', onDocPointer);
714
689
  if (cleanup) cleanup(() => document.removeEventListener('pointerdown', onDocPointer));
715
690
  }
716
- // A generated (datalist/select) menu lives in <body>; remove it when the field
717
- // is torn down (e.g. an x-markdown re-render in a SPA) so it doesn't orphan as
718
- // a stale, sometimes-open duplicate. Authored/adopted menus go with their container.
691
+ // The generated menu lives in <body>; remove it on teardown so it doesn't orphan
692
+ // as a stale duplicate. Authored/adopted menus go with their container.
719
693
  if (cleanup && generatedMenu) cleanup(() => { if (generatedMenu.isConnected) generatedMenu.remove(); });
720
694
 
721
695
  // ----- Reactive model (x-model) -----
722
- // Renders chips from the bound value on init and whenever it changes externally
723
- // (e.g. switching which record is being edited); writes back on add/remove. The
724
- // read runs inside an Alpine effect so $x / nested state stay reactive.
696
+ // Render chips from the bound value on init and external change; write back on
697
+ // add/remove. Read inside an Alpine effect so $x / nested state stay reactive.
725
698
  if (modelExpr) {
726
699
  const read = Alpine.evaluateLater(el, modelExpr);
727
700
  modelSet = (vals) => {
@@ -741,8 +714,7 @@ function initializeComboboxPlugin() {
741
714
  });
742
715
  }
743
716
 
744
- // ----- Reactive locked list (config-object form re-evaluates, so authors can
745
- // gate it on state, e.g. last-owner: locked: owners.length <= 1 ? ['owner'] : []) -----
717
+ // ----- Reactive locked list (config-object form, so `locked` can gate on state) -----
746
718
  if (expr.startsWith('{')) {
747
719
  Alpine.effect(() => {
748
720
  let c; try { c = Alpine.evaluate(el, expr); } catch (_) { return; }
@@ -753,8 +725,8 @@ function initializeComboboxPlugin() {
753
725
  });
754
726
  }
755
727
 
756
- // ----- Dynamic options: re-read when x-for / $x fills the source list after
757
- // build, so the menu, chip labels, and data-locked flags stay current. -----
728
+ // ----- Dynamic options: re-read when x-for / $x fills the source after build,
729
+ // keeping the menu, chip labels, and data-locked flags current. -----
758
730
  if (src) {
759
731
  const reread = () => {
760
732
  options = readOptions(src);
@@ -762,21 +734,31 @@ function initializeComboboxPlugin() {
762
734
  options.filter(o => o.locked).forEach(o => lockedFromOptions.push(String(o.value).toLowerCase()));
763
735
  lockedSet = computeLocked();
764
736
  selected = selected.map(s => ({ value: s.value, label: labelFor(s.value) }));
765
- if (generatedMenu) setOptions(options);
737
+ setOptions(options);
766
738
  render();
739
+ // Keep an open list (and its empty-state) correct after the source changes.
740
+ if (menu && menu.matches(':popover-open')) filter();
767
741
  };
768
742
  const mo = new MutationObserver(reread);
769
- mo.observe(src, { childList: true, subtree: true, characterData: true });
743
+ // childList/characterData: x-for row edits. attributes: bound data-* toggles.
744
+ mo.observe(src, {
745
+ childList: true, subtree: true, characterData: true,
746
+ attributes: true, attributeFilter: ['data-value', 'data-label', 'data-locked', 'data-pattern']
747
+ });
770
748
  if (cleanup) cleanup(() => mo.disconnect());
771
749
  }
772
750
 
773
- // ----- Seed initial chips from value="a, b" — only when there's no x-model
774
- // (x-model wins) and no adopted wrapper (which already seeded). -----
751
+ // ----- Seed initial chips from value="a, b" — only when no x-model (it wins)
752
+ // and no adopted wrapper (already seeded). -----
775
753
  if (!modelExpr && !adopt && !editorNone && chips && el.value) {
776
754
  const seeds = el.value.split(/[,\n;]+/).map(s => s.trim()).filter(Boolean);
777
755
  el.value = '';
778
756
  seeds.forEach(s => commitText(s));
779
757
  }
758
+ // In chips/multiple the editor is a typing buffer. Clear value AND remove the
759
+ // attribute: mnfst-render serializes the attribute, so leaving it bakes seed text
760
+ // into the prerendered editor (shows as inline text on hydration in Safari).
761
+ if ((chips || multiple) && !editorNone) { el.value = ''; el.removeAttribute('value'); }
780
762
  render();
781
763
  }
782
764
  }