mnfst 0.5.163 → 0.5.165

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.
@@ -1,37 +1,14 @@
1
- /* Manifest Date Picker
1
+ /* Manifest Date Picker — modular date/time picker behind one `x-date` directive
2
2
  /* By Andrew Matlock under MIT license
3
3
  /* https://manifestx.dev
4
- /*
5
- /* A fully modular date (and time) picker behind one `x-date` directive.
6
- /*
7
- /* The element decides its role — no role modifiers needed:
8
- /* <input x-date> field with auto-generated dropdown calendar
9
- /* <input x-date type="date"> same, degrades to the native picker without JS
10
- /* <input x-date type="time"> standalone time picker
11
- /* <input x-date type="datetime-local"> date + time, degrades natively without JS
12
- /* <button x-date="cal-id"> button trigger for an authored calendar
13
- /* <div x-date> inline calendar
14
- /* <menu popover x-date> / <dialog x-date> dropdown / modal calendar
15
- /*
16
- /* Selection modes are modifiers: x-date.range, x-date.multiple, x-date.time
17
- /* (x-date.time on a date field appends a time-of-day control).
18
- /*
19
- /* The value is polymorphic, mirroring the color picker's swatch:
20
- /* string → the id of an authored calendar element (fields only)
21
- /* object → reactive config: { months, min, max, disabled, presets }
22
4
  */
23
5
 
24
6
  (function () {
25
7
  'use strict';
26
8
 
27
- /* ------------------------------------------------------------------ *
28
- * Shared global: ManifestDates in-house, dependency-free date math.
29
- * Operates on date-only values anchored at noon local time, which makes
30
- * every add/subtract immune to DST shifts (a ±1h move never crosses a
31
- * calendar day from noon). All formatting/localization is delegated to
32
- * Intl, so we never parse locale-formatted strings — the one place a date
33
- * library would earn its weight. Thin by design: swappable for Temporal.
34
- * ------------------------------------------------------------------ */
9
+ /* Shared global: ManifestDates — dependency-free date math, noon-anchored to
10
+ * dodge DST shifts. Formatting/localization delegated to Intl (never parses
11
+ * locale strings). */
35
12
  if (!window.ManifestDates) {
36
13
  const pad = (n) => String(n).padStart(2, '0');
37
14
  const D = {
@@ -78,24 +55,13 @@
78
55
  window.ManifestDates = D;
79
56
  }
80
57
 
81
- /* ------------------------------------------------------------------ *
82
- * Shared global: ManifestUI universal `_ui` text resolver.
83
- * Any Manifest element with baked-in default chrome (kept intentionally
84
- * rare) can localize/override that text without rebuilding the element.
85
- * `_ui` is a reserved, self-identifying key: any loaded data source may
86
- * carry a top-level `_ui` object, namespaced per element, e.g.
87
- * _ui: { date: { today: …, clear: … }, colorpicker: { … } }
88
- * No manifest flag — overrides piggyback on the normal local-data/
89
- * localization model and can be colocated with author content. Values may
90
- * be plain strings or $x/$locale references (locale-reactive). resolve()
91
- * deep-merges every loaded source's `_ui[component]` onto the plugin's
92
- * English fallbacks. Defensive: any failure returns the fallbacks untouched.
93
- * Kept byte-identical across the colorpicker / charts copies.
94
- * ------------------------------------------------------------------ */
58
+ /* Shared global: ManifestUI — `_ui` text resolver. A loaded data source may carry
59
+ * a top-level `_ui` object namespaced per component (_ui.date, _ui.colorpicker, …);
60
+ * resolve() deep-merges it onto the plugin's English fallbacks. Locale-reactive via
61
+ * $x/$locale. Kept byte-identical across the colorpicker / charts copies. */
95
62
  if (!window.ManifestUI) {
96
63
  window.ManifestUI = {
97
- /* Names of data sources that have loaded (current locale). Enumerates loaded
98
- * sources only — never force-loads others just to scan them for `_ui`. */
64
+ /* Loaded data-source names only never force-loads others to scan them. */
99
65
  _loadedSourceNames() {
100
66
  try {
101
67
  const store = window.ManifestDataStore && window.ManifestDataStore.rawDataStore;
@@ -103,8 +69,7 @@
103
69
  } catch (_) { }
104
70
  return [];
105
71
  },
106
- /* Deep-merge every loaded source's `_ui[component]` onto `fallbacks`.
107
- * Reads inside the caller's Alpine effect (if any) so $x/$locale make it reactive. */
72
+ /* Deep-merge every loaded source's `_ui[component]` onto `fallbacks`. */
108
73
  resolve(component, fallbacks) {
109
74
  const merged = JSON.parse(JSON.stringify(fallbacks || {}));
110
75
  try {
@@ -141,8 +106,7 @@
141
106
  const Alpine = window.Alpine;
142
107
  const D = window.ManifestDates;
143
108
 
144
- // Reactive registry: id -> api, so $date('id') resolves even when the
145
- // calendar mounts after the reader binds.
109
+ // Reactive registry id -> api, so $date('id') resolves across mount order.
146
110
  const _registry = Alpine.reactive ? Alpine.reactive({}) : {};
147
111
  let _uid = 0;
148
112
 
@@ -188,9 +152,7 @@
188
152
  return (!isNaN(n) && n >= 0 && n <= 6) ? n : null;
189
153
  }
190
154
 
191
- // Intl gives typographically-correct names ("juin", "lun."), but chrome
192
- // labels should read like an author wrote them: capitalized, no
193
- // abbreviation periods. aria-labels keep the untouched Intl output.
155
+ // Capitalize + strip trailing periods for chrome labels; aria-labels keep raw Intl output.
194
156
  function tidy(s, locale) {
195
157
  if (!s) return s;
196
158
  let out = s.replace(/\.+$/, '');
@@ -302,9 +264,8 @@
302
264
  return { start: s, end: e };
303
265
  },
304
266
 
305
- // (Re)apply selection/today state to a day button. Selection and
306
- // today are ARIA attributes (aria-selected / aria-current="date");
307
- // range shape has no ARIA equivalent, so plain state classes.
267
+ // (Re)apply selection/today to a day button ARIA for selection/today,
268
+ // plain classes for range shape (no ARIA equivalent).
308
269
  applyDayState(btn, date) {
309
270
  btn.classList.remove('range-start', 'range-end', 'in-range');
310
271
  btn.removeAttribute('aria-selected');
@@ -331,10 +292,7 @@
331
292
 
332
293
  select(date) {
333
294
  if (!date || this.isDisabled(date)) return;
334
- // Keep the viewed months as-is when the picked date is already
335
- // visible (matters in multi-month: clicking a day in the 2nd
336
- // month must not scroll it into the 1st). Only jump when the
337
- // date falls outside the visible [view.m … view.m+count-1] window.
295
+ // Jump the view only when the picked date is outside the visible month window.
338
296
  const count = Math.max(1, this.monthCount || 1);
339
297
  const idx = (date.getFullYear() * 12 + date.getMonth()) - (this.view.y * 12 + (this.view.m - 1));
340
298
  if (idx < 0 || idx >= count) this.view = { y: date.getFullYear(), m: date.getMonth() + 1 };
@@ -361,8 +319,7 @@
361
319
  this.selected = date;
362
320
  if (this.withTime) this.timeSet = true;
363
321
  this.syncOut(); this.bump(); this.render();
364
- // With a time control, keep the dropdown open so the user can set
365
- // the time after picking the day.
322
+ // With a time control, stay open so the user can set the time.
366
323
  if (this.mode === 'menu' && !this.withTime) this.close();
367
324
  },
368
325
 
@@ -418,17 +375,13 @@
418
375
  const disp = this.displayValue();
419
376
  if (this.field && this.field.tagName === 'INPUT' && this.field.type !== 'hidden') {
420
377
  this.field.value = disp;
421
- // Alpine's own x-model on the input writes the RAW model value
422
- // into it during the same reactive flush — re-assert the
423
- // human-readable display after that write settles.
378
+ // Re-assert the display after x-model writes the raw value back this flush.
424
379
  if (this.field.hasAttribute('x-model')) {
425
380
  const f = this.field;
426
381
  setTimeout(() => { f.value = this.displayValue(); }, 0);
427
382
  }
428
383
  } else if (this.field && !this.field.firstElementChild) {
429
- // Text-only triggers swap their text for the selection.
430
- // Triggers with element children (icons, custom layout) own
431
- // their content — authors bind $date(id) for display.
384
+ // Text-only triggers swap their text; triggers with element children own theirs.
432
385
  this.field.textContent = disp || this.fieldPlaceholder;
433
386
  }
434
387
  },
@@ -467,13 +420,8 @@
467
420
  }
468
421
  },
469
422
 
470
- // Only the plugin-generated dropdown (for an <input> field, which
471
- // can't carry popovertarget without becoming a button) is opened
472
- // here. We still fire native showPopover() — so the menu rides the
473
- // reset.css popover transitions, dropdown.css anchor positioning
474
- // (anchor-name/position-anchor are wired in wireField), light-
475
- // dismiss, and nested-popover cascade. Authored <div>/<menu>/
476
- // <dialog> calendars are inline; native HTML owns their visibility.
423
+ // Only the plugin-generated dropdown opens here; native showPopover() rides
424
+ // the reset.css/dropdown.css popover wiring. Authored calendars are inline.
477
425
  open() {
478
426
  if (this.mode !== 'menu') return;
479
427
  this.viewMode = 'days';
@@ -718,17 +666,10 @@
718
666
  return wrap;
719
667
  }
720
668
 
721
- /* ---- Time of day ---------------------------------------------
722
- * No native time input (its picker UI is unstylable). Two shapes:
723
- * Standalone (timeOnly): the dropdown IS the time picker, so the
724
- * hour/minute/(AM-PM) columns render inline no nested menu. Now and
725
- * Clear (configurable) sit in the actions column.
726
- * • Date+time: a ghost row of typed segments inside the calendar, with a
727
- * columns picker opened by a popovertarget chevron. That menu is
728
- * appended to <body> so the calendar's overflow can't clip it, yet the
729
- * in-calendar invoker keeps it nested (it won't dismiss the calendar).
730
- * setTime never triggers a full re-render (that would blur the segments).
731
- * ---------------------------------------------------------------- */
669
+ /* ---- Time of day — no native time input (unstylable). Standalone: columns
670
+ * render inline in the dropdown. Date+time: typed segments + a body-appended
671
+ * columns menu (nested under an in-calendar invoker). setTime avoids a full
672
+ * re-render to keep segment focus. */
732
673
  function renderTime(state, shell, locale, ui) {
733
674
  const h12 = uses12h(locale);
734
675
  const cur = () => { const [h, mi] = state.time.split(':').map(Number); return { h: h || 0, mi: mi || 0 }; };
@@ -823,9 +764,8 @@
823
764
  if (h12) { apBtn = document.createElement('button'); apBtn.type = 'button'; apBtn.setAttribute('aria-label', 'AM or PM'); fieldBox.appendChild(apBtn); }
824
765
  segs = { hIn, mIn, apBtn };
825
766
 
826
- // Columns menu — auto popover appended to <body> so the calendar's
827
- // overflow can't clip it. The popovertarget chevron lives inside the
828
- // calendar, so the menu nests under it (won't dismiss it on interaction).
767
+ // Columns menu — body-appended popover so the calendar's overflow can't clip it,
768
+ // yet nests under the in-calendar invoker (won't dismiss the calendar).
829
769
  const menu = buildColumns(document.createElement('menu'), false);
830
770
  menu.setAttribute('popover', '');
831
771
  menu.id = 'mnfst-dp-time-' + (++_uid);
@@ -839,8 +779,7 @@
839
779
  trigger.setAttribute('popovertarget', menu.id);
840
780
  trigger.setAttribute('aria-label', ui.time);
841
781
  fieldBox.appendChild(trigger);
842
- // The "Time" label is a native invoker too: label activation forwards
843
- // the click to its associated button, whose popovertarget opens the menu.
782
+ // The "Time" label is an invoker too (htmlFor forwards activation to the button).
844
783
  lab.htmlFor = trigger.id;
845
784
 
846
785
  const timeAnchor = '--datepicker-time-' + _uid;
@@ -961,11 +900,8 @@
961
900
  }
962
901
 
963
902
  /* ---- Config: the directive's value --------------------------- */
964
- // An object value is reactive config: { months, min, max, disabled,
965
- // presets }. min/max also read from the element's native attributes
966
- // (an input's own min/max are semantic). Modal/dropdown opening of
967
- // authored calendars is native HTML's job (popovertarget), so the value
968
- // is no longer used to link a field to a calendar by id.
903
+ // An object value is reactive config: { months, min, max, disabled, presets }.
904
+ // min/max also read the element's native attributes.
969
905
  function applyAttrConfig(state, el) {
970
906
  const min = el.getAttribute('min');
971
907
  const max = el.getAttribute('max');
@@ -990,14 +926,8 @@
990
926
  if (dirty) { state.bump(); if (state._mounted) state.render(); }
991
927
  });
992
928
  }
993
- /* ---- Reactive chrome ------------------------------------------
994
- * Re-render when locale, direction, or the resolved `_ui` text actually
995
- * changes. An Alpine effect tracking $locale + the data store's version
996
- * heartbeat covers async locale-source reloads settling after a switch,
997
- * and `_ui` sources that finish loading post-mount. The resolved-output
998
- * comparison keeps high-frequency _dataVersion bumps (e.g. realtime
999
- * plugins) from re-rendering a calendar that didn't change.
1000
- * ---------------------------------------------------------------- */
929
+ /* ---- Reactive chrome — re-render when locale, direction, or resolved `_ui`
930
+ * text changes. Output comparison skips no-op _dataVersion bumps. */
1001
931
  function bindReactiveRender(state) {
1002
932
  if (state._uiEffectBound) return;
1003
933
  state._uiEffectBound = true;
@@ -1019,10 +949,7 @@
1019
949
  const read = Alpine.evaluateLater(el, modelExpr);
1020
950
  state.modelGet = (cb) => { let out; read(v => { out = v; if (cb) cb(v); }); return out; };
1021
951
  state.modelSet = (v) => { try { Alpine.evaluate(el, `${modelExpr} = ${JSON.stringify(v)}`); } catch (_) { } };
1022
- // React to external model changes. Compare against the full primary
1023
- // value (date+time when withTime) and re-sync the field display —
1024
- // syncOut writes the same value back to the model, which Alpine
1025
- // treats as a no-op, so this cannot loop.
952
+ // React to external model changes; syncOut writes the same value back (no loop).
1026
953
  Alpine.effect(() => { read(v => { const iso = (v instanceof Date) ? D.toISO(v) : v; const cur = state._primaryValue(); if (typeof iso === 'string' && iso !== cur) { const tm = iso.match(/T(\d{2}:\d{2})/); if (tm) state.time = tm[1]; const d = D.fromISO(iso); state.selected = d; if (d) state.view = { y: d.getFullYear(), m: d.getMonth() + 1 }; state.bump(); if (state._mounted) { state.render(); state.syncOut(); } } }); });
1027
954
  }
1028
955
 
@@ -1034,11 +961,9 @@
1034
961
  return menu;
1035
962
  }
1036
963
 
1037
- /* ---- Wiring an <input>/<button> field to a generated dropdown -
1038
- * The plugin generates and OWNS this menu's open/close because a text
1039
- * <input> can't be a native popover invoker. It's a dropdown, not a
1040
- * modal — authored <dialog>/<menu> calendars are inline and opened
1041
- * natively (popovertarget). */
964
+ /* ---- Wiring an <input>/<button> field to a generated dropdown — the plugin
965
+ * owns this menu's open/close (a text <input> can't be a native popover
966
+ * invoker). Authored <dialog>/<menu> calendars are inline. */
1042
967
  function wireField(fieldEl, expression, modifiers, cleanup) {
1043
968
  const calendar = createDefaultMenu(fieldEl);
1044
969
  const state = (calendar._dateState = createState(calendar));
@@ -1048,8 +973,7 @@
1048
973
  else if (modifiers.includes('multiple')) state.selectionMode = 'multiple';
1049
974
  if (modifiers.includes('time')) state.withTime = true;
1050
975
 
1051
- // Piggyback the input's authored type: it declares the format AND is
1052
- // the no-JS fallback (native picker). We capture it, then take over
976
+ // Piggyback the input's authored type (format + no-JS fallback), then take over
1053
977
  // with text+readonly so the styled calendar owns the UX.
1054
978
  if (fieldEl.tagName === 'INPUT') {
1055
979
  const authored = (fieldEl.getAttribute('type') || '').toLowerCase();
@@ -1060,19 +984,15 @@
1060
984
  fieldEl.readOnly = true; fieldEl.autocomplete = 'off';
1061
985
  } else {
1062
986
  applyAttrConfig(state, fieldEl);
1063
- // Text-only triggers: the authored text is the placeholder the
1064
- // selection swaps in and out of. Triggers with element children
1065
- // are left untouched (syncOut skips them).
987
+ // Text-only triggers: authored text is the placeholder the selection swaps.
1066
988
  state.fieldPlaceholder = fieldEl.firstElementChild ? '' : fieldEl.textContent.trim();
1067
989
  }
1068
990
 
1069
991
  // Object config in the value.
1070
992
  bindConfigValue(state, fieldEl, expression);
1071
993
 
1072
- // Synthesize a hidden input for form participation if the field carries
1073
- // a name. The name moves to the hidden (ISO) input — otherwise the
1074
- // visible field would submit its display text under the same key.
1075
- // No-JS fallback is unaffected: this only runs once the plugin has.
994
+ // Move the name onto a synthesized hidden (ISO) input so the form submits the
995
+ // value, not the display text.
1076
996
  const nameAttr = fieldEl.getAttribute('name');
1077
997
  if (nameAttr && !state.hiddenInput) {
1078
998
  const hidden = document.createElement('input');
@@ -1092,10 +1012,8 @@
1092
1012
  // Also resolve $date(fieldId) when the calendar is an auto-generated menu.
1093
1013
  if (fieldEl.id) _registry[fieldEl.id] = state.api;
1094
1014
 
1095
- // CSS anchor positioning (same wiring as the dropdowns plugin): the
1096
- // field is the anchor, dropdown.css positions the menu bottom-start
1097
- // with auto-flip fallbacks. We only fire showPopover() on click since
1098
- // a text <input> can't carry popovertarget.
1015
+ // CSS anchor positioning (as in the dropdowns plugin); showPopover() on click
1016
+ // since a text <input> can't carry popovertarget.
1099
1017
  const anchorName = '--datepicker-' + (++_uid);
1100
1018
  fieldEl.style.setProperty('anchor-name', `${anchorName}, var(--co-anchor, --no-anchor)`);
1101
1019
  fieldEl.style.setProperty('--trigger-anchor', anchorName);
@@ -1106,8 +1024,7 @@
1106
1024
  calendar.addEventListener('toggle', (e) => fieldEl.setAttribute('aria-expanded', e.newState === 'open' ? 'true' : 'false'));
1107
1025
  fieldEl.addEventListener('click', () => state.open());
1108
1026
 
1109
- // Reactive chrome re-render (locale, direction, _ui). Auto-generated
1110
- // menus never run the calendar-element path, so wire it here too.
1027
+ // Reactive chrome re-render (locale, direction, _ui).
1111
1028
  bindReactiveRender(state);
1112
1029
 
1113
1030
  if (cleanup) cleanup(() => {
@@ -1128,9 +1045,7 @@
1128
1045
  return;
1129
1046
  }
1130
1047
 
1131
- // Authored calendar <div>, <menu popover>, <dialog popover>. Always
1132
- // inline: the plugin renders into it; native HTML owns any
1133
- // open/close (popovertarget, light-dismiss).
1048
+ // Authored calendar (<div>/<menu>/<dialog>) always inline; native HTML owns open/close.
1134
1049
  const state = el._dateState || (el._dateState = createState(el));
1135
1050
  state.mode = 'inline';
1136
1051
  if (modifiers.includes('range')) state.selectionMode = 'range';
@@ -1,18 +1,14 @@
1
1
  /* Manifest Dropdowns */
2
2
 
3
- // Track the user's most recent input modality so we only move focus into a menu
4
- // (and surface a :focus-visible ring) for keyboard opens. Capture-phase so we
5
- // see the event before any handler can stop it. This mirrors the heuristic the
6
- // browser uses for :focus-visible, but lets us decide whether to focus at all.
3
+ // Most recent input modality, so we only move focus into a menu for keyboard opens.
4
+ // Capture-phase so no handler can stop it first.
7
5
  let lastInputModality = 'mouse';
8
6
  document.addEventListener('keydown', () => { lastInputModality = 'keyboard'; }, true);
9
7
  document.addEventListener('pointerdown', () => { lastInputModality = 'mouse'; }, true);
10
8
 
11
- // Initialize plugin when either DOM is ready or Alpine is ready
12
9
  function initializeDropdownPlugin() {
13
- // Ensure Alpine.js context exists for directives to work. Keep the scope
14
- // empty — seeding properties here collides with author state and the
15
- // tabs plugin's page-level `tab` property.
10
+ // Ensure an Alpine context exists. Keep the scope empty seeding properties
11
+ // collides with author state and the tabs plugin's `tab` property.
16
12
  function ensureAlpineContext() {
17
13
  const body = document.body;
18
14
  if (!body.hasAttribute('x-data')) {
@@ -186,14 +182,9 @@ function initializeDropdownPlugin() {
186
182
  el.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') pointMenuHere(); });
187
183
  }
188
184
 
189
- // ----- A11y wiring (WAI-ARIA Menu Button pattern) -----
190
- // The trigger needs `aria-haspopup="menu"`, `aria-controls`, and a
191
- // dynamic `aria-expanded` that follows the popover's open state.
192
- // The menu element gets `role="menu"` and each list item gets
193
- // `role="menuitem"` so screen readers announce the relationship.
194
- //
195
- // We don't apply these for `.context` dropdowns invoked by right-
196
- // click — they're not button-triggered popups in the APG sense.
185
+ // A11y wiring (WAI-ARIA Menu Button): trigger gets aria-haspopup/
186
+ // controls/expanded, menu gets role=menu, items role=menuitem. Skipped
187
+ // for .context (right-click) not button-triggered popups per APG.
197
188
  if (!modifiers.includes('context')) {
198
189
  if (!menu.id) menu.id = 'mnfst-dropdown-' + Math.random().toString(36).slice(2, 9);
199
190
  el.setAttribute('aria-haspopup', 'menu');
@@ -257,9 +248,7 @@ function initializeDropdownPlugin() {
257
248
  if (menu.matches(':popover-open')) menu.hidePopover();
258
249
  };
259
250
 
260
- // Bind the right-click trigger once per element. Without
261
- // the guard, reprocessing (router re-walks, re-init) would
262
- // stack duplicate handlers.
251
+ // Bind once per element (reprocessing would stack duplicates).
263
252
  if (!el.__mnfstContextTriggerBound) {
264
253
  el.__mnfstContextTriggerBound = true;
265
254
  el.addEventListener('contextmenu', (e) => {
@@ -291,10 +280,8 @@ function initializeDropdownPlugin() {
291
280
  });
292
281
  }
293
282
 
294
- // Document-level dismiss listeners are GLOBALre-binding
295
- // them on every reprocess leaks listeners on `document`.
296
- // Bind once per menu, tied to an AbortController so they
297
- // share one removable signal.
283
+ // Document-level dismiss listeners are globalbind once per
284
+ // menu via an AbortController so reprocessing can't leak them.
298
285
  if (!menu.__mnfstContextDismissBound) {
299
286
  menu.__mnfstContextDismissBound = true;
300
287
  const ctxAbort = new AbortController();
@@ -394,14 +381,11 @@ function initializeDropdownPlugin() {
394
381
  if (firstLi && !menu.querySelector('button, [href], input, select, textarea, [tabindex="0"]')) {
395
382
  firstLi.setAttribute('tabindex', '0');
396
383
  if (lastInputModality === 'keyboard') {
397
- // Keyboard open (APG menu pattern): focus the first item;
398
- // :focus-visible correctly paints the ring.
384
+ // Keyboard open: focus the first item (rings correctly).
399
385
  firstLi.focus();
400
386
  } else {
401
- // Pointer open: park focus on the menu itself so arrow
402
- // keys still enter the list, without forcing a
403
- // focus-visible ring on an item the user didn't reach
404
- // by keyboard.
387
+ // Pointer open: park focus on the menu so arrow keys enter
388
+ // the list without ringing an item the user didn't reach.
405
389
  if (!menu.hasAttribute('tabindex')) menu.setAttribute('tabindex', '-1');
406
390
  menu.focus();
407
391
  }
@@ -446,11 +430,9 @@ function initializeDropdownPlugin() {
446
430
  });
447
431
  }
448
432
 
449
- // Track initialization to prevent duplicates
450
433
  let dropdownPluginInitialized = false;
451
434
 
452
- // True once Alpine has completed its initial DOM walk. Listener is bound at
453
- // module load so we never miss the event, whatever the script order.
435
+ // True once Alpine finished its initial walk. Bound at load so we never miss it.
454
436
  let alpineHasWalked = false;
455
437
  document.addEventListener('alpine:initialized', () => { alpineHasWalked = true; });
456
438
 
@@ -465,13 +447,10 @@ function ensureDropdownPluginInitialized() {
465
447
  dropdownPluginInitialized = true;
466
448
  initializeDropdownPlugin();
467
449
 
468
- // Only walk existing [x-dropdown] subtrees ourselves when Alpine has ALREADY
469
- // finished its initial walk (i.e. this plugin loaded late). In the normal
470
- // flow we register the directive during `alpine:init`, before Alpine's one
471
- // boot walk so Alpine processes every dropdown with all sibling directives
472
- // (x-icon, x-tooltip, …) already registered. Walking here during boot would
473
- // re-init those subtrees before the other directives exist, permanently
474
- // dropping nested plugin content (e.g. a trailing x-icon in a menu button).
450
+ // Walk existing [x-dropdown] subtrees only if Alpine already finished its boot
451
+ // walk (late load). During boot, let Alpine's own walk handle them with all
452
+ // sibling directives registered walking here would drop nested plugin content
453
+ // (e.g. a trailing x-icon in a menu button).
475
454
  if (alpineHasWalked && typeof window.Alpine.initTree === 'function') {
476
455
  document.querySelectorAll('[x-dropdown]').forEach(el => {
477
456
  if (!el.__x) {