mnfst 0.5.156 → 0.5.158

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.
@@ -75,7 +75,7 @@
75
75
  /* Chips */
76
76
  :where(.combobox-chip):not(.unstyle) {
77
77
  display: inline-flex;
78
- flex-shrink: 0; /* wrap, don't shrink — overflow:hidden below otherwise lets flex squeeze chips to an ellipsis (notably Safari) */
78
+ flex: 0 0 auto; /* hold content width; don't grow or shrink — overflow:hidden below otherwise lets flex squeeze chips to an ellipsis (notably Safari) */
79
79
  align-items: center;
80
80
  gap: var(--spacing, 0.25rem);
81
81
  max-width: 100%;
@@ -117,6 +117,12 @@
117
117
  }
118
118
  }
119
119
 
120
+ /* Locked — selected but not removable; no × button, so restore the right padding */
121
+ &[data-locked] {
122
+ padding-inline-end: calc(var(--spacing, 0.25rem) * 2);
123
+ cursor: default
124
+ }
125
+
120
126
  /* Failed validation */
121
127
  &[aria-invalid="true"] {
122
128
  color: var(--color-negative-inverse, oklch(44.4% 0.177 26.899));
@@ -81,6 +81,7 @@ function initializeComboboxPlugin() {
81
81
  value: li.dataset.value != null ? li.dataset.value : li.textContent.trim(),
82
82
  label: li.dataset.label || li.textContent.trim(),
83
83
  pattern: li.dataset.pattern || null,
84
+ locked: li.hasAttribute('data-locked'),
84
85
  html: li.innerHTML
85
86
  }));
86
87
  }
@@ -88,11 +89,31 @@ function initializeComboboxPlugin() {
88
89
  value: o.value || o.textContent.trim(),
89
90
  label: o.textContent.trim() || o.value,
90
91
  pattern: o.getAttribute('data-pattern') || null,
92
+ locked: o.hasAttribute('data-locked'),
91
93
  html: null
92
94
  }));
93
95
  }
94
96
 
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.
101
+ function captureModel(el) {
102
+ if (el.__cbModelExpr !== undefined) return;
103
+ let attr = null;
104
+ for (const a of Array.from(el.attributes)) {
105
+ if (a.name === 'x-model' || a.name.indexOf('x-model.') === 0) { attr = a.name; break; }
106
+ }
107
+ el.__cbModelExpr = attr ? el.getAttribute(attr) : null;
108
+ if (attr) el.removeAttribute(attr);
109
+ }
110
+
111
+ // Pre-walk pass: this runs on alpine:init / DOMContentLoaded, before Alpine walks
112
+ // the tree, so x-model is gone by the time Alpine's model directive looks for it.
113
+ document.querySelectorAll('[x-combobox]').forEach(captureModel);
114
+
95
115
  Alpine.directive('combobox', (el, { modifiers, expression }, { cleanup }) => {
116
+ captureModel(el); // fallback for elements added after the pre-walk pass
96
117
  // Build after the current tick so sibling sources (datalist/menu) exist.
97
118
  setTimeout(() => build(el, modifiers, expression || '', cleanup), 0);
98
119
  });
@@ -198,6 +219,41 @@ function initializeComboboxPlugin() {
198
219
  let selected = adopt && seedSelected ? seedSelected : [];
199
220
  const isSelected = (v) => selected.some(s => String(s.value).toLowerCase() === String(v).toLowerCase());
200
221
  const atCap = () => multiple && selected.length >= max;
222
+ const labelFor = (v) => {
223
+ const o = options.find(o => String(o.value).toLowerCase() === String(v).toLowerCase());
224
+ return o ? o.label : String(v);
225
+ };
226
+
227
+ // ----- Locked values (non-removable chips) -----
228
+ // From a `locked: [...]` config list (re-evaluated reactively below) and/or a
229
+ // `data-locked` flag on individual options. Locked chips keep their × hidden and
230
+ // refuse removal while staying selected.
231
+ const lockedFromOptions = options.filter(o => o.locked).map(o => String(o.value).toLowerCase());
232
+ const computeLocked = (cfgObj) => {
233
+ const set = new Set(lockedFromOptions);
234
+ const raw = cfgObj && cfgObj.locked != null ? cfgObj.locked : cfg.locked;
235
+ const list = Array.isArray(raw) ? raw : (raw != null ? String(raw).split(',') : []);
236
+ list.forEach(v => set.add(String(v).trim().toLowerCase()));
237
+ return set;
238
+ };
239
+ let lockedSet = computeLocked();
240
+ const isLocked = (v) => lockedSet.has(String(v).toLowerCase());
241
+
242
+ // ----- Reactive model (x-model) — captured + stripped pre-walk, owned here -----
243
+ // The bound value may be a token array or a CSV string; we preserve whichever the
244
+ // author used (default array for .multiple). Chips render labels; the model carries
245
+ // values/tokens. Read/effect/set are wired near mount, once render() exists.
246
+ const modelExpr = el.__cbModelExpr || null;
247
+ let modelArrayShape = null; // null until first read; true = array, false = CSV
248
+ let modelSet = null;
249
+ const modelToValues = (v) => {
250
+ if (v == null || v === '') return [];
251
+ if (Array.isArray(v)) return v.map(x => (x && typeof x === 'object' && x.value != null) ? x.value : x).map(String);
252
+ if (typeof v === 'string') return v.split(/[,\n;]+/).map(s => s.trim()).filter(Boolean);
253
+ return [String(v)];
254
+ };
255
+ const sameList = (a, b) => a.length === b.length && a.every((x, i) => String(x).toLowerCase() === String(b[i]).toLowerCase());
256
+ function syncOut() { if (modelSet) modelSet(selected.map(s => s.value)); }
201
257
 
202
258
  // ----- Menu -----
203
259
  let menu = null, generatedMenu = null, optionEls = [], createEl = null, emptyEl = null, activeIndex = -1;
@@ -421,18 +477,21 @@ function initializeComboboxPlugin() {
421
477
  function refocus() { suppressOpen = true; el.focus(); setTimeout(() => { suppressOpen = false; }, 0); }
422
478
 
423
479
  function addValue(value, label) {
424
- if (!multiple) { selected = [{ value, label }]; render(); announce(label + ' selected'); return; }
480
+ if (!multiple) { selected = [{ value, label }]; render(); syncOut(); announce(label + ' selected'); return; }
425
481
  if (isSelected(value)) return;
426
482
  if (selected.length >= max) { announce('Maximum of ' + max + ' reached'); return; }
427
483
  selected.push({ value, label });
428
484
  render();
485
+ syncOut();
429
486
  announce(label + ' added');
430
487
  }
431
488
  function removeValue(value) {
489
+ if (isLocked(value)) return; // locked chips stay put
432
490
  const i = selected.findIndex(s => String(s.value).toLowerCase() === String(value).toLowerCase());
433
491
  if (i < 0) return;
434
492
  const [g] = selected.splice(i, 1);
435
493
  render();
494
+ syncOut();
436
495
  announce(g.label + ' removed');
437
496
  if (menu && !isAsync) filter();
438
497
  refocus();
@@ -444,13 +503,24 @@ function initializeComboboxPlugin() {
444
503
  chip.dataset.value = s.value;
445
504
  const label = document.createElement('span');
446
505
  label.textContent = s.label;
506
+ chip.appendChild(label);
507
+ applyLock(chip, s.value, s.label);
508
+ return chip;
509
+ }
510
+ // Add or drop the × to match the value's locked state. Re-run from render() so a
511
+ // reactive `locked` change toggles the affordance on existing chips too.
512
+ function applyLock(chip, value, label) {
513
+ const locked = isLocked(value);
514
+ chip.toggleAttribute('data-locked', locked);
515
+ const btn = chip.querySelector(':scope > button');
516
+ if (locked) { if (btn) btn.remove(); return; }
517
+ if (btn) return;
447
518
  const x = document.createElement('button');
448
519
  x.type = 'button';
449
- x.setAttribute('aria-label', 'Remove ' + s.label);
520
+ x.setAttribute('aria-label', 'Remove ' + (label || (chip.querySelector(':scope > span') || {}).textContent || value));
450
521
  x.textContent = '×';
451
- x.addEventListener('click', () => removeValue(s.value));
452
- chip.append(label, x);
453
- return chip;
522
+ x.addEventListener('click', () => removeValue(value));
523
+ chip.appendChild(x);
454
524
  }
455
525
  function hidden(value) {
456
526
  const h = document.createElement('input');
@@ -462,14 +532,34 @@ function initializeComboboxPlugin() {
462
532
  }
463
533
 
464
534
  function render() {
465
- wrap.querySelectorAll('.combobox-chip, input[data-cb]').forEach(n => n.remove());
466
535
  if (chips) {
536
+ // Incremental: keep existing chip nodes, only add new / drop removed /
537
+ // refresh locked state. Recreating every chip re-inserts them next to the
538
+ // focused editor, which collapses them to an ellipsis in WebKit; untouched
539
+ // nodes keep their width.
540
+ const norm = v => String(v).toLowerCase();
541
+ const have = new Map();
542
+ wrap.querySelectorAll('.combobox-chip').forEach(c => have.set(norm(c.dataset.value), c));
543
+ const want = selected.map(s => norm(s.value));
544
+ const wantSet = new Set(want);
545
+ have.forEach((node, key) => { if (!wantSet.has(key)) { node.remove(); have.delete(key); } });
467
546
  selected.forEach(s => {
468
- wrap.insertBefore(makeChip(s), el);
469
- if (name) wrap.appendChild(hidden(s.value));
547
+ const key = norm(s.value);
548
+ const node = have.get(key);
549
+ if (!node) { const n = makeChip(s); have.set(key, n); wrap.insertBefore(n, el); }
550
+ else applyLock(node, s.value, s.label);
470
551
  });
552
+ // Reorder to match selection only when it actually differs — needless
553
+ // re-insertion collapses chips in WebKit while the editor is focused, so the
554
+ // common append path (order already matches) skips this.
555
+ const cur = Array.from(wrap.querySelectorAll('.combobox-chip')).map(c => norm(c.dataset.value));
556
+ if (!sameList(cur, want)) selected.forEach(s => wrap.insertBefore(have.get(norm(s.value)), el));
557
+ // Hidden inputs are type=hidden (no layout), so a clean rebuild is harmless.
558
+ wrap.querySelectorAll('input[data-cb]').forEach(n => n.remove());
559
+ if (name) selected.forEach(s => wrap.appendChild(hidden(s.value)));
471
560
  if (!editorNone) el.placeholder = selected.length ? '' : placeholder;
472
561
  } else if (!multiple) {
562
+ wrap.querySelectorAll('.combobox-chip, input[data-cb]').forEach(n => n.remove());
473
563
  if (editorNone) el.textContent = selected[0] ? selected[0].label : placeholder;
474
564
  else { el.value = selected[0] ? selected[0].label : ''; el.placeholder = placeholder; }
475
565
  if (name && selected[0]) wrap.appendChild(hidden(selected[0].value));
@@ -587,9 +677,61 @@ function initializeComboboxPlugin() {
587
677
  // a stale, sometimes-open duplicate. Authored/adopted menus go with their container.
588
678
  if (cleanup && generatedMenu) cleanup(() => { if (generatedMenu.isConnected) generatedMenu.remove(); });
589
679
 
590
- // ----- Seed initial chips from value="a, b" (fresh input triggers only;
591
- // an adopted wrapper already provided its selection above) -----
592
- if (!adopt && !editorNone && chips && el.value) {
680
+ // ----- Reactive model (x-model) -----
681
+ // Renders chips from the bound value on init and whenever it changes externally
682
+ // (e.g. switching which record is being edited); writes back on add/remove. The
683
+ // read runs inside an Alpine effect so $x / nested state stay reactive.
684
+ if (modelExpr) {
685
+ const read = Alpine.evaluateLater(el, modelExpr);
686
+ modelSet = (vals) => {
687
+ const out = !multiple ? (vals.length ? vals[0] : '')
688
+ : (modelArrayShape === false ? vals.join(',') : vals.slice());
689
+ try { Alpine.evaluate(el, `${modelExpr} = ${JSON.stringify(out)}`); } catch (_) { }
690
+ };
691
+ Alpine.effect(() => {
692
+ read(raw => {
693
+ if (modelArrayShape === null && raw != null && raw !== '') modelArrayShape = Array.isArray(raw);
694
+ const incoming = modelToValues(raw);
695
+ if (sameList(incoming, selected.map(s => s.value))) return; // unchanged / our own write-back
696
+ selected = incoming.map(v => ({ value: v, label: labelFor(v) }));
697
+ if (!multiple && selected.length > 1) selected = selected.slice(-1);
698
+ render();
699
+ });
700
+ });
701
+ }
702
+
703
+ // ----- Reactive locked list (config-object form re-evaluates, so authors can
704
+ // gate it on state, e.g. last-owner: locked: owners.length <= 1 ? ['owner'] : []) -----
705
+ if (expr.startsWith('{')) {
706
+ Alpine.effect(() => {
707
+ let c; try { c = Alpine.evaluate(el, expr); } catch (_) { return; }
708
+ const next = computeLocked(c && typeof c === 'object' && !Array.isArray(c) ? c : null);
709
+ if (next.size === lockedSet.size && [...next].every(v => lockedSet.has(v))) return;
710
+ lockedSet = next;
711
+ render();
712
+ });
713
+ }
714
+
715
+ // ----- Dynamic options: re-read when x-for / $x fills the source list after
716
+ // build, so the menu, chip labels, and data-locked flags stay current. -----
717
+ if (src) {
718
+ const reread = () => {
719
+ options = readOptions(src);
720
+ lockedFromOptions.length = 0;
721
+ options.filter(o => o.locked).forEach(o => lockedFromOptions.push(String(o.value).toLowerCase()));
722
+ lockedSet = computeLocked();
723
+ selected = selected.map(s => ({ value: s.value, label: labelFor(s.value) }));
724
+ if (generatedMenu) setOptions(options);
725
+ render();
726
+ };
727
+ const mo = new MutationObserver(reread);
728
+ mo.observe(src, { childList: true, subtree: true, characterData: true });
729
+ if (cleanup) cleanup(() => mo.disconnect());
730
+ }
731
+
732
+ // ----- Seed initial chips from value="a, b" — only when there's no x-model
733
+ // (x-model wins) and no adopted wrapper (which already seeded). -----
734
+ if (!modelExpr && !adopt && !editorNone && chips && el.value) {
593
735
  const seeds = el.value.split(/[,\n;]+/).map(s => s.trim()).filter(Boolean);
594
736
  el.value = '';
595
737
  seeds.forEach(s => commitText(s));
package/lib/manifest.css CHANGED
@@ -1533,7 +1533,7 @@
1533
1533
  /* Chips */
1534
1534
  :where(.combobox-chip):not(.unstyle) {
1535
1535
  display: inline-flex;
1536
- flex-shrink: 0; /* wrap, don't shrink — overflow:hidden below otherwise lets flex squeeze chips to an ellipsis (notably Safari) */
1536
+ flex: 0 0 auto; /* hold content width; don't grow or shrink — overflow:hidden below otherwise lets flex squeeze chips to an ellipsis (notably Safari) */
1537
1537
  align-items: center;
1538
1538
  gap: var(--spacing, 0.25rem);
1539
1539
  max-width: 100%;
@@ -1575,6 +1575,12 @@
1575
1575
  }
1576
1576
  }
1577
1577
 
1578
+ /* Locked — selected but not removable; no × button, so restore the right padding */
1579
+ &[data-locked] {
1580
+ padding-inline-end: calc(var(--spacing, 0.25rem) * 2);
1581
+ cursor: default
1582
+ }
1583
+
1578
1584
  /* Failed validation */
1579
1585
  &[aria-invalid="true"] {
1580
1586
  color: var(--color-negative-inverse, oklch(44.4% 0.177 26.899));
@@ -6,7 +6,7 @@
6
6
  "manifest.code.js": "sha384-BJSRJ7txA6fY660qX+SfEEBrEB0dOChHdHI4/9iNGz3rGqohEZdzs7qA3LuR1GJW",
7
7
  "manifest.color.js": "sha384-6Rv3LxyTcZNjrhtayQfqRdCx0uSZ4BiEbgEI98I62eTvp8Aw7LBIoNJ0Je1oktwL",
8
8
  "manifest.colorpicker.js": "sha384-Wqz0ZIbeIi7KarqqqSLsQk+7E/fMaKhb32hrq5/eWzX1yjqMrpPZKH8y+jZ3mfg+",
9
- "manifest.combobox.js": "sha384-Tlw0aURKFQu0TA+ukRvNv+RHWrNIgWo4/h8mVjwm+CjllcTGL3s5krgllUw76jVc",
9
+ "manifest.combobox.js": "sha384-3rP3iWhh5lJsoQcqY6pBJUMzfSqQUqwHRxkSCOJiRo0HbmIXu5YXzy3UFOYnfbrh",
10
10
  "manifest.components.js": "sha384-mzPFoM0vqL9dnTVLMN3OrmO+KCgSqGknM1fd7bM1xzYeCco5OaZi56IMR5RS5oad",
11
11
  "manifest.data.js": "sha384-bCYTYyAYNVkg5pSwGcoe07Dgf5B7JDN7GtOIQdS+BtrBStQwvjZtskiQ38Bncvrf",
12
12
  "manifest.datepicker.js": "sha384-NEb/H4vuR3CFtRcodHsm3jJjrcYW2JMpDlQKlgwTrzpMMTcDkFKYXzAYJD0gZ7Ov",
@@ -27,6 +27,6 @@
27
27
  "manifest.tooltips.js": "sha384-ADzAx9D0HWq2b46mvNG05iOwPmEWdiFZNpEOXONSbBxs4xj1B/bzNL7S3x2R9cS1",
28
28
  "manifest.url.parameters.js": "sha384-FIufiClqDx1rJpU/QUc9z/D43qClQ6Qm8rBahipbJl9BDHUvhrOsUDegmTWW7Tuf",
29
29
  "manifest.utilities.js": "sha384-HWyVkjQoDRlWFKDBQw4RQOYODkBcU72NHW6l1p4bhQv1RtN0/XtnjwIb+lQK6+zv",
30
- "manifest.virtual.js": "sha384-J2+MpRskVH39R2DmHUib2bItuGsvSLEhPM+83iznrdfQFMZ63Ea6xwLeCsG04jOl",
30
+ "manifest.virtual.js": "sha384-0W3Hg1Gu4YcHibh0KB0G56/a2v9n7tO5Fw2Pz2rJmHbitvJWZ69f6nvYRVkQB2JE",
31
31
  "manifest.js": "sha384-zPXrym9jwpYVdg4TJ1XPQVxQhz7uezdDubaO/MZDvLeiTufor+6lNJsTG75cYPXm"
32
32
  }