@zeyos/zx 4.2.0 → 4.3.4

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.
Files changed (69) hide show
  1. package/CHANGELOG.md +148 -1
  2. package/README.md +5 -3
  3. package/dist/types/components/calendar/calendar-model.d.ts +22 -0
  4. package/dist/types/components/calendar/calendar.d.ts +19 -2
  5. package/dist/types/components/card-view/card-view.d.ts +1 -1
  6. package/dist/types/components/card-view/record-card.d.ts +47 -5
  7. package/dist/types/components/dialog/dialog.d.ts +6 -1
  8. package/dist/types/components/dropdown/dropdown.d.ts +5 -0
  9. package/dist/types/components/kanban-view/kanban-policy.d.ts +268 -0
  10. package/dist/types/components/kanban-view/kanban-view.d.ts +669 -24
  11. package/dist/types/components/launcher/launcher.d.ts +5 -0
  12. package/dist/types/components/message/message.d.ts +9 -1
  13. package/dist/types/components/modal/modal.d.ts +44 -7
  14. package/dist/types/components/sheet/sheet.d.ts +22 -12
  15. package/dist/types/core/component.d.ts +6 -1
  16. package/dist/types/core/overlay-host.d.ts +12 -0
  17. package/dist/types/index.d.ts +2 -1
  18. package/dist/zx-zeyos.esm.js +173 -43
  19. package/dist/zx-zeyos.esm.min.js +1 -1
  20. package/dist/zx.css +876 -230
  21. package/dist/zx.esm.js +2053 -338
  22. package/dist/zx.esm.min.js +3 -3
  23. package/dist/zx.global.js +2053 -338
  24. package/dist/zx.global.min.js +3 -3
  25. package/dist/zx.min.css +1 -1
  26. package/docs/api.json +250 -2
  27. package/docs/llms.md +67 -48
  28. package/docs/llms.txt +1 -3
  29. package/package.json +1 -2
  30. package/src/components/account-menu/account-menu.css +1 -1
  31. package/src/components/app-rail/app-rail.css +1 -1
  32. package/src/components/calendar/calendar-model.js +45 -0
  33. package/src/components/calendar/calendar.css +96 -34
  34. package/src/components/calendar/calendar.js +122 -29
  35. package/src/components/card-view/card-view.css +211 -9
  36. package/src/components/card-view/card-view.js +7 -7
  37. package/src/components/card-view/record-card.js +92 -19
  38. package/src/components/context-menu/context-menu.css +2 -2
  39. package/src/components/context-menu/context-menu.js +5 -1
  40. package/src/components/date-picker/date-picker.css +18 -11
  41. package/src/components/datebox/date-range-box.css +4 -2
  42. package/src/components/datebox/datebox.css +4 -2
  43. package/src/components/dialog/dialog.css +4 -3
  44. package/src/components/dialog/dialog.js +2 -1
  45. package/src/components/dropdown/dropdown.css +4 -2
  46. package/src/components/dropdown/dropdown.js +7 -2
  47. package/src/components/kanban-view/kanban-policy.js +412 -0
  48. package/src/components/kanban-view/kanban-view.css +480 -36
  49. package/src/components/kanban-view/kanban-view.js +1514 -235
  50. package/src/components/launcher/launcher.css +10 -8
  51. package/src/components/launcher/launcher.js +9 -2
  52. package/src/components/menu-button/menu-button.css +2 -2
  53. package/src/components/message/message.css +15 -25
  54. package/src/components/message/message.js +17 -11
  55. package/src/components/modal/modal.css +6 -4
  56. package/src/components/modal/modal.js +99 -25
  57. package/src/components/select/select.css +9 -7
  58. package/src/components/sheet/sheet.css +25 -7
  59. package/src/components/sheet/sheet.js +43 -23
  60. package/src/components/table/table.css +5 -3
  61. package/src/components/tag-picker/tag-picker.css +5 -3
  62. package/src/components/tooltip/tooltip.css +4 -2
  63. package/src/components/tooltip/tooltip.js +4 -1
  64. package/src/core/component.js +8 -3
  65. package/src/core/overlay-host.js +23 -0
  66. package/src/index.js +8 -3
  67. package/styles/tokens/dark.css +4 -0
  68. package/styles/tokens/semantic.css +83 -8
  69. package/DESIGN-SYSTEM.md +0 -182
package/dist/zx.global.js CHANGED
@@ -91,9 +91,12 @@ var zx = (() => {
91
91
  TreeView: () => TreeView,
92
92
  ValueList: () => ValueList,
93
93
  __: () => __,
94
+ absoluteKanbanIndex: () => absoluteKanbanIndex,
94
95
  addCalendarDays: () => addCalendarDays,
96
+ addCalendarMonths: () => addCalendarMonths,
95
97
  addDays: () => addDays,
96
98
  addMonths: () => addMonths,
99
+ allowsKanbanTransition: () => allowsKanbanTransition,
97
100
  appIcon: () => appIcon,
98
101
  appendCompact: () => appendCompact,
99
102
  appendCompactChild: () => appendCompactChild,
@@ -112,6 +115,7 @@ var zx = (() => {
112
115
  calendarDayKey: () => calendarDayKey,
113
116
  calendarEventIntersects: () => calendarEventIntersects,
114
117
  calendarEventSpansDays: () => calendarEventSpansDays,
118
+ calendarPageDate: () => calendarPageDate,
115
119
  calendarRange: () => calendarRange,
116
120
  chartSummaryTable: () => chartSummaryTable,
117
121
  clamp: () => clamp,
@@ -125,6 +129,7 @@ var zx = (() => {
125
129
  configureIcons: () => configureIcons,
126
130
  copyButton: () => copyButton,
127
131
  copyToClipboard: () => copyToClipboard,
132
+ createKanbanHistory: () => createKanbanHistory,
128
133
  debounce: () => debounce,
129
134
  deepMerge: () => deepMerge,
130
135
  defaultDateRangePresets: () => defaultDateRangePresets,
@@ -134,6 +139,7 @@ var zx = (() => {
134
139
  emptyFilterAst: () => emptyFilterAst,
135
140
  emptyState: () => emptyState,
136
141
  escapeRegExp: () => escapeRegExp,
142
+ evaluateKanbanMove: () => evaluateKanbanMove,
137
143
  faFamilies: () => faFamilies,
138
144
  faIconClasses: () => faIconClasses,
139
145
  faNames: () => faNames,
@@ -168,15 +174,19 @@ var zx = (() => {
168
174
  isSameDay: () => isSameDay,
169
175
  isTruncated: () => isTruncated,
170
176
  joinTime: () => joinTime,
177
+ kanbanSearchTerms: () => kanbanSearchTerms,
171
178
  kitUrl: () => kitUrl,
172
179
  layoutCalendarSpans: () => layoutCalendarSpans,
173
180
  layoutTimedCalendarEvents: () => layoutTimedCalendarEvents,
174
181
  loadFontAwesome: () => loadFontAwesome,
175
182
  loadFontAwesomeKit: () => loadFontAwesomeKit,
183
+ locateKanbanRecord: () => locateKanbanRecord,
176
184
  matchBreakpoint: () => matchBreakpoint,
185
+ matchesKanbanSearch: () => matchesKanbanSearch,
177
186
  moveViewField: () => moveViewField,
178
187
  normalizeAppItems: () => normalizeAppItems,
179
188
  normalizeCalendarEvents: () => normalizeCalendarEvents,
189
+ normalizeKanbanRules: () => normalizeKanbanRules,
180
190
  normalizeRange: () => normalizeRange,
181
191
  normalizeViewFields: () => normalizeViewFields,
182
192
  normalizeViewState: () => normalizeViewState,
@@ -201,8 +211,10 @@ var zx = (() => {
201
211
  registerFieldAdapters: () => registerFieldAdapters,
202
212
  registerIcons: () => registerIcons,
203
213
  renderViewField: () => renderViewField,
214
+ reorderKanbanRecords: () => reorderKanbanRecords,
204
215
  reorderTableRows: () => reorderTableRows,
205
216
  resolveElement: () => resolveElement,
217
+ resolveKanbanRules: () => resolveKanbanRules,
206
218
  resolveSize: () => resolveSize,
207
219
  rovingTabindex: () => rovingTabindex,
208
220
  setLanguage: () => setLanguage,
@@ -1039,21 +1051,26 @@ var zx = (() => {
1039
1051
  * Dispatches a component event and a bubbling, composed `zx-*` DOM event.
1040
1052
  * @param {string} type Lowercase event name.
1041
1053
  * @param {Record<string, unknown>} [detail={}] Single event detail object.
1054
+ * @param {{honorDomCancellation?:boolean}} [options={}] Mirrored DOM-event behavior. Components
1055
+ * with a cancelable mutation boundary may opt into treating `preventDefault()` on the bubbling
1056
+ * `zx-*` event exactly like cancellation of the component event.
1042
1057
  * @returns {Event & {detail: Record<string, unknown>}}
1043
1058
  * @fires Component#event
1044
1059
  */
1045
- emit(type, detail = {}) {
1060
+ emit(type, detail = {}, options = {}) {
1046
1061
  if (detail === null || typeof detail !== "object" || Array.isArray(detail)) {
1047
1062
  throw new TypeError("Event detail must be an object");
1048
1063
  }
1049
1064
  const componentEvent = createCustomEvent(type, detail, { cancelable: true });
1050
1065
  this.dispatchEvent(componentEvent);
1051
1066
  if (this.el) {
1052
- this.el.dispatchEvent(createCustomEvent(`zx-${type}`, detail, {
1067
+ const domEvent = createCustomEvent(`zx-${type}`, detail, {
1053
1068
  bubbles: true,
1054
1069
  composed: true,
1055
1070
  cancelable: true
1056
- }));
1071
+ });
1072
+ this.el.dispatchEvent(domEvent);
1073
+ if (options.honorDomCancellation && domEvent.defaultPrevented) componentEvent.preventDefault();
1057
1074
  }
1058
1075
  return componentEvent;
1059
1076
  }
@@ -10527,6 +10544,15 @@ var zx = (() => {
10527
10544
  element.replaceChildren(...state.children);
10528
10545
  }
10529
10546
 
10547
+ // src/core/overlay-host.js
10548
+ var SCOPE_SELECTOR = ".zx-scope, [data-zx-theme], [data-zx-density], [data-zx-preset]";
10549
+ function overlayHost(source = null) {
10550
+ const context = source == null ? document.activeElement instanceof Element ? document.activeElement : null : resolveElement(source);
10551
+ const scope2 = context?.closest?.(SCOPE_SELECTOR) ?? null;
10552
+ if (!scope2 || scope2 === document.documentElement || scope2 === document.body) return document.body;
10553
+ return scope2;
10554
+ }
10555
+
10530
10556
  // src/components/modal/modal.js
10531
10557
  var Modal = class extends Component {
10532
10558
  static cssName = "modal";
@@ -10536,13 +10562,17 @@ var zx = (() => {
10536
10562
  width: "auto",
10537
10563
  closable: true,
10538
10564
  lightDismiss: false,
10539
- destroyOnClose: false
10565
+ destroyOnClose: false,
10566
+ scope: null
10540
10567
  };
10541
- #pendingResult;
10542
- #hasPendingResult = false;
10568
+ /** @type {Map<number, {result: unknown}>} */
10569
+ #pendingResults = /* @__PURE__ */ new Map();
10543
10570
  #destroying = false;
10571
+ #presentation = 0;
10572
+ /** @type {number[]} */
10573
+ #nativeClosingPresentations = [];
10544
10574
  /**
10545
- * Creates a modal appended to `document.body`.
10575
+ * Creates a modal in the configured theme scope, or at document level when none is present.
10546
10576
  * @param {Element|string|null} [_target=null] Ignored; Modal always owns its root dialog.
10547
10577
  * @param {ModalOptions} [options={}] Modal options.
10548
10578
  */
@@ -10555,14 +10585,7 @@ var zx = (() => {
10555
10585
  if (!this.options.closable || cancelEvent.defaultPrevented) event.preventDefault();
10556
10586
  });
10557
10587
  this.listen(this.el, "close", () => {
10558
- if (!this._isRealClose()) return;
10559
- this.el.dataset.state = "closed";
10560
- delete this.el.dataset.zxOverlayOrder;
10561
- const result = this.#hasPendingResult ? this.#pendingResult : this.el.returnValue || void 0;
10562
- this.#pendingResult = void 0;
10563
- this.#hasPendingResult = false;
10564
- this.emit("close", { result });
10565
- if (this.options.destroyOnClose && !this.#destroying) this.destroy();
10588
+ this._settleClose(this.#nativeClosingPresentations.shift());
10566
10589
  });
10567
10590
  this.listen(this.el, "click", (event) => {
10568
10591
  if (!this.options.lightDismiss || event.target !== this.el || !isBackdropClick(this.el, event)) return;
@@ -10578,7 +10601,7 @@ var zx = (() => {
10578
10601
  * @returns {Element}
10579
10602
  */
10580
10603
  mountTarget() {
10581
- return document.body;
10604
+ return overlayHost(this.options.scope);
10582
10605
  }
10583
10606
  /**
10584
10607
  * Puts the dialog on screen. Overridden by subclasses that open non-modally.
@@ -10587,17 +10610,78 @@ var zx = (() => {
10587
10610
  _show() {
10588
10611
  this.el.showModal();
10589
10612
  }
10613
+ /**
10614
+ * Removes the dialog from its current presentation layer.
10615
+ * Subclasses may replace native dialog presentation while preserving Modal lifecycle events.
10616
+ * @param {string} [result=''] Native string return value.
10617
+ * @param {number} [presentation=this.#presentation] Presentation generation being hidden.
10618
+ * @returns {void}
10619
+ */
10620
+ _hide(result = "", presentation = this.#presentation) {
10621
+ this.#nativeClosingPresentations.push(presentation);
10622
+ try {
10623
+ this.el.close(result);
10624
+ } catch (error2) {
10625
+ this.#nativeClosingPresentations.pop();
10626
+ throw error2;
10627
+ }
10628
+ }
10629
+ /**
10630
+ * Shows a new presentation and gives it a generation of its own. Close events are queued by the
10631
+ * platform, so the generation lets an event from an earlier presentation expire harmlessly if
10632
+ * the same overlay has already reopened.
10633
+ * @returns {void}
10634
+ */
10635
+ _present() {
10636
+ this._show();
10637
+ this.#presentation += 1;
10638
+ }
10639
+ /**
10640
+ * Hides the current presentation and records which generation its queued close event belongs to.
10641
+ * @param {string} [result=''] Native string return value.
10642
+ * @returns {void}
10643
+ */
10644
+ _dismiss(result = "") {
10645
+ this._hide(result, this.#presentation);
10646
+ }
10647
+ /**
10648
+ * Reports whether the current presentation layer is visible.
10649
+ * @returns {boolean}
10650
+ */
10651
+ _isShown() {
10652
+ return this.el.open;
10653
+ }
10590
10654
  /**
10591
10655
  * Whether an incoming `close` event is a real dismissal.
10592
10656
  *
10593
- * A dialog cannot move between the top layer and the page flow while it is open, so a subclass
10594
- * that re-hosts itself has to close and immediately reopen. `close` is dispatched in a queued
10595
- * task, not synchronously, so that stale event arrives once the dialog is already open again —
10596
- * late enough to clobber `data-state`, fire a `close` nobody asked for, and trip
10597
- * `destroyOnClose`. Returning false from an override drops it at the source instead.
10657
+ * `close` is dispatched in a queued task, not synchronously. By the time it arrives the overlay
10658
+ * may have reopened or crossed presentation layers, so only the event for the current hidden
10659
+ * generation may settle state, emit lifecycle events, or trigger `destroyOnClose`.
10660
+ * @param {number|undefined} closingPresentation Presentation that produced the event, if known.
10598
10661
  * @returns {boolean}
10599
10662
  */
10600
- _isRealClose() {
10663
+ _isRealClose(closingPresentation) {
10664
+ if (closingPresentation !== void 0 && closingPresentation !== this.#presentation) return false;
10665
+ return !this._isShown();
10666
+ }
10667
+ /**
10668
+ * Settles one presentation's close without allowing a queued event to invalidate a newer one.
10669
+ * Popover-based subclasses call this directly; native dialog events arrive through the listener.
10670
+ * @param {number|undefined} closingPresentation Presentation that was hidden, if known.
10671
+ * @returns {boolean} Whether this event closed the current presentation.
10672
+ */
10673
+ _settleClose(closingPresentation) {
10674
+ if (!this._isRealClose(closingPresentation)) {
10675
+ if (closingPresentation !== void 0) this.#pendingResults.delete(closingPresentation);
10676
+ return false;
10677
+ }
10678
+ this.el.dataset.state = "closed";
10679
+ delete this.el.dataset.zxOverlayOrder;
10680
+ const pending = closingPresentation === void 0 ? void 0 : this.#pendingResults.get(closingPresentation);
10681
+ if (closingPresentation !== void 0) this.#pendingResults.delete(closingPresentation);
10682
+ const result = pending ? pending.result : this.el.returnValue || void 0;
10683
+ this.emit("close", { result });
10684
+ if (this.options.destroyOnClose && !this.#destroying) this.destroy();
10601
10685
  return true;
10602
10686
  }
10603
10687
  /**
@@ -10622,10 +10706,12 @@ var zx = (() => {
10622
10706
  */
10623
10707
  open() {
10624
10708
  if (this.isOpen()) return this;
10709
+ const host = this.mountTarget();
10710
+ if (this.el.parentElement !== host) host.append(this.el);
10625
10711
  this.el.dataset.zxOverlayOrder = String(nextOverlayOrder());
10626
10712
  this.el.returnValue = "";
10627
10713
  try {
10628
- this._show();
10714
+ this._present();
10629
10715
  } catch (error2) {
10630
10716
  delete this.el.dataset.zxOverlayOrder;
10631
10717
  throw error2;
@@ -10641,9 +10727,14 @@ var zx = (() => {
10641
10727
  */
10642
10728
  close(result) {
10643
10729
  if (!this.isOpen()) return this;
10644
- this.#pendingResult = result;
10645
- this.#hasPendingResult = true;
10646
- this.el.close(typeof result === "string" ? result : "");
10730
+ const presentation = this.#presentation;
10731
+ this.#pendingResults.set(presentation, { result });
10732
+ try {
10733
+ this._dismiss(typeof result === "string" ? result : "");
10734
+ } catch (error2) {
10735
+ this.#pendingResults.delete(presentation);
10736
+ throw error2;
10737
+ }
10647
10738
  return this;
10648
10739
  }
10649
10740
  /**
@@ -10660,7 +10751,7 @@ var zx = (() => {
10660
10751
  * @returns {boolean}
10661
10752
  */
10662
10753
  isOpen() {
10663
- return this.el.open;
10754
+ return this._isShown();
10664
10755
  }
10665
10756
  /**
10666
10757
  * Closes and removes the owned dialog. Safe to call repeatedly.
@@ -10669,7 +10760,7 @@ var zx = (() => {
10669
10760
  destroy() {
10670
10761
  if (this.#destroying) return;
10671
10762
  this.#destroying = true;
10672
- if (this.isOpen()) this.el.close();
10763
+ if (this.isOpen()) this._dismiss();
10673
10764
  super.destroy();
10674
10765
  }
10675
10766
  /** @param {string|number} width @returns {void} */
@@ -10734,7 +10825,7 @@ var zx = (() => {
10734
10825
  /** @type {Element|null} */
10735
10826
  #opener = null;
10736
10827
  /**
10737
- * Creates a structured dialog appended to `document.body`.
10828
+ * Creates a structured dialog in the configured theme scope, or at document level when unscoped.
10738
10829
  * @param {Element|string|null} [_target=null] Ignored; Dialog always owns its root.
10739
10830
  * @param {DialogOptions} [options={}] Dialog options.
10740
10831
  */
@@ -15631,7 +15722,8 @@ var zx = (() => {
15631
15722
  offset: 4,
15632
15723
  matchWidth: false,
15633
15724
  openOn: "click",
15634
- closeOnSelect: false
15725
+ closeOnSelect: false,
15726
+ scope: null
15635
15727
  };
15636
15728
  /** @type {Element} */
15637
15729
  #anchor;
@@ -15650,6 +15742,7 @@ var zx = (() => {
15650
15742
  if (!resolved) throw new TypeError("Dropdown anchor could not be resolved");
15651
15743
  super(null, options);
15652
15744
  this.#anchor = resolved;
15745
+ overlayHost(this.options.scope ?? resolved).append(this.el);
15653
15746
  this.#rememberAnchorAttribute("aria-expanded");
15654
15747
  this.#rememberAnchorAttribute("aria-controls");
15655
15748
  this.#anchor.setAttribute("aria-expanded", "false");
@@ -15672,7 +15765,6 @@ var zx = (() => {
15672
15765
  popover: "manual"
15673
15766
  });
15674
15767
  panel.dataset.state = "closed";
15675
- document.body.append(panel);
15676
15768
  return panel;
15677
15769
  }
15678
15770
  /**
@@ -15682,6 +15774,8 @@ var zx = (() => {
15682
15774
  */
15683
15775
  open() {
15684
15776
  if (this.isOpen() || this.#destroyed) return this;
15777
+ const host = overlayHost(this.options.scope ?? this.#anchor);
15778
+ if (this.el.parentElement !== host) host.append(this.el);
15685
15779
  this.#position = position(this.#anchor, this.el, {
15686
15780
  placement: this.options.placement,
15687
15781
  offset: Number(this.options.offset),
@@ -20341,7 +20435,8 @@ var zx = (() => {
20341
20435
  emptyText: "No results",
20342
20436
  loadingText: "Searching\u2026",
20343
20437
  shortcut: "mod+k",
20344
- hints: { move: "Move", open: "Open", close: "Close" }
20438
+ hints: { move: "Move", open: "Open", close: "Close" },
20439
+ scope: null
20345
20440
  };
20346
20441
  /**
20347
20442
  * Creates an owned launcher dialog or enhances an existing `<dialog>` target.
@@ -20432,7 +20527,7 @@ var zx = (() => {
20432
20527
  )
20433
20528
  );
20434
20529
  dialog.dataset.state = "closed";
20435
- if (this._createdRoot) document.body.append(dialog);
20530
+ if (this._createdRoot) overlayHost(this.options.scope).append(dialog);
20436
20531
  this.listen(input, "input", () => {
20437
20532
  this._query = /** @type {HTMLInputElement} */
20438
20533
  input.value;
@@ -20483,6 +20578,10 @@ var zx = (() => {
20483
20578
  /** Opens the launcher and focuses/selects its query. @returns {this} @fires Launcher#open */
20484
20579
  open() {
20485
20580
  if (this.isOpen() || this._destroyed) return this;
20581
+ if (this._createdRoot) {
20582
+ const host = overlayHost(this.options.scope);
20583
+ if (this.el.parentElement !== host) host.append(this.el);
20584
+ }
20486
20585
  this.el.showModal();
20487
20586
  this.el.dataset.state = "open";
20488
20587
  this.refs.input.setAttribute("aria-expanded", "true");
@@ -21361,6 +21460,7 @@ var zx = (() => {
21361
21460
  if (!resolved) throw new TypeError("Tooltip anchor could not be resolved");
21362
21461
  super(null, options);
21363
21462
  this.#anchor = resolved;
21463
+ overlayHost(resolved).append(this.el);
21364
21464
  this.#content = this.options.content;
21365
21465
  this.#disabled = Boolean(this.options.disabled);
21366
21466
  this.#describedBy = resolved.getAttribute("aria-describedby");
@@ -21421,7 +21521,6 @@ var zx = (() => {
21421
21521
  popover: "manual"
21422
21522
  });
21423
21523
  panel.dataset.state = "closed";
21424
- document.body.append(panel);
21425
21524
  return panel;
21426
21525
  }
21427
21526
  /**
@@ -21433,6 +21532,8 @@ var zx = (() => {
21433
21532
  show() {
21434
21533
  if (this.#destroyed || this.#disabled || this.isOpen()) return this;
21435
21534
  this.#cancelTimers();
21535
+ const host = overlayHost(this.#anchor);
21536
+ if (this.el.parentElement !== host) host.append(this.el);
21436
21537
  const node = resolveContent(this.#content);
21437
21538
  if (node === null) return this;
21438
21539
  this.el.replaceChildren(node);
@@ -22879,8 +22980,8 @@ var zx = (() => {
22879
22980
  closable: true,
22880
22981
  maxVisible: 5
22881
22982
  };
22882
- /** @type {Message|null} */
22883
- static #floating = null;
22983
+ /** @type {WeakMap<Element, Message>} */
22984
+ static #floating = /* @__PURE__ */ new WeakMap();
22884
22985
  /** @returns {HTMLElement} */
22885
22986
  render() {
22886
22987
  const created = !this.el;
@@ -22959,15 +23060,16 @@ var zx = (() => {
22959
23060
  * @returns {MessageHandle}
22960
23061
  */
22961
23062
  static show(msg, options = {}) {
22962
- return this.#floatingRegion().show(msg, options);
23063
+ return this.#floatingRegion(options.scope).show(msg, options);
22963
23064
  }
22964
23065
  /**
22965
23066
  * Shows a floating progress status.
22966
23067
  * @param {string} text Initial status text.
23068
+ * @param {{scope?: Element|string|null}} [options={}] Floating-region scope.
22967
23069
  * @returns {ProgressHandle}
22968
23070
  */
22969
- static progress(text4) {
22970
- return this.#floatingRegion()._progress(text4);
23071
+ static progress(text4, options = {}) {
23072
+ return this.#floatingRegion(options.scope)._progress(text4);
22971
23073
  }
22972
23074
  /** @returns {void} */
22973
23075
  destroy() {
@@ -23145,19 +23247,22 @@ var zx = (() => {
23145
23247
  this._entries.delete(entry);
23146
23248
  this._queue.remove(entry);
23147
23249
  }
23148
- /** @returns {Message} */
23149
- static #floatingRegion() {
23150
- if (this.#floating) return this.#floating;
23250
+ /** @param {Element|string|null|undefined} scope @returns {Message} */
23251
+ static #floatingRegion(scope2) {
23252
+ const host = overlayHost(scope2 ?? null);
23253
+ const existing = this.#floating.get(host);
23254
+ if (existing) return existing;
23151
23255
  const region = h("div", {
23152
23256
  class: "zx-message-region",
23153
23257
  popover: "manual",
23154
23258
  role: "status",
23155
23259
  ariaLive: "polite"
23156
23260
  });
23157
- document.body.append(region);
23158
- this.#floating = new _Message(region);
23261
+ host.append(region);
23262
+ const floating = new _Message(region);
23263
+ this.#floating.set(host, floating);
23159
23264
  if (typeof region.showPopover === "function") region.showPopover();
23160
- return this.#floating;
23265
+ return floating;
23161
23266
  }
23162
23267
  };
23163
23268
  function normalizeKind3(value) {
@@ -23245,7 +23350,6 @@ var zx = (() => {
23245
23350
  constructor(_target = null, options = {}) {
23246
23351
  super(null, options);
23247
23352
  this._dock = null;
23248
- this._handoff = false;
23249
23353
  this.setSide(this.options.side);
23250
23354
  this.el.dataset.backdrop = BACKDROPS.has(this.options.backdrop) ? this.options.backdrop : "dim";
23251
23355
  this.el.dataset.modality = modality(this.options.modal);
@@ -23420,7 +23524,7 @@ var zx = (() => {
23420
23524
  * @returns {Element}
23421
23525
  */
23422
23526
  mountTarget() {
23423
- return this._dock?.el ?? document.body;
23527
+ return this._dock?.el ?? super.mountTarget();
23424
23528
  }
23425
23529
  /**
23426
23530
  * Reports whether a dock currently owns this sheet's positioning.
@@ -23446,35 +23550,23 @@ var zx = (() => {
23446
23550
  /**
23447
23551
  * Moves the dialog to its current mount and, when it was open, reopens it there.
23448
23552
  *
23449
- * The close is unavoidable — a dialog cannot cross between the top layer and the flow while
23450
- * open — but it is invisible: `_isRealClose()` drops the stale event, and reopening bypasses
23451
- * `open()` so no lifecycle event is emitted for what is only a change of address.
23553
+ * The close is unavoidable — a dialog cannot cross between presentation layers while open —
23554
+ * but it is invisible: Modal's presentation generation drops the stale event, and reopening
23555
+ * bypasses `open()` so no lifecycle event is emitted for what is only a change of address.
23452
23556
  * @returns {void}
23453
23557
  */
23454
23558
  _rehost() {
23455
23559
  const wasOpen = this.isOpen();
23456
- if (wasOpen) {
23457
- this._handoff = true;
23458
- this.el.close();
23459
- }
23560
+ if (wasOpen) this._dismiss();
23460
23561
  this.mountTarget().append(this.el);
23461
23562
  if (!wasOpen) return;
23462
23563
  this.el.returnValue = "";
23463
- this._show();
23564
+ this._present();
23464
23565
  this.el.dataset.state = "open";
23465
23566
  if (this._dock) delete /** @type {HTMLElement} */
23466
23567
  this.el.dataset.zxOverlayOrder;
23467
23568
  else this.el.dataset.zxOverlayOrder = String(nextOverlayOrder3());
23468
23569
  }
23469
- /**
23470
- * Drops the stale `close` a re-hosting handoff leaves behind.
23471
- * @returns {boolean}
23472
- */
23473
- _isRealClose() {
23474
- if (!this._handoff) return true;
23475
- this._handoff = false;
23476
- return false;
23477
- }
23478
23570
  /**
23479
23571
  * Opens the sheet, trapping focus first where the modality asks for it.
23480
23572
  * @returns {this}
@@ -23555,10 +23647,38 @@ var zx = (() => {
23555
23647
  */
23556
23648
  _show() {
23557
23649
  if (this.options.modal === true && !this._dock) {
23650
+ this.el.removeAttribute("popover");
23651
+ this.el.removeAttribute("aria-modal");
23558
23652
  super._show();
23559
23653
  return;
23560
23654
  }
23561
- this.el.show();
23655
+ this.el.setAttribute("aria-modal", "false");
23656
+ if (this._dock) {
23657
+ this.el.removeAttribute("popover");
23658
+ this.el.show();
23659
+ return;
23660
+ }
23661
+ this.el.setAttribute("popover", "manual");
23662
+ this.el.showPopover();
23663
+ }
23664
+ /**
23665
+ * Hides a native dialog or a non-modal Sheet promoted through the popover top layer.
23666
+ * @param {string} [result=''] Native string return value.
23667
+ * @param {number} [presentation] Presentation generation being hidden.
23668
+ * @returns {void}
23669
+ */
23670
+ _hide(result = "", presentation) {
23671
+ if (!this.el.matches(":popover-open")) {
23672
+ super._hide(result, presentation);
23673
+ return;
23674
+ }
23675
+ this.el.returnValue = result;
23676
+ this.el.hidePopover();
23677
+ queueMicrotask(() => this._settleClose(presentation));
23678
+ }
23679
+ /** @returns {boolean} */
23680
+ _isShown() {
23681
+ return this.el.matches(":popover-open") || super._isShown();
23562
23682
  }
23563
23683
  /**
23564
23684
  * Releases the focus trap and the owned dialog.
@@ -23883,7 +24003,11 @@ var zx = (() => {
23883
24003
  super(null, options);
23884
24004
  this.#target = resolved;
23885
24005
  document.body.append(this.el);
23886
- this.#dropdown = new Dropdown(this.el, { openOn: "manual", placement: "bottom-start" });
24006
+ this.#dropdown = new Dropdown(this.el, {
24007
+ openOn: "manual",
24008
+ placement: "bottom-start",
24009
+ scope: resolved
24010
+ });
23887
24011
  this.#panel = this.#dropdown.getPanel();
23888
24012
  this.#panel.classList.add("zx-context-menu");
23889
24013
  this.#panel.setAttribute("role", "menu");
@@ -24143,6 +24267,29 @@ var zx = (() => {
24143
24267
  }
24144
24268
  return { start, end };
24145
24269
  }
24270
+ function calendarPageDate(view, anchor, direction, options = {}) {
24271
+ const date = validDate5(anchor, "Calendar date");
24272
+ const step = integer(direction, "direction");
24273
+ if (view === "day") date.setDate(date.getDate() + step);
24274
+ else if (view === "agenda") {
24275
+ date.setDate(date.getDate() + step * positiveInteger(options.agendaDays ?? 14, "agendaDays"));
24276
+ } else if (view === "week") date.setDate(date.getDate() + step * 7);
24277
+ else if (view === "month") return addCalendarMonths(date, step);
24278
+ else if (view === "year") return addCalendarMonths(date, step * 12);
24279
+ else throw new RangeError(`Unknown calendar view: ${view}`);
24280
+ return date;
24281
+ }
24282
+ function addCalendarMonths(value, months) {
24283
+ const date = validDate5(value, "Calendar date");
24284
+ const day = date.getDate();
24285
+ date.setDate(1);
24286
+ date.setMonth(date.getMonth() + integer(months, "month amount"));
24287
+ date.setDate(Math.min(day, daysInCalendarMonth(date.getFullYear(), date.getMonth())));
24288
+ return date;
24289
+ }
24290
+ function daysInCalendarMonth(year, month) {
24291
+ return new Date(year, month + 1, 0).getDate();
24292
+ }
24146
24293
  function normalizeCalendarEvents(records, options = {}) {
24147
24294
  if (!Array.isArray(records)) throw new TypeError("Calendar events must be an array");
24148
24295
  const readers = {
@@ -24586,7 +24733,7 @@ var zx = (() => {
24586
24733
  durationEditable: Object.prototype.hasOwnProperty.call(changes, "durationEditable") ? changes.durationEditable : current.durationEditable,
24587
24734
  data: changes.data ?? current.data
24588
24735
  };
24589
- const [next] = normalizeCalendarEvents([source]);
24736
+ const [next] = normalizeCalendarEvents([source], { dateUnit: this.options.dateUnit });
24590
24737
  next.data = source.data;
24591
24738
  this._events = this._events.map((event, eventIndex) => eventIndex === index ? next : event);
24592
24739
  this._mutationVersions.delete(String(id));
@@ -24737,7 +24884,11 @@ var zx = (() => {
24737
24884
  }).sort(compareEvents);
24738
24885
  const row2 = h(
24739
24886
  "section",
24740
- { class: "zx-calendar__agenda-day", role: "row" },
24887
+ {
24888
+ class: "zx-calendar__agenda-day",
24889
+ role: "row",
24890
+ dataset: { date: calendarDayKey(day) }
24891
+ },
24741
24892
  h("button", {
24742
24893
  type: "button",
24743
24894
  class: "zx-calendar__agenda-heading",
@@ -24747,11 +24898,13 @@ var zx = (() => {
24747
24898
  disabled: this.options.disabled
24748
24899
  }, this._format(day, { weekday: "long", day: "numeric", month: "long" }))
24749
24900
  );
24901
+ const entries = h("div", { class: "zx-calendar__agenda-events", role: "gridcell" });
24750
24902
  if (!events.length) {
24751
- row2.append(h("p", { class: "zx-calendar__agenda-empty", role: "gridcell" }, this.msg("noEvents")));
24903
+ entries.append(h("p", { class: "zx-calendar__agenda-empty" }, this.msg("noEvents")));
24752
24904
  } else {
24753
- for (const event of events) row2.append(this._eventElement(event, { variant: "agenda", day }));
24905
+ for (const event of events) entries.append(this._eventElement(event, { variant: "agenda", day }));
24754
24906
  }
24907
+ row2.append(entries);
24755
24908
  grid2.append(row2);
24756
24909
  }
24757
24910
  return grid2;
@@ -25021,6 +25174,7 @@ var zx = (() => {
25021
25174
  dataset: {
25022
25175
  eventId: key,
25023
25176
  variant: context.variant,
25177
+ allDay: event.allDay ? "true" : null,
25024
25178
  draggable: this._canMove(event) ? "true" : null
25025
25179
  },
25026
25180
  style: event.color ? { "--zx-calendar-event-color": event.color } : null
@@ -25287,7 +25441,9 @@ var zx = (() => {
25287
25441
  const item = this._eventById(control.dataset.eventId);
25288
25442
  const action = control.dataset.eventAction === "resize" ? "resize" : "move";
25289
25443
  if (!item || (action === "move" ? !this._canMove(item) : !this._canResize(item))) return;
25290
- control.setPointerCapture?.(event.pointerId);
25444
+ event.preventDefault();
25445
+ this._capturePointer(event.pointerId);
25446
+ const offset = action === "move" ? this._dragOffsetForEvent(item, event.clientX, event.clientY) : null;
25291
25447
  this._pointer = {
25292
25448
  type: "event",
25293
25449
  pointerId: event.pointerId,
@@ -25297,6 +25453,8 @@ var zx = (() => {
25297
25453
  event: cloneCalendarEvent(item),
25298
25454
  action,
25299
25455
  control,
25456
+ timeOffsetMinutes: offset?.minutes ?? 0,
25457
+ dayOffset: offset?.days ?? 0,
25300
25458
  moved: false
25301
25459
  };
25302
25460
  return;
@@ -25305,7 +25463,8 @@ var zx = (() => {
25305
25463
  if (!slot || !this.options.selectable) return;
25306
25464
  const date = this._dateFromTarget(slot);
25307
25465
  if (!date) return;
25308
- slot.setPointerCapture?.(event.pointerId);
25466
+ event.preventDefault();
25467
+ this._capturePointer(event.pointerId);
25309
25468
  this._pointer = {
25310
25469
  type: "selection",
25311
25470
  pointerId: event.pointerId,
@@ -25338,7 +25497,13 @@ var zx = (() => {
25338
25497
  const wrapper = pointer.control.closest(".zx-calendar__event");
25339
25498
  if (wrapper) {
25340
25499
  wrapper.dataset.state = "dragging";
25341
- wrapper.style.transform = `translate(${event.clientX - pointer.startX}px, ${event.clientY - pointer.startY}px)`;
25500
+ if (pointer.action === "resize") {
25501
+ wrapper.style.transform = "";
25502
+ const height = this._timedPreviewHeight(proposed);
25503
+ if (height) wrapper.style.blockSize = height;
25504
+ } else {
25505
+ wrapper.style.transform = `translate(${event.clientX - pointer.startX}px, ${event.clientY - pointer.startY}px)`;
25506
+ }
25342
25507
  wrapper.dataset.preview = this._eventTime(proposed, proposed.start);
25343
25508
  }
25344
25509
  this._announce(this.msg(
@@ -25353,6 +25518,7 @@ var zx = (() => {
25353
25518
  const pointer = this._pointer;
25354
25519
  if (!pointer || pointer.pointerId !== event.pointerId) return;
25355
25520
  this._pointer = null;
25521
+ this._releasePointerCapture(pointer.pointerId);
25356
25522
  this._clearSelectionPreview();
25357
25523
  if (!pointer.moved) return;
25358
25524
  event.preventDefault();
@@ -25370,6 +25536,7 @@ var zx = (() => {
25370
25536
  _cancelPointer(event) {
25371
25537
  if (!this._pointer || this._pointer.pointerId !== event.pointerId) return;
25372
25538
  const control = this._pointer.control;
25539
+ this._releasePointerCapture(this._pointer.pointerId);
25373
25540
  this._pointer = null;
25374
25541
  this._clearSelectionPreview();
25375
25542
  control.closest?.(".zx-calendar__event")?.removeAttribute("data-state");
@@ -25377,13 +25544,14 @@ var zx = (() => {
25377
25544
  }
25378
25545
  /** @param {CalendarEvent} event @param {'move'|'resize'} action @param {number} x @param {number} y @returns {CalendarEvent|null} */
25379
25546
  _proposalAtPoint(event, action, x, y) {
25380
- const timed = this._timeAtPoint(x, y);
25547
+ const resizeTimed = action === "resize" && !calendarEventSpansDays(event);
25548
+ const timed = this._timeAtPoint(x, y, resizeTimed ? event.start : null);
25381
25549
  if (timed && !calendarEventSpansDays(event)) {
25382
25550
  if (action === "move") {
25383
25551
  const duration = event.end.getTime() - event.start.getTime();
25384
25552
  const next3 = cloneCalendarEvent(event);
25385
- next3.start = timed;
25386
- next3.end = new Date(timed.getTime() + duration);
25553
+ next3.start = addMinutes(timed, -Number(this._pointer?.timeOffsetMinutes ?? 0));
25554
+ next3.end = new Date(next3.start.getTime() + duration);
25387
25555
  return next3;
25388
25556
  }
25389
25557
  const next2 = cloneCalendarEvent(event);
@@ -25392,33 +25560,36 @@ var zx = (() => {
25392
25560
  }
25393
25561
  const day = this._dayAtPoint(x, y);
25394
25562
  if (!day) return null;
25395
- const difference = calendarDayDifference(startOfCalendarDay(event.start), day);
25563
+ const targetDay = addCalendarDays(day, -Number(this._pointer?.dayOffset ?? 0));
25564
+ const difference = calendarDayDifference(startOfCalendarDay(event.start), targetDay);
25396
25565
  if (action === "move") return shiftCalendarEvent(event, { days: difference, minutes: 0 });
25397
25566
  const next = cloneCalendarEvent(event);
25398
25567
  const endDay = addCalendarDays(day, 1);
25399
25568
  next.end = new Date(endDay.getTime());
25400
25569
  return next.end > next.start ? next : null;
25401
25570
  }
25402
- /** @param {number} x @param {number} y @returns {Date|null} */
25403
- _timeAtPoint(x, y) {
25571
+ /** @param {number} x @param {number} y @param {Date|null} [fixedDay=null] @returns {Date|null} */
25572
+ _timeAtPoint(x, y, fixedDay = null) {
25404
25573
  const slots = this.refs.body.querySelector(".zx-calendar__slots");
25405
25574
  if (!slots) return null;
25406
25575
  const rect = slots.getBoundingClientRect();
25407
- if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom || rect.width <= 0 || rect.height <= 0) return null;
25576
+ const scroller = slots.closest(".zx-calendar__time-scroller");
25577
+ const viewport = scroller?.getBoundingClientRect();
25578
+ const left = viewport ? Math.max(rect.left, viewport.left) : rect.left;
25579
+ const right = viewport ? Math.min(rect.right, viewport.right) : rect.right;
25580
+ const top = viewport ? Math.max(rect.top, viewport.top) : rect.top;
25581
+ const bottom = viewport ? Math.min(rect.bottom, viewport.bottom) : rect.bottom;
25582
+ if (x < left || x > right || y < top || y > bottom || rect.width <= 0 || rect.height <= 0) return null;
25408
25583
  const columns = Number(slots.closest('[role="grid"]')?.getAttribute("aria-colcount")) || 1;
25409
25584
  const rows = Number(slots.closest('[role="grid"]')?.getAttribute("aria-rowcount")) || 1;
25410
- const column = Math.max(0, Math.min(columns - 1, Math.floor((x - rect.left) / rect.width * columns)));
25585
+ const fixedColumn = fixedDay ? this._timeGridDayIndex(fixedDay) : -1;
25586
+ const column = fixedColumn >= 0 ? fixedColumn : Math.max(0, Math.min(columns - 1, Math.floor((x - rect.left) / rect.width * columns)));
25411
25587
  const row2 = Math.max(0, Math.min(rows - 1, Math.floor((y - rect.top) / rect.height * rows)));
25412
25588
  const target = slots.querySelector(`[data-row-index="${row2}"][data-day-index="${column}"]`);
25413
25589
  return target ? this._dateFromTarget(target) : null;
25414
25590
  }
25415
25591
  /** @param {number} x @param {number} y @returns {Date|null} */
25416
25592
  _dayAtPoint(x, y) {
25417
- const elements = typeof document.elementsFromPoint === "function" ? document.elementsFromPoint(x, y) : [];
25418
- for (const element of elements) {
25419
- const date = element instanceof HTMLElement ? element.dataset.date : null;
25420
- if (date) return parseDayKey(date);
25421
- }
25422
25593
  const allDay = this.refs.body.querySelector(".zx-calendar__all-day-grid");
25423
25594
  if (allDay) {
25424
25595
  const rect = allDay.getBoundingClientRect();
@@ -25428,8 +25599,72 @@ var zx = (() => {
25428
25599
  return parseDayKey(cells[index]?.dataset.date);
25429
25600
  }
25430
25601
  }
25602
+ for (const monthEvents of this.refs.body.querySelectorAll(".zx-calendar__month-events")) {
25603
+ const rect = monthEvents.getBoundingClientRect();
25604
+ if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom || rect.width <= 0) continue;
25605
+ const start = parseDayKey(monthEvents.closest(".zx-calendar__month-week")?.dataset.weekStart);
25606
+ if (!start) continue;
25607
+ const index = Math.max(0, Math.min(6, Math.floor((x - rect.left) / rect.width * 7)));
25608
+ return addCalendarDays(start, index);
25609
+ }
25610
+ for (const agendaDay of this.refs.body.querySelectorAll(".zx-calendar__agenda-day")) {
25611
+ const rect = agendaDay.getBoundingClientRect();
25612
+ if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) continue;
25613
+ return parseDayKey(agendaDay.dataset.date);
25614
+ }
25615
+ const elements = typeof document.elementsFromPoint === "function" ? document.elementsFromPoint(x, y) : [];
25616
+ for (const element of elements) {
25617
+ const dateTarget = element instanceof HTMLElement ? element.closest("[data-date]") : null;
25618
+ const date = dateTarget instanceof HTMLElement ? dateTarget.dataset.date : null;
25619
+ if (date) return parseDayKey(date);
25620
+ }
25431
25621
  return null;
25432
25622
  }
25623
+ /** @param {CalendarEvent} event @param {number} x @param {number} y @returns {{minutes: number, days: number}} */
25624
+ _dragOffsetForEvent(event, x, y) {
25625
+ if (!calendarEventSpansDays(event)) {
25626
+ const timed = this._timeAtPoint(x, y);
25627
+ if (timed) {
25628
+ const slot = normalizeSlot(this.options.slotDuration);
25629
+ const duration = Math.max(0, Math.round((event.end.getTime() - event.start.getTime()) / 6e4));
25630
+ const maxOffset = Math.max(0, duration - slot);
25631
+ const minutes = Math.round((timed.getTime() - event.start.getTime()) / 6e4);
25632
+ return { minutes: Math.max(0, Math.min(maxOffset, minutes)), days: 0 };
25633
+ }
25634
+ }
25635
+ const day = this._dayAtPoint(x, y);
25636
+ const days = day ? calendarDayDifference(startOfCalendarDay(event.start), day) : 0;
25637
+ return { minutes: 0, days: Math.max(0, days) };
25638
+ }
25639
+ /** @param {Date} day @returns {number} */
25640
+ _timeGridDayIndex(day) {
25641
+ const key = calendarDayKey(day);
25642
+ const headings = [...this.refs.body.querySelectorAll(".zx-calendar__day-heading")];
25643
+ return headings.findIndex((heading) => heading.dataset.date === key);
25644
+ }
25645
+ /** @param {CalendarEvent} event @returns {string|null} */
25646
+ _timedPreviewHeight(event) {
25647
+ const grid2 = this.refs.body.querySelector(".zx-calendar__time-grid");
25648
+ if (!grid2 || calendarEventSpansDays(event)) return null;
25649
+ const min = normalizeMinute(this.options.slotMinTime, 0);
25650
+ const max = normalizeMinute(this.options.slotMaxTime, 1440, true);
25651
+ const slot = normalizeSlot(this.options.slotDuration);
25652
+ const start = event.start.getHours() * 60 + event.start.getMinutes();
25653
+ const end = event.end.getHours() * 60 + event.end.getMinutes();
25654
+ const minutes = Math.max(slot, Math.min(max, end) - Math.max(min, start));
25655
+ return `${minutes / (max - min) * 100}%`;
25656
+ }
25657
+ /** @param {number} pointerId @returns {void} */
25658
+ _capturePointer(pointerId) {
25659
+ try {
25660
+ this.el.setPointerCapture?.(pointerId);
25661
+ } catch {
25662
+ }
25663
+ }
25664
+ /** @param {number} pointerId @returns {void} */
25665
+ _releasePointerCapture(pointerId) {
25666
+ if (this.el.hasPointerCapture?.(pointerId)) this.el.releasePointerCapture?.(pointerId);
25667
+ }
25433
25668
  /** @param {Date} start @param {Date} end @returns {void} */
25434
25669
  _previewSelection(start, end) {
25435
25670
  const low = Math.min(start.getTime(), end.getTime());
@@ -25524,13 +25759,9 @@ var zx = (() => {
25524
25759
  }
25525
25760
  /** @param {number} direction @returns {this} */
25526
25761
  _page(direction) {
25527
- const date = new Date(this._date.getTime());
25528
- if (this._view === "day") date.setDate(date.getDate() + direction);
25529
- else if (this._view === "agenda") date.setDate(date.getDate() + direction * this.options.agendaDays);
25530
- else if (this._view === "week") date.setDate(date.getDate() + direction * 7);
25531
- else if (this._view === "month") date.setMonth(date.getMonth() + direction);
25532
- else date.setFullYear(date.getFullYear() + direction);
25533
- return this.setDate(date);
25762
+ return this.setDate(calendarPageDate(this._view, this._date, direction, {
25763
+ agendaDays: this.options.agendaDays
25764
+ }));
25534
25765
  }
25535
25766
  /** @param {{start: Date, end: Date}} range @returns {string} */
25536
25767
  _rangeTitle(range) {
@@ -26687,6 +26918,16 @@ var zx = (() => {
26687
26918
  class: "zx-record-card__heading",
26688
26919
  id: headingId
26689
26920
  }, headingContent);
26921
+ const fieldGroups = partitionRecordCardFields(visibleFields, titleSource, subtitleSource);
26922
+ const eyebrow = createEyebrow(record, index, fieldGroups.eyebrowStart, fieldGroups.eyebrowEnd);
26923
+ const titlePrefix = createFieldList(
26924
+ record,
26925
+ index,
26926
+ fieldGroups.titlePrefix,
26927
+ "zx-record-card__title-prefix",
26928
+ "title-prefix"
26929
+ );
26930
+ const titleRow = titlePrefix ? h("div", { class: "zx-record-card__title-row" }, titlePrefix, heading) : heading;
26690
26931
  const selection = selectable === "multi" ? h("label", {
26691
26932
  class: "zx-record-card__selection"
26692
26933
  }, h("input", {
@@ -26708,19 +26949,19 @@ var zx = (() => {
26708
26949
  h(
26709
26950
  "div",
26710
26951
  { class: "zx-record-card__titles" },
26711
- heading,
26952
+ titleRow,
26712
26953
  isEmptyContent(subtitle) ? null : h("p", { class: "zx-record-card__subtitle" }, contentNode(subtitle))
26713
26954
  ),
26714
26955
  actionArea
26715
26956
  );
26716
- const metadata = createMetadata(record, index, visibleFields, titleSource, subtitleSource);
26717
- const body = h("div", { class: "zx-record-card__body" }, header, metadata);
26957
+ const metadata = createFieldList(record, index, fieldGroups.metadata, "zx-record-card__metadata");
26958
+ const body = h("div", { class: "zx-record-card__body" }, eyebrow, header, metadata);
26718
26959
  const card = (
26719
26960
  /** @type {HTMLLIElement} */
26720
26961
  h("li", {
26721
26962
  class: "zx-record-card",
26722
26963
  tabindex: 0,
26723
- ariaSelected: selectable ? String(selected) : void 0,
26964
+ ariaDescription: selectable ? selected ? "Selected" : "Not selected" : void 0,
26724
26965
  ariaLabelledby: headingId,
26725
26966
  dataset: {
26726
26967
  recordIndex: String(index),
@@ -26731,6 +26972,20 @@ var zx = (() => {
26731
26972
  );
26732
26973
  return card;
26733
26974
  }
26975
+ function partitionRecordCardFields(fields, titleSource = null, subtitleSource = null) {
26976
+ const groups = { eyebrowStart: [], eyebrowEnd: [], titlePrefix: [], metadata: [] };
26977
+ const titleId = typeof titleSource === "string" ? titleSource : null;
26978
+ const subtitleId = typeof subtitleSource === "string" ? subtitleSource : null;
26979
+ for (const field of Array.isArray(fields) ? fields : []) {
26980
+ if (!field.duplicate && (field.id === titleId || field.id === subtitleId)) continue;
26981
+ const slot = resolveRecordCardFieldSlot(field);
26982
+ if (slot === "eyebrow-start") groups.eyebrowStart.push(field);
26983
+ else if (slot === "eyebrow-end") groups.eyebrowEnd.push(field);
26984
+ else if (slot === "title-prefix") groups.titlePrefix.push(field);
26985
+ else groups.metadata.push(field);
26986
+ }
26987
+ return groups;
26988
+ }
26734
26989
  function resolveRecordPreview(record, index, preview, previewAlt, fields = []) {
26735
26990
  if (preview == null) return null;
26736
26991
  const candidate = typeof preview === "function" ? preview(record, index) : readRecordSource(record, index, preview, fields, false);
@@ -26799,21 +27054,49 @@ var zx = (() => {
26799
27054
  dataset: { failed: String(preview.rejected) }
26800
27055
  }, fallback, image);
26801
27056
  }
26802
- function createMetadata(record, index, fields, titleSource, subtitleSource) {
26803
- const titleId = typeof titleSource === "string" ? titleSource : null;
26804
- const subtitleId = typeof subtitleSource === "string" ? subtitleSource : null;
26805
- const visible = fields.filter((field) => field.duplicate || field.id !== titleId && field.id !== subtitleId);
26806
- if (visible.length === 0) return null;
26807
- return h("dl", { class: "zx-record-card__metadata" }, visible.map((field) => h(
27057
+ function createEyebrow(record, index, eyebrowStart, eyebrowEnd) {
27058
+ const start = createFieldList(
27059
+ record,
27060
+ index,
27061
+ eyebrowStart,
27062
+ "zx-record-card__eyebrow-group",
27063
+ "eyebrow-start"
27064
+ );
27065
+ const end = createFieldList(
27066
+ record,
27067
+ index,
27068
+ eyebrowEnd,
27069
+ "zx-record-card__eyebrow-group",
27070
+ "eyebrow-end"
27071
+ );
27072
+ return start || end ? h("div", { class: "zx-record-card__eyebrow" }, start, end) : null;
27073
+ }
27074
+ function createFieldList(record, index, fields, className, slot = null) {
27075
+ if (fields.length === 0) return null;
27076
+ return h("dl", {
27077
+ class: className,
27078
+ dataset: slot ? { cardSlot: slot } : void 0
27079
+ }, fields.map((field) => h(
26808
27080
  "div",
26809
27081
  {
26810
27082
  class: "zx-record-card__field",
26811
- dataset: { fieldId: field.id }
27083
+ dataset: { fieldId: field.id, ...slot ? { cardSlot: slot } : {} }
26812
27084
  },
26813
27085
  h("dt", { class: "zx-record-card__label" }, field.label),
26814
27086
  h("dd", { class: "zx-record-card__value" }, contentNode(renderViewField(field, record, index)))
26815
27087
  )));
26816
27088
  }
27089
+ function resolveRecordCardFieldSlot(field) {
27090
+ const view = field?.view;
27091
+ if (!view || typeof view !== "object" || Array.isArray(view)) return null;
27092
+ const card = view.card;
27093
+ if (!card || typeof card !== "object" || Array.isArray(card)) return null;
27094
+ const slot = (
27095
+ /** @type {{slot?:unknown}} */
27096
+ card.slot
27097
+ );
27098
+ return slot === "eyebrow-start" || slot === "eyebrow-end" || slot === "title-prefix" ? slot : null;
27099
+ }
26817
27100
  function createAction(action) {
26818
27101
  const label = String(action.label ?? "");
26819
27102
  const title = String(action.title ?? (label || action.id));
@@ -26883,7 +27166,7 @@ var zx = (() => {
26883
27166
  try {
26884
27167
  const base = globalThis.location?.href ?? "https://zx.invalid/";
26885
27168
  const protocol = new URL(href, base).protocol.toLowerCase();
26886
- return ["http:", "https:", "blob:", "file:"].includes(protocol) ? href : null;
27169
+ return ["http:", "https:", "blob:"].includes(protocol) ? href : null;
26887
27170
  } catch {
26888
27171
  return null;
26889
27172
  }
@@ -26927,7 +27210,7 @@ var zx = (() => {
26927
27210
  actions: [],
26928
27211
  groupBy: null,
26929
27212
  groupOrder: [],
26930
- minCardWidth: "18rem",
27213
+ minCardWidth: "15rem",
26931
27214
  maxColumns: null,
26932
27215
  variant: "outlined",
26933
27216
  loadingCount: 6,
@@ -27201,7 +27484,7 @@ var zx = (() => {
27201
27484
  for (const [id, card] of this._cardNodesById) {
27202
27485
  const selected = this._isViewSelected(id);
27203
27486
  card.dataset.selected = String(selected);
27204
- if (this.options.selectable) card.setAttribute("aria-selected", String(selected));
27487
+ if (this.options.selectable) card.setAttribute("aria-description", selected ? "Selected" : "Not selected");
27205
27488
  const checkbox = (
27206
27489
  /** @type {HTMLInputElement|null} */
27207
27490
  card.querySelector("[data-record-selection]")
@@ -27345,11 +27628,11 @@ var zx = (() => {
27345
27628
  return value == null || value === "" ? "" : String(value);
27346
27629
  }
27347
27630
  function normalizeCardWidth(value) {
27348
- if (typeof value === "number") return Number.isFinite(value) && value > 0 ? `${value}px` : "18rem";
27631
+ if (typeof value === "number") return Number.isFinite(value) && value > 0 ? `${value}px` : "15rem";
27349
27632
  const text4 = String(value ?? "").trim();
27350
- if (!text4 || text4.length > 128 || /[;{}\u0000-\u001f\u007f]/.test(text4)) return "18rem";
27351
- if (/(?:url|image|image-set|cross-fade|element|expression)\s*\(/i.test(text4)) return "18rem";
27352
- if (typeof globalThis.CSS?.supports === "function" && !globalThis.CSS.supports("width", text4)) return "18rem";
27633
+ if (!text4 || text4.length > 128 || /[;{}\u0000-\u001f\u007f]/.test(text4)) return "15rem";
27634
+ if (/(?:url|image|image-set|cross-fade|element|expression)\s*\(/i.test(text4)) return "15rem";
27635
+ if (typeof globalThis.CSS?.supports === "function" && !globalThis.CSS.supports("width", text4)) return "15rem";
27353
27636
  return text4;
27354
27637
  }
27355
27638
  function normalizeMaxColumns(value) {
@@ -27387,8 +27670,284 @@ var zx = (() => {
27387
27670
  return value.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "empty";
27388
27671
  }
27389
27672
 
27673
+ // src/components/kanban-view/kanban-policy.js
27674
+ var RULE_TONES = Object.freeze(["neutral", "accent", "success", "warning", "danger", "info"]);
27675
+ function normalizeKanbanRules(rules) {
27676
+ if (rules == null) return [];
27677
+ if (!Array.isArray(rules)) throw new TypeError("Kanban rules must be an array or null");
27678
+ const seen = /* @__PURE__ */ new Set();
27679
+ return rules.map((rule, index) => {
27680
+ if (!rule || typeof rule !== "object" || Array.isArray(rule)) {
27681
+ throw new TypeError(`Kanban rule at index ${index} must be an object`);
27682
+ }
27683
+ const source = (
27684
+ /** @type {Record<string, any>} */
27685
+ rule
27686
+ );
27687
+ const id = String(source.id ?? "").trim();
27688
+ if (!id) throw new TypeError(`Kanban rule at index ${index} requires an id`);
27689
+ if (seen.has(id)) throw new TypeError(`Duplicate Kanban rule: ${id}`);
27690
+ if (typeof source.when !== "function") {
27691
+ throw new TypeError(`Kanban rule ${id} requires a when predicate`);
27692
+ }
27693
+ seen.add(id);
27694
+ return {
27695
+ id,
27696
+ when: source.when,
27697
+ tone: RULE_TONES.includes(source.tone) ? source.tone : "accent",
27698
+ label: source.label == null ? "" : String(source.label),
27699
+ icon: source.icon == null ? null : String(source.icon),
27700
+ description: source.description == null ? "" : String(source.description)
27701
+ };
27702
+ });
27703
+ }
27704
+ function resolveKanbanRules(rules, record, context) {
27705
+ if (!Array.isArray(rules) || !rules.length) return [];
27706
+ return rules.filter((rule) => {
27707
+ try {
27708
+ return rule.when(record, context) === true;
27709
+ } catch {
27710
+ return false;
27711
+ }
27712
+ });
27713
+ }
27714
+ function evaluateKanbanMove(input) {
27715
+ const { records, column, swimlane, context, from } = input;
27716
+ const moving = Math.max(1, records.length);
27717
+ const policy = column?.wipPolicy === "block" || column?.wipPolicy === "warn" ? column.wipPolicy : input.policy === "block" ? "block" : "warn";
27718
+ const refuse = (reason, extra = {}) => ({
27719
+ allowed: false,
27720
+ reason,
27721
+ limit: null,
27722
+ count: 0,
27723
+ limitExceeded: false,
27724
+ limitReached: false,
27725
+ policy,
27726
+ ...extra
27727
+ });
27728
+ const blockedTransition = allowsKanbanTransition(column, swimlane, from);
27729
+ if (blockedTransition) return refuse(blockedTransition);
27730
+ for (const record of records) {
27731
+ if (column.accept && column.accept(record, context) === false) return refuse("accept");
27732
+ if (swimlane?.accept && swimlane.accept(record, context) === false) return refuse("lane-accept");
27733
+ }
27734
+ const capacities = [];
27735
+ if (column.limit != null) {
27736
+ capacities.push({ reason: "wip", limit: column.limit, count: input.columnCount + moving });
27737
+ }
27738
+ const cellLimit = swimlane ? readLaneLimit(column.laneLimits, swimlane.id) : null;
27739
+ if (cellLimit != null) {
27740
+ capacities.push({ reason: "wip", limit: cellLimit, count: input.cellCount + moving });
27741
+ }
27742
+ if (swimlane?.limit != null) {
27743
+ capacities.push({ reason: "lane-wip", limit: swimlane.limit, count: input.laneCount + moving });
27744
+ }
27745
+ const violated = capacities.find((capacity) => capacity.count > capacity.limit);
27746
+ const reported = violated ?? capacities.find((capacity) => capacity.count === capacity.limit) ?? capacities[0];
27747
+ if (violated && policy === "block") {
27748
+ return refuse(violated.reason, {
27749
+ limit: violated.limit,
27750
+ count: violated.count,
27751
+ limitExceeded: true
27752
+ });
27753
+ }
27754
+ return {
27755
+ allowed: true,
27756
+ reason: null,
27757
+ limit: reported ? reported.limit : null,
27758
+ count: reported ? reported.count : 0,
27759
+ limitExceeded: Boolean(violated),
27760
+ limitReached: Boolean(reported && !violated && reported.count === reported.limit),
27761
+ policy
27762
+ };
27763
+ }
27764
+ function allowsKanbanTransition(column, swimlane, from) {
27765
+ if (column.id !== from.column && !allowsOrigin(column.from, from.column)) return "transition";
27766
+ if (swimlane && swimlane.id !== from.lane && !allowsOrigin(swimlane.from, from.lane)) {
27767
+ return "lane-transition";
27768
+ }
27769
+ return null;
27770
+ }
27771
+ function kanbanSearchTerms(search) {
27772
+ const text4 = search == null ? "" : String(search).trim().toLowerCase();
27773
+ if (!text4) return [];
27774
+ return [...text4.matchAll(/"([^"]*)"|(\S+)/g)].map((match) => (match[1] ?? match[2] ?? "").trim()).filter(Boolean);
27775
+ }
27776
+ function matchesKanbanSearch(text4, terms) {
27777
+ if (!terms.length) return true;
27778
+ const haystack = text4.toLowerCase();
27779
+ return terms.every((term) => haystack.includes(term));
27780
+ }
27781
+ function createKanbanHistory(limit = 50) {
27782
+ const max = Number.isFinite(Number(limit)) && Number(limit) > 0 ? Math.floor(Number(limit)) : 50;
27783
+ let undoStack = [];
27784
+ let redoStack = [];
27785
+ return {
27786
+ push(entry) {
27787
+ undoStack.push(entry);
27788
+ if (undoStack.length > max) undoStack = undoStack.slice(-max);
27789
+ redoStack = [];
27790
+ },
27791
+ undo(current) {
27792
+ const entry = undoStack.pop();
27793
+ if (!entry) return null;
27794
+ redoStack.push(current);
27795
+ if (redoStack.length > max) redoStack = redoStack.slice(-max);
27796
+ return entry;
27797
+ },
27798
+ redo(current) {
27799
+ const entry = redoStack.pop();
27800
+ if (!entry) return null;
27801
+ undoStack.push(current);
27802
+ if (undoStack.length > max) undoStack = undoStack.slice(-max);
27803
+ return entry;
27804
+ },
27805
+ canUndo: () => undoStack.length > 0,
27806
+ canRedo: () => redoStack.length > 0,
27807
+ clear() {
27808
+ undoStack = [];
27809
+ redoStack = [];
27810
+ },
27811
+ depth: () => ({ undo: undoStack.length, redo: redoStack.length })
27812
+ };
27813
+ }
27814
+ function reorderKanbanRecords(records, entries, options) {
27815
+ if (!Array.isArray(records) || !Array.isArray(entries) || !entries.length) return null;
27816
+ const sources = entries.map((entry) => ({
27817
+ ...entry,
27818
+ index: records.findIndex((record) => Object.is(viewRecordId(record, options.recordId), entry.id))
27819
+ }));
27820
+ if (sources.some((source) => source.index < 0 || !source.record || typeof source.record !== "object")) {
27821
+ return null;
27822
+ }
27823
+ sources.sort((left, right) => left.index - right.index);
27824
+ const moves = sources.map((source) => ({
27825
+ id: source.id,
27826
+ from: locateKanbanRecord(records, source.index, options)
27827
+ }));
27828
+ const movingIndices = new Set(sources.map((source) => source.index));
27829
+ const next = records.filter((_, index) => !movingIndices.has(index));
27830
+ const destinationRecords = next.filter((record, index) => normalizeAxisId(readKanbanAccessor(record, index, options.columnBy)) === options.destination.column && (options.swimlaneBy == null || normalizeAxisId(readKanbanAccessor(record, index, options.swimlaneBy)) === options.destination.lane));
27831
+ const targetIndex = clampKanbanIndex(options.destination.index, destinationRecords.length);
27832
+ let insertion = next.length;
27833
+ if (destinationRecords[targetIndex]) insertion = next.indexOf(destinationRecords[targetIndex]);
27834
+ else if (destinationRecords.length) insertion = next.indexOf(destinationRecords.at(-1)) + 1;
27835
+ next.splice(insertion, 0, ...sources.map((source) => source.record));
27836
+ return {
27837
+ records: next,
27838
+ moves,
27839
+ to: {
27840
+ column: options.destination.column,
27841
+ lane: options.swimlaneBy == null ? null : options.destination.lane,
27842
+ index: targetIndex
27843
+ }
27844
+ };
27845
+ }
27846
+ function locateKanbanRecord(records, index, options) {
27847
+ const record = records[index];
27848
+ const column = normalizeAxisId(readKanbanAccessor(record, index, options.columnBy));
27849
+ const lane = options.swimlaneBy == null ? null : normalizeAxisId(readKanbanAccessor(record, index, options.swimlaneBy));
27850
+ const group = records.filter((candidate, candidateIndex) => normalizeAxisId(readKanbanAccessor(candidate, candidateIndex, options.columnBy)) === column && (options.swimlaneBy == null || normalizeAxisId(readKanbanAccessor(candidate, candidateIndex, options.swimlaneBy)) === lane));
27851
+ const id = viewRecordId(record, options.recordId);
27852
+ return {
27853
+ column,
27854
+ lane,
27855
+ index: group.findIndex((candidate) => Object.is(viewRecordId(candidate, options.recordId), id))
27856
+ };
27857
+ }
27858
+ function absoluteKanbanIndex(visibility, visibleIndex) {
27859
+ const target = clampKanbanIndex(visibleIndex, visibility.filter(Boolean).length);
27860
+ let seen = 0;
27861
+ for (let index = 0; index < visibility.length; index += 1) {
27862
+ if (!visibility[index]) continue;
27863
+ if (seen === target) return index;
27864
+ seen += 1;
27865
+ }
27866
+ return visibility.length;
27867
+ }
27868
+ function readKanbanAccessor(record, index, accessor) {
27869
+ return typeof accessor === "function" ? accessor(record, index) : accessor == null ? null : record?.[accessor];
27870
+ }
27871
+ function normalizeAxisId(value) {
27872
+ return value == null ? "" : String(value);
27873
+ }
27874
+ function clampKanbanIndex(value, maximum) {
27875
+ if (value == null || !Number.isFinite(Number(value))) return maximum;
27876
+ return Math.max(0, Math.min(maximum, Math.trunc(Number(value))));
27877
+ }
27878
+ function allowsOrigin(allowed, origin) {
27879
+ if (allowed == null) return true;
27880
+ if (!Array.isArray(allowed)) return true;
27881
+ if (!allowed.length) return false;
27882
+ return allowed.some((entry) => String(entry) === "*" || String(entry) === normalizeAxisId(origin));
27883
+ }
27884
+ function readLaneLimit(laneLimits, lane) {
27885
+ if (!laneLimits || typeof laneLimits !== "object") return null;
27886
+ const value = (
27887
+ /** @type {Record<string, unknown>} */
27888
+ laneLimits[lane]
27889
+ );
27890
+ if (value == null || value === "") return null;
27891
+ const number2 = Number(value);
27892
+ return Number.isFinite(number2) && number2 >= 0 ? Math.floor(number2) : null;
27893
+ }
27894
+
27390
27895
  // src/components/kanban-view/kanban-view.js
27391
27896
  var INTERACTIVE = 'a, button, input, select, textarea, summary, [contenteditable="true"]';
27897
+ var DRAG_THRESHOLD = 4;
27898
+ var TOUCH_HOLD = 350;
27899
+ var SEARCH_DEBOUNCE = 120;
27900
+ var SCROLL_EDGE = 56;
27901
+ var SCROLL_SPEED = 18;
27902
+ var DEFAULT_LABELS = Object.freeze({
27903
+ fields: "Fields",
27904
+ search: "Search board",
27905
+ searchPlaceholder: "Search cards",
27906
+ clearSearch: "Clear search",
27907
+ searchSummary: "%count% of %total% cards match.",
27908
+ undo: "Undo",
27909
+ redo: "Redo",
27910
+ add: "Add card",
27911
+ addTo: "Add card to %target%",
27912
+ move: "Move %name%",
27913
+ moveMultiple: "Move %count% selected cards",
27914
+ collapseColumn: "Collapse column %label%",
27915
+ expandColumn: "Expand column %label%",
27916
+ collapseLane: "Collapse swim lane %label%",
27917
+ expandLane: "Expand swim lane %label%",
27918
+ count: "%count% records",
27919
+ countFiltered: "%count% of %total% records",
27920
+ countLimit: "%count% records, work-in-progress limit %limit%",
27921
+ countLimitInLane: "%count% records in this lane, %total% of work-in-progress limit %limit% in the column",
27922
+ empty: "No records",
27923
+ columnEmpty: "",
27924
+ selected: "Selected",
27925
+ notSelected: "Not selected",
27926
+ noMatches: "No cards match the current search.",
27927
+ record: "record",
27928
+ grabbed: "Grabbed %name%. Use left and right for columns, up and down for position, and Alt plus up or down for swim lanes.",
27929
+ grabbedMultiple: "Grabbed %count% cards. Use left and right for columns, up and down for position, and Alt plus up or down for swim lanes.",
27930
+ target: "Target %target%, position %position% of %total%.",
27931
+ moved: "Moved %name% to %target%.%wip%",
27932
+ movedMultiple: "Moved %count% cards to %target%.%wip%",
27933
+ moveRequested: "Move requested for %name% to %target%.%wip%",
27934
+ moveCanceled: "Move canceled for %name%.",
27935
+ moveCanceledPlain: "Move canceled.",
27936
+ limitReached: " Work-in-progress limit %limit% reached.",
27937
+ limitExceeded: " Work-in-progress limit %limit% exceeded.",
27938
+ rejectAccept: "Move rejected: %target% does not accept %name%.",
27939
+ rejectTransition: "Move rejected: %target% does not accept cards from %origin%.",
27940
+ rejectWip: "Move rejected: %target% is at its work-in-progress limit of %limit%.",
27941
+ rejectDestination: "Move rejected: the destination is not available.",
27942
+ rejectGrouping: "Move rejected: callback grouping requires external move mode.",
27943
+ undone: "Move undone.",
27944
+ redone: "Move redone.",
27945
+ nothingToUndo: "Nothing to undo.",
27946
+ nothingToRedo: "Nothing to redo.",
27947
+ menuMoveTo: "Move to %target%",
27948
+ menuMoveToLane: "Move to lane %target%",
27949
+ dragPreview: "%count% cards"
27950
+ });
27392
27951
  var KanbanView = class extends RecordView {
27393
27952
  static cssName = "kanban-view";
27394
27953
  /** @type {Readonly<KanbanViewOptions & RecordViewOptions>} */
@@ -27405,6 +27964,27 @@ var zx = (() => {
27405
27964
  link: null,
27406
27965
  actions: [],
27407
27966
  moveMode: "local",
27967
+ dragFrom: "card",
27968
+ multiMove: true,
27969
+ autoScroll: true,
27970
+ wipPolicy: "warn",
27971
+ rules: [],
27972
+ search: "",
27973
+ searchFields: null,
27974
+ filter: null,
27975
+ searchControl: false,
27976
+ history: true,
27977
+ historyLimit: 50,
27978
+ historyControls: false,
27979
+ columnHeight: null,
27980
+ allowAdd: false,
27981
+ contextMenu: false,
27982
+ renderCard: null,
27983
+ renderColumnHeader: null,
27984
+ renderSwimlaneHeader: null,
27985
+ renderColumnEmpty: null,
27986
+ renderDragPreview: null,
27987
+ labels: {},
27408
27988
  columnOrder: [],
27409
27989
  swimlaneOrder: [],
27410
27990
  collapsedColumns: [],
@@ -27440,17 +28020,45 @@ var zx = (() => {
27440
28020
  this._kanbanSwimlaneOrder = normalizeOrder(options.swimlaneOrder);
27441
28021
  this._collapsedColumns = new Set(normalizeOrder(options.collapsedColumns));
27442
28022
  this._collapsedSwimlanes = new Set(normalizeOrder(options.collapsedSwimlanes));
28023
+ this._kanbanRules = normalizeKanbanRules(options.rules);
28024
+ this._kanbanSearch = options.search == null ? "" : String(options.search);
28025
+ this._kanbanFilter = typeof options.filter === "function" ? options.filter : null;
28026
+ this._kanbanHistory = createKanbanHistory(options.historyLimit);
27443
28027
  this._keyboardMove = /** @type {KanbanKeyboardMove|null} */
27444
28028
  null;
27445
- this._dragId = null;
28029
+ this._dragIds = /** @type {unknown[]} */
28030
+ [];
27446
28031
  this._dragDestination = /** @type {KanbanMovePoint|null} */
27447
28032
  null;
28033
+ this._pointerStart = /** @type {{x:number,y:number,id:number,ids:unknown[],touch:boolean}|null} */
28034
+ null;
28035
+ this._pointerHold = 0;
28036
+ this._dragFrame = 0;
28037
+ this._dragPreview = /** @type {HTMLElement|null} */
28038
+ null;
28039
+ this._dragPointer = /** @type {number|null} */
28040
+ null;
28041
+ this._dragCaptured = false;
28042
+ this._searchTimer = 0;
28043
+ this._dragPoint = { x: 0, y: 0 };
28044
+ this._suppressClick = false;
27448
28045
  this._announcementToken = 0;
27449
28046
  this._cardMeta = /* @__PURE__ */ new WeakMap();
27450
28047
  this._sectionMeta = /* @__PURE__ */ new WeakMap();
27451
28048
  this._cardsById = /* @__PURE__ */ new Map();
28049
+ this._visibleIds = /* @__PURE__ */ new Set();
28050
+ this._kanbanMenu = /** @type {ContextMenu|null} */
28051
+ null;
28052
+ this._searchInput = /** @type {HTMLInputElement|null} */
28053
+ null;
28054
+ this._undoButton = /** @type {HTMLButtonElement|null} */
28055
+ null;
28056
+ this._redoButton = /** @type {HTMLButtonElement|null} */
28057
+ null;
27452
28058
  this._toolbar = h("div", { class: "zx-record-view__toolbar zx-kanban-view__toolbar" });
27453
- if (options.fieldControls) this._toolbar.append(this._createViewFieldControls());
28059
+ if (options.fieldControls) this._toolbar.append(this._createViewFieldControls(this._text("fields")));
28060
+ if (options.searchControl) this._toolbar.append(this._createSearchControl());
28061
+ if (options.historyControls) this._toolbar.append(this._createHistoryControls());
27454
28062
  this._board = h("div", {
27455
28063
  class: "zx-kanban-view__board",
27456
28064
  role: "region",
@@ -27463,6 +28071,10 @@ var zx = (() => {
27463
28071
  ariaAtomic: "true"
27464
28072
  });
27465
28073
  root.replaceChildren(this._toolbar, this._board, this._live);
28074
+ if (options.columnHeight) {
28075
+ root.style.setProperty("--zx-kanban-column-height", String(options.columnHeight));
28076
+ root.dataset.columnScroll = "true";
28077
+ }
27466
28078
  this.listen(this._board, "click", (event) => this._handleBoardClick(
27467
28079
  /** @type {MouseEvent} */
27468
28080
  event
@@ -27476,20 +28088,22 @@ var zx = (() => {
27476
28088
  event
27477
28089
  ));
27478
28090
  this.listen(this._board, "error", (event) => this._handlePreviewError(event), { capture: true });
27479
- this.listen(this._board, "dragstart", (event) => this._handleDragStart(
27480
- /** @type {DragEvent} */
28091
+ this.listen(this._board, "pointerdown", (event) => this._handlePointerDown(
28092
+ /** @type {PointerEvent} */
27481
28093
  event
27482
28094
  ));
27483
- this.listen(this._board, "dragover", (event) => this._handleDragOver(
27484
- /** @type {DragEvent} */
28095
+ this.listen(this._board, "pointermove", (event) => this._handlePointerMove(
28096
+ /** @type {PointerEvent} */
27485
28097
  event
27486
28098
  ));
27487
- this.listen(this._board, "drop", (event) => this._handleDrop(
27488
- /** @type {DragEvent} */
28099
+ this.listen(this._board, "pointerup", (event) => this._handlePointerUp(
28100
+ /** @type {PointerEvent} */
27489
28101
  event
27490
28102
  ));
27491
- this.listen(this._board, "dragend", () => this._clearDrag());
28103
+ this.listen(this._board, "pointercancel", () => this._clearDrag());
28104
+ this.listen(this._board, "dragstart", (event) => event.preventDefault());
27492
28105
  this._refreshView("init");
28106
+ if (options.contextMenu) this._createContextMenu();
27493
28107
  return root;
27494
28108
  } catch (error2) {
27495
28109
  if (this._kanbanSnapshot) restoreTarget(root, this._kanbanSnapshot);
@@ -27507,7 +28121,16 @@ var zx = (() => {
27507
28121
  */
27508
28122
  setColumns(columns) {
27509
28123
  this._kanbanColumns = columns === null ? null : normalizeKanbanDescriptors(columns, "column");
27510
- this._kanbanColumnOrder = reconcileKanbanOrder(this._rawColumns(), this._kanbanColumnOrder);
28124
+ const descriptors = this._rawColumns();
28125
+ this._kanbanColumnOrder = reconcileKanbanPreference(
28126
+ descriptors,
28127
+ this._kanbanColumnOrder,
28128
+ this._kanbanColumns === null
28129
+ );
28130
+ if (this._kanbanColumns !== null) {
28131
+ const ids = new Set(descriptors.map((column) => column.id));
28132
+ this._collapsedColumns = new Set([...this._collapsedColumns].filter((id) => ids.has(id)));
28133
+ }
27511
28134
  this._refreshView("columns");
27512
28135
  this._emitKanbanState("columns");
27513
28136
  return this;
@@ -27523,7 +28146,17 @@ var zx = (() => {
27523
28146
  */
27524
28147
  setSwimlanes(lanes) {
27525
28148
  this._kanbanSwimlanes = lanes === null ? null : normalizeKanbanDescriptors(lanes, "swimlane");
27526
- this._kanbanSwimlaneOrder = reconcileKanbanOrder(this._rawSwimlanes(), this._kanbanSwimlaneOrder);
28149
+ const descriptors = this._rawSwimlanes();
28150
+ const preserveUnknown = this._kanbanSwimlanes === null && this._kanbanOptions().swimlaneBy != null;
28151
+ this._kanbanSwimlaneOrder = reconcileKanbanPreference(
28152
+ descriptors,
28153
+ this._kanbanSwimlaneOrder,
28154
+ preserveUnknown
28155
+ );
28156
+ if (!preserveUnknown) {
28157
+ const ids = new Set(descriptors.map((lane) => lane.id));
28158
+ this._collapsedSwimlanes = new Set([...this._collapsedSwimlanes].filter((id) => ids.has(id)));
28159
+ }
27527
28160
  this._refreshView("swimlanes");
27528
28161
  this._emitKanbanState("swimlanes");
27529
28162
  return this;
@@ -27539,9 +28172,16 @@ var zx = (() => {
27539
28172
  * @returns {this}
27540
28173
  */
27541
28174
  setColumnOrder(order, options = {}) {
27542
- const next = reconcileKanbanOrder(this._rawColumns(), order);
27543
- if (sameArray3(next, this.getColumnOrder())) return this;
27544
- this._kanbanColumnOrder = next;
28175
+ const descriptors = this._rawColumns();
28176
+ const current = this.getColumnOrder();
28177
+ const next = reconcileKanbanOrder(descriptors, order);
28178
+ if (sameArray3(next, current)) return this;
28179
+ this._kanbanColumnOrder = mergeKanbanResolvedOrder(
28180
+ this._kanbanColumnOrder,
28181
+ descriptors,
28182
+ next,
28183
+ this._kanbanColumns === null
28184
+ );
27545
28185
  this._refreshView("columnorder");
27546
28186
  if (!options.silent) this._emitKanbanState("columnorder");
27547
28187
  return this;
@@ -27567,9 +28207,17 @@ var zx = (() => {
27567
28207
  * @returns {this}
27568
28208
  */
27569
28209
  setSwimlaneOrder(order, options = {}) {
27570
- const next = reconcileKanbanOrder(this._rawSwimlanes(), order);
27571
- if (sameArray3(next, this.getSwimlaneOrder())) return this;
27572
- this._kanbanSwimlaneOrder = next;
28210
+ const descriptors = this._rawSwimlanes();
28211
+ const current = this.getSwimlaneOrder();
28212
+ const next = reconcileKanbanOrder(descriptors, order);
28213
+ if (sameArray3(next, current)) return this;
28214
+ const preserveUnknown = this._kanbanSwimlanes === null && this._kanbanOptions().swimlaneBy != null;
28215
+ this._kanbanSwimlaneOrder = mergeKanbanResolvedOrder(
28216
+ this._kanbanSwimlaneOrder,
28217
+ descriptors,
28218
+ next,
28219
+ preserveUnknown
28220
+ );
27573
28221
  this._refreshView("swimlaneorder");
27574
28222
  if (!options.silent) this._emitKanbanState("swimlaneorder");
27575
28223
  return this;
@@ -27637,126 +28285,292 @@ var zx = (() => {
27637
28285
  return this.setSwimlaneCollapsed(id, !this._collapsedSwimlanes.has(String(id)));
27638
28286
  }
27639
28287
  /**
27640
- * Proposes and, in local mode, commits a record move. Destination acceptance is checked before
27641
- * a cancelable `recordmove` event. WIP limits are reported but remain advisory.
28288
+ * Proposes and, in local mode, commits a record move. Equivalent to `moveRecords([id], …)`.
27642
28289
  * @param {unknown} id Record id.
27643
28290
  * @param {KanbanDestination} destination Destination.
27644
28291
  * @returns {this}
27645
28292
  * @fires KanbanView#recordmove
28293
+ * @fires KanbanView#movereject
27646
28294
  * @fires RecordView#datachange
27647
28295
  */
27648
28296
  moveRecord(id, destination) {
28297
+ return this.moveRecords([id], destination);
28298
+ }
28299
+ /**
28300
+ * Proposes and, in local mode, commits an atomic move of one or more records. Destination
28301
+ * eligibility, transition rules, and work-in-progress policy are all checked before a single
28302
+ * cancelable `recordmove` event; the move commits completely or not at all, and the moving
28303
+ * records keep their relative order at the destination.
28304
+ * @param {unknown[]} ids Record ids, in any order.
28305
+ * @param {KanbanDestination} destination Destination.
28306
+ * @returns {this}
28307
+ * @fires KanbanView#recordmove
28308
+ * @fires KanbanView#movereject
28309
+ * @fires RecordView#datachange
28310
+ */
28311
+ moveRecords(ids, destination) {
27649
28312
  const options = this._kanbanOptions();
27650
- const record = this.getRecord(id);
27651
- const from = this._recordLocation(id);
27652
- if (!record || !from || !destination || typeof destination !== "object") return this;
28313
+ if (!Array.isArray(ids) || !destination || typeof destination !== "object") return this;
28314
+ const moving = this._orderedIds(ids);
28315
+ if (!moving.length) return this;
28316
+ const records = moving.map((id) => (
28317
+ /** @type {KanbanRecord} */
28318
+ this.getRecord(id)
28319
+ ));
28320
+ const origins = moving.map((id) => (
28321
+ /** @type {KanbanMovePoint} */
28322
+ this._recordLocation(id)
28323
+ ));
28324
+ const from = origins[0];
27653
28325
  const columns = this._resolvedColumns();
27654
28326
  const lanes = this._resolvedSwimlanes();
27655
28327
  const columnId = destination.column == null ? from.column : String(destination.column);
27656
28328
  const laneId = options.swimlaneBy == null ? null : destination.lane === void 0 ? from.lane : normalizeAxisId(destination.lane);
27657
28329
  const column = columns.find((candidate) => candidate.id === columnId);
27658
- const lane = options.swimlaneBy == null ? null : lanes.find((candidate) => candidate.id === laneId);
28330
+ const lane = options.swimlaneBy == null ? null : lanes.find((candidate) => candidate.id === laneId) ?? null;
27659
28331
  if (!column || options.swimlaneBy != null && !lane) {
27660
- this._announce("Move rejected: the destination is not available.");
28332
+ this._rejectMove("destination", { ids: moving, records, from, to: null, column: null, swimlane: null });
27661
28333
  return this;
27662
28334
  }
27663
- const destinationCount = this._recordsIn(columnId, laneId).filter((candidate) => !Object.is(this._viewRecordId(candidate.record), id)).length;
27664
- const destinationColumnCount = this._viewData.filter((candidate, candidateIndex) => normalizeAxisId(this._readAxis(options.columnBy, candidate, candidateIndex)) === columnId && !Object.is(this._viewRecordId(candidate), id)).length;
27665
- const index = normalizeMoveIndex(destination.index, destinationCount);
28335
+ const excluded = new Set(moving);
28336
+ const cellCount = this._recordsIn(columnId, laneId).filter((entry) => !excluded.has(this._viewRecordId(entry.record))).length;
28337
+ const index = clampKanbanIndex(destination.index, cellCount);
27666
28338
  const to = { column: columnId, lane: laneId, index };
27667
- if (sameMovePoint(from, to)) return this;
27668
- const context = { id, from: { ...from }, to: { ...to }, column: { ...column }, swimlane: lane ? { ...lane } : null };
27669
- if (column.accept && column.accept(record, context) === false || lane?.accept && lane.accept(record, context) === false) {
27670
- this._announce(`Move rejected for ${this._recordName(record, from.index)}.`);
28339
+ if (moving.length === 1 && sameMovePoint(from, to)) return this;
28340
+ const context = {
28341
+ id: moving[0],
28342
+ from: { ...from },
28343
+ to: { ...to },
28344
+ column: { ...column },
28345
+ swimlane: lane ? { ...lane } : null
28346
+ };
28347
+ const evaluation = this._evaluateMove(records, origins, column, lane, to, context, excluded);
28348
+ if (!evaluation.allowed) {
28349
+ this._rejectMove(
28350
+ /** @type {KanbanRejectReason} */
28351
+ evaluation.reason,
28352
+ {
28353
+ ids: moving,
28354
+ records,
28355
+ from,
28356
+ to,
28357
+ column,
28358
+ swimlane: lane,
28359
+ evaluation
28360
+ }
28361
+ );
27671
28362
  return this;
27672
28363
  }
27673
- let movedRecord = record;
27674
- if (options.moveMode === "local") {
27675
- movedRecord = this._localMovedRecord(record, from, to, column, lane);
27676
- if (!movedRecord) {
27677
- this._announce("Move rejected: callback grouping requires external move mode.");
28364
+ const entries = [];
28365
+ for (let position2 = 0; position2 < moving.length; position2 += 1) {
28366
+ const record = records[position2];
28367
+ const moved = options.moveMode === "local" ? this._localMovedRecord(record, origins[position2], to, column, lane) : record;
28368
+ if (!moved) {
28369
+ this._rejectMove("grouping", { ids: moving, records, from, to, column, swimlane: lane });
27678
28370
  return this;
27679
28371
  }
28372
+ entries.push({ id: moving[position2], record: moved });
27680
28373
  }
27681
- const next = reorderKanbanData(this._viewData, id, movedRecord, {
28374
+ const next = reorderKanbanRecords(this._viewData, entries, {
27682
28375
  recordId: options.recordId,
27683
28376
  columnBy: (candidate, candidateIndex) => this._readAxis(options.columnBy, candidate, candidateIndex),
27684
28377
  swimlaneBy: options.swimlaneBy == null ? null : (candidate, candidateIndex) => this._readAxis(options.swimlaneBy, candidate, candidateIndex),
27685
28378
  destination: to
27686
28379
  });
27687
28380
  if (!next) {
27688
- this._announce("Move rejected: the destination could not be resolved.");
28381
+ this._rejectMove("destination", { ids: moving, records, from, to, column, swimlane: lane });
27689
28382
  return this;
27690
28383
  }
27691
- const finalCount = destinationColumnCount + 1;
27692
- const limitExceeded = column.limit != null && finalCount > column.limit;
28384
+ const wip = this._wipAnnouncement(evaluation);
27693
28385
  const moveEvent = this.emit("recordmove", {
27694
- record,
27695
- id,
28386
+ record: records[0],
28387
+ id: moving[0],
28388
+ records: [...records],
28389
+ ids: [...moving],
28390
+ moves: next.moves.map((move) => ({ id: move.id, from: { ...move.from } })),
27696
28391
  from: { ...from },
27697
28392
  to: { ...next.to },
27698
28393
  column: { ...column },
27699
28394
  swimlane: lane ? { ...lane } : null,
27700
- limitExceeded
27701
- });
28395
+ limitExceeded: evaluation.limitExceeded
28396
+ }, { honorDomCancellation: true });
27702
28397
  if (moveEvent.defaultPrevented) {
27703
- this._announce(`Move canceled for ${this._recordName(record, from.index)}.`);
28398
+ this._announce(this._text("moveCanceled", { name: this._movingName(records) }));
27704
28399
  return this;
27705
28400
  }
27706
28401
  if (options.moveMode === "external") {
27707
- this._announce(`Move requested for ${this._recordName(record, from.index)} to ${this._targetName(next.to)}.`);
28402
+ this._announce(this._text("moveRequested", {
28403
+ name: this._movingName(records),
28404
+ target: this._targetName(next.to),
28405
+ wip
28406
+ }));
27708
28407
  return this;
27709
28408
  }
27710
- this._viewData = next.records;
27711
- this._refreshView("move");
27712
- this.emit("datachange", { records: this.getData() });
27713
- this._announce(`Moved ${this._recordName(movedRecord, next.to.index)} to ${this._targetName(next.to)}.`);
27714
- queueMicrotask(() => this._moveHandle(id)?.focus());
28409
+ this._commitLocalRecords(next.records);
28410
+ this._announce(moving.length > 1 ? this._text("movedMultiple", { count: moving.length, target: this._targetName(next.to), wip }) : this._text("moved", {
28411
+ name: this._recordName(entries[0].record, next.to.index),
28412
+ target: this._targetName(next.to),
28413
+ wip
28414
+ }));
28415
+ queueMicrotask(() => this._moveHandle(moving[0])?.focus());
27715
28416
  return this;
27716
28417
  }
27717
- /** Returns common plus board-specific serializable configuration. @returns {KanbanViewState} */
27718
- getViewState() {
27719
- return {
27720
- ...super.getViewState(),
27721
- columnOrder: this.getColumnOrder(),
27722
- swimlaneOrder: this.getSwimlaneOrder(),
27723
- collapsedColumns: this.getCollapsedColumns(),
27724
- collapsedSwimlanes: this.getCollapsedSwimlanes()
27725
- };
28418
+ /** Whether a committed local move can be reverted. @returns {boolean} */
28419
+ canUndo() {
28420
+ return this._kanbanOptions().history !== false && this._kanbanHistory.canUndo();
28421
+ }
28422
+ /** Whether a reverted local move can be reapplied. @returns {boolean} */
28423
+ canRedo() {
28424
+ return this._kanbanOptions().history !== false && this._kanbanHistory.canRedo();
27726
28425
  }
27727
28426
  /**
27728
- * Restores common and Kanban configuration. State from another record view is accepted safely.
27729
- * @param {Partial<KanbanViewState>|null} state Saved state.
27730
- * @param {{silent?:boolean}} [options={}] Event behavior.
28427
+ * Reverts the last committed local move, restoring the exact record order and selection.
27731
28428
  * @returns {this}
28429
+ * @fires RecordView#datachange
28430
+ * @fires KanbanView#historychange
27732
28431
  */
27733
- setViewState(state, options = {}) {
27734
- const source = state && typeof state === "object" ? state : {};
27735
- super.setViewState(source, { silent: true });
27736
- this._kanbanColumnOrder = reconcileKanbanOrder(
27737
- this._rawColumns(),
27738
- Array.isArray(source.columnOrder) ? source.columnOrder : this._kanbanColumnOrder
27739
- );
27740
- this._kanbanSwimlaneOrder = reconcileKanbanOrder(
27741
- this._rawSwimlanes(),
27742
- Array.isArray(source.swimlaneOrder) ? source.swimlaneOrder : this._kanbanSwimlaneOrder
27743
- );
27744
- const columnIds = new Set(this._rawColumns().map((column) => column.id));
27745
- const laneIds = new Set(this._rawSwimlanes().map((lane) => lane.id));
27746
- if (Array.isArray(source.collapsedColumns)) {
27747
- this._collapsedColumns = new Set(source.collapsedColumns.map(String).filter((id) => columnIds.has(id)));
27748
- }
27749
- if (Array.isArray(source.collapsedSwimlanes)) {
27750
- this._collapsedSwimlanes = new Set(source.collapsedSwimlanes.map(String).filter((id) => laneIds.has(id)));
27751
- }
27752
- this._refreshView("state");
27753
- if (!options.silent) this._emitKanbanState("restore");
27754
- return this;
28432
+ undo() {
28433
+ return this._travelHistory("undo");
28434
+ }
28435
+ /**
28436
+ * Reapplies the last reverted local move.
28437
+ * @returns {this}
28438
+ * @fires RecordView#datachange
28439
+ * @fires KanbanView#historychange
28440
+ */
28441
+ redo() {
28442
+ return this._travelHistory("redo");
28443
+ }
28444
+ /** Empties both history stacks. @returns {this} @fires KanbanView#historychange */
28445
+ clearHistory() {
28446
+ this._kanbanHistory.clear();
28447
+ this._syncHistoryControls();
28448
+ this._emitHistory();
28449
+ return this;
28450
+ }
28451
+ /** Returns the current board search text. @returns {string} */
28452
+ getSearch() {
28453
+ return this._kanbanSearch;
28454
+ }
28455
+ /**
28456
+ * Narrows visible cards to those matching a search. Hidden records keep their position in the
28457
+ * data, so a card dropped between two visible cards still lands between them.
28458
+ * @param {string} search Search text.
28459
+ * @param {{silent?:boolean}} [options={}] Event behavior.
28460
+ * @returns {this}
28461
+ * @fires KanbanView#searchchange
28462
+ */
28463
+ setSearch(search, options = {}) {
28464
+ const next = search == null ? "" : String(search);
28465
+ if (next === this._kanbanSearch) return this;
28466
+ this._kanbanSearch = next;
28467
+ if (this._searchInput && this._searchInput.value !== next) this._searchInput.value = next;
28468
+ this._refreshView("search");
28469
+ if (!options.silent) this.emit("searchchange", { search: next });
28470
+ return this;
28471
+ }
28472
+ /**
28473
+ * Replaces the extra card predicate. Pass null to clear it.
28474
+ * @param {((record:KanbanRecord,index:number)=>boolean)|null} filter Predicate.
28475
+ * @returns {this}
28476
+ */
28477
+ setFilter(filter) {
28478
+ if (filter != null && typeof filter !== "function") {
28479
+ throw new TypeError("Kanban filter must be a function or null");
28480
+ }
28481
+ this._kanbanFilter = filter ?? null;
28482
+ this._refreshView("filter");
28483
+ return this;
28484
+ }
28485
+ /** Returns the records currently rendered, in board order. @returns {KanbanRecord[]} */
28486
+ getVisibleRecords() {
28487
+ return this._viewData.filter((record) => this._visibleIds.has(this._viewRecordId(record)));
28488
+ }
28489
+ /**
28490
+ * Replaces every record. History is dropped because its snapshots describe records the host has
28491
+ * just replaced, and reinstating them would resurrect data the application no longer owns.
28492
+ * @param {KanbanRecord[]} records New records.
28493
+ * @returns {this}
28494
+ */
28495
+ setData(records) {
28496
+ this._resetHistory();
28497
+ return super.setData(records);
28498
+ }
28499
+ /** @param {KanbanRecord[]} records Records to append. @returns {this} */
28500
+ addData(records) {
28501
+ this._resetHistory();
28502
+ return super.addData(records);
28503
+ }
28504
+ /** @param {unknown} id Existing record id. @param {KanbanRecord} record Replacement. @returns {this} */
28505
+ updateRecord(id, record) {
28506
+ this._resetHistory();
28507
+ return super.updateRecord(id, record);
28508
+ }
28509
+ /** @param {unknown} id Record id. @returns {this} */
28510
+ removeRecord(id) {
28511
+ this._resetHistory();
28512
+ return super.removeRecord(id);
28513
+ }
28514
+ /** Returns common plus board-specific serializable configuration. @returns {KanbanViewState} */
28515
+ getViewState() {
28516
+ this.getColumnOrder();
28517
+ this.getSwimlaneOrder();
28518
+ const derivedColumns = this._kanbanColumns === null;
28519
+ const derivedSwimlanes = this._kanbanSwimlanes === null && this._kanbanOptions().swimlaneBy != null;
28520
+ return {
28521
+ ...super.getViewState(),
28522
+ columnOrder: derivedColumns ? [...this._kanbanColumnOrder] : this.getColumnOrder(),
28523
+ swimlaneOrder: derivedSwimlanes ? [...this._kanbanSwimlaneOrder] : this.getSwimlaneOrder(),
28524
+ collapsedColumns: derivedColumns ? [...this._collapsedColumns] : this.getCollapsedColumns(),
28525
+ collapsedSwimlanes: derivedSwimlanes ? [...this._collapsedSwimlanes] : this.getCollapsedSwimlanes()
28526
+ };
28527
+ }
28528
+ /**
28529
+ * Restores common and Kanban configuration. State from another record view is accepted safely.
28530
+ * @param {Partial<KanbanViewState>|null} state Saved state.
28531
+ * @param {{silent?:boolean}} [options={}] Event behavior.
28532
+ * @returns {this}
28533
+ */
28534
+ setViewState(state, options = {}) {
28535
+ const source = state && typeof state === "object" ? state : {};
28536
+ super.setViewState(source, { silent: true });
28537
+ const columns = this._rawColumns();
28538
+ const lanes = this._rawSwimlanes();
28539
+ const derivedColumns = this._kanbanColumns === null;
28540
+ const derivedSwimlanes = this._kanbanSwimlanes === null && this._kanbanOptions().swimlaneBy != null;
28541
+ this._kanbanColumnOrder = reconcileKanbanPreference(
28542
+ columns,
28543
+ Array.isArray(source.columnOrder) ? source.columnOrder : this._kanbanColumnOrder,
28544
+ derivedColumns
28545
+ );
28546
+ this._kanbanSwimlaneOrder = reconcileKanbanPreference(
28547
+ lanes,
28548
+ Array.isArray(source.swimlaneOrder) ? source.swimlaneOrder : this._kanbanSwimlaneOrder,
28549
+ derivedSwimlanes
28550
+ );
28551
+ const columnIds = new Set(columns.map((column) => column.id));
28552
+ const laneIds = new Set(lanes.map((lane) => lane.id));
28553
+ if (Array.isArray(source.collapsedColumns)) {
28554
+ const collapsed = normalizeOrder(source.collapsedColumns);
28555
+ this._collapsedColumns = new Set(derivedColumns ? collapsed : collapsed.filter((id) => columnIds.has(id)));
28556
+ }
28557
+ if (Array.isArray(source.collapsedSwimlanes)) {
28558
+ const collapsed = normalizeOrder(source.collapsedSwimlanes);
28559
+ this._collapsedSwimlanes = new Set(derivedSwimlanes ? collapsed : collapsed.filter((id) => laneIds.has(id)));
28560
+ }
28561
+ this._refreshView("state");
28562
+ if (!options.silent) this._emitKanbanState("restore");
28563
+ return this;
27755
28564
  }
27756
28565
  /** Restores enhanced targets after base lifecycle cleanup. @returns {void} */
27757
28566
  destroy() {
27758
28567
  if (this._kanbanDestroyed) return;
27759
28568
  this._kanbanDestroyed = true;
28569
+ this._clearDrag();
28570
+ clearTimeout(this._pointerHold);
28571
+ clearTimeout(this._searchTimer);
28572
+ this._kanbanMenu?.destroy();
28573
+ this._kanbanMenu = null;
27760
28574
  const root = this.el;
27761
28575
  const snapshot10 = this._kanbanSnapshot;
27762
28576
  super.destroy();
@@ -27771,25 +28585,61 @@ var zx = (() => {
27771
28585
  }
27772
28586
  if (_reason === "loading") return;
27773
28587
  const options = this._kanbanOptions();
28588
+ const activeElement = document.activeElement;
27774
28589
  const activeMeta = this._metaForTarget(document.activeElement);
27775
28590
  const restoreHandle = Boolean(document.activeElement?.classList?.contains("zx-kanban-view__move-handle"));
28591
+ const activeCollapse = activeElement instanceof Element ? activeElement.closest("[data-kanban-collapse]") : null;
28592
+ const activeSection = activeCollapse?.closest(".zx-kanban-view__column");
28593
+ const activeCollapseFocus = activeCollapse && this._board.contains(activeCollapse) ? {
28594
+ axis: (
28595
+ /** @type {'column'|'swimlane'} */
28596
+ /** @type {HTMLElement} */
28597
+ activeCollapse.dataset.kanbanCollapse
28598
+ ),
28599
+ id: String(
28600
+ /** @type {HTMLElement} */
28601
+ activeCollapse.dataset.kanbanId
28602
+ ),
28603
+ lane: activeSection ? this._sectionMeta.get(activeSection)?.lane ?? null : null
28604
+ } : null;
27776
28605
  const columns = this._resolvedColumns();
27777
28606
  const lanes = this._resolvedSwimlanes();
27778
28607
  this._cardMeta = /* @__PURE__ */ new WeakMap();
27779
28608
  this._sectionMeta = /* @__PURE__ */ new WeakMap();
27780
28609
  this._cardsById = /* @__PURE__ */ new Map();
28610
+ this._resolveVisibility();
27781
28611
  const content = [];
27782
28612
  if (options.swimlaneBy == null) {
27783
28613
  content.push(this._renderColumns(columns, null, false));
27784
28614
  } else {
27785
28615
  lanes.forEach((lane) => content.push(this._renderLane(lane, columns)));
27786
28616
  }
27787
- const shownCards = this._cardsById.size;
27788
- if (shownCards === 0 && !this._viewData.length) content.push(this._emptyContent());
28617
+ if (!this._viewData.length) content.push(this._emptyContent(this._text("empty")));
28618
+ else if (!this._visibleIds.size) content.push(this._emptyContent(this._text("noMatches")));
27789
28619
  this._board.replaceChildren(...content);
27790
- if (this._keyboardMove && !this.getRecord(this._keyboardMove.id)) this._keyboardMove = null;
28620
+ if (this._keyboardMove) {
28621
+ this._keyboardMove.ids = this._keyboardMove.ids.filter((id) => this.getRecord(id));
28622
+ if (!this._keyboardMove.ids.length) this._keyboardMove = null;
28623
+ }
27791
28624
  this._syncKeyboardTarget();
27792
- if (activeMeta) queueMicrotask(() => {
28625
+ if (activeCollapseFocus || activeMeta) queueMicrotask(() => {
28626
+ if (activeCollapseFocus) {
28627
+ this._collapseControl(
28628
+ activeCollapseFocus.axis,
28629
+ activeCollapseFocus.id,
28630
+ activeCollapseFocus.lane
28631
+ )?.focus();
28632
+ return;
28633
+ }
28634
+ if (!activeMeta) return;
28635
+ if (activeMeta.lane != null && this._collapsedSwimlanes.has(activeMeta.lane)) {
28636
+ this._collapseControl("swimlane", activeMeta.lane)?.focus();
28637
+ return;
28638
+ }
28639
+ if (this._collapsedColumns.has(activeMeta.column)) {
28640
+ this._collapseControl("column", activeMeta.column, activeMeta.lane)?.focus();
28641
+ return;
28642
+ }
27793
28643
  const card = this._cardsById.get(activeMeta.id);
27794
28644
  const target = restoreHandle ? card?.querySelector(".zx-kanban-view__move-handle") : card;
27795
28645
  if (target?.isConnected) target.focus();
@@ -27801,21 +28651,36 @@ var zx = (() => {
27801
28651
  const collapsed = this._collapsedSwimlanes.has(lane.id);
27802
28652
  const headingId = uid("zx-kanban-lane");
27803
28653
  const bodyId = uid("zx-kanban-lane-body");
27804
- const count2 = this._viewData.filter((record, index) => normalizeAxisId(this._readAxis(options.swimlaneBy, record, index)) === lane.id).length;
28654
+ const entries = this._viewData.filter((record, index) => normalizeAxisId(this._readAxis(options.swimlaneBy, record, index)) === lane.id);
28655
+ const total = entries.length;
28656
+ const count2 = entries.filter((record) => this._visibleIds.has(this._viewRecordId(record))).length;
27805
28657
  const toggle = h("button", {
27806
28658
  class: "zx-kanban-view__collapse",
27807
28659
  type: "button",
27808
28660
  ariaExpanded: String(!collapsed),
27809
28661
  ariaControls: bodyId,
27810
- ariaLabel: `${collapsed ? "Expand" : "Collapse"} swim lane ${lane.label}`,
28662
+ ariaLabel: this._text(collapsed ? "expandLane" : "collapseLane", { label: lane.label }),
27811
28663
  dataset: { kanbanCollapse: "swimlane", kanbanId: lane.id }
27812
28664
  }, icon(collapsed ? "chevron-right" : "chevron-down", { size: 13 }));
28665
+ const limit = lane.limit ?? null;
28666
+ const wip = wipState(total, limit);
28667
+ const custom = this._renderHook("renderSwimlaneHeader", {
28668
+ swimlane: { ...lane },
28669
+ count: count2,
28670
+ total,
28671
+ columnTotal: total,
28672
+ limit,
28673
+ wip,
28674
+ collapsed
28675
+ });
27813
28676
  const heading = h(
27814
28677
  "h2",
27815
28678
  { class: "zx-kanban-view__lane-heading", id: headingId },
27816
28679
  toggle,
27817
- h("span", {}, lane.label),
27818
- options.showCounts ? h("span", { class: "zx-kanban-view__count", ariaLabel: `${count2} records` }, String(count2)) : null
28680
+ custom ?? [
28681
+ h("span", {}, lane.label),
28682
+ options.showCounts || limit != null ? this._countIndicator(count2, total, limit) : null
28683
+ ]
27819
28684
  );
27820
28685
  const body = this._renderColumns(columns, lane, true);
27821
28686
  body.id = bodyId;
@@ -27823,7 +28688,7 @@ var zx = (() => {
27823
28688
  return h("section", {
27824
28689
  class: "zx-kanban-view__lane",
27825
28690
  ariaLabelledby: headingId,
27826
- dataset: { collapsed: String(collapsed) }
28691
+ dataset: { collapsed: String(collapsed), wip }
27827
28692
  }, heading, body);
27828
28693
  }
27829
28694
  /** @param {KanbanColumn[]} columns @param {KanbanSwimlane|null} lane @param {boolean} hasLanes @returns {HTMLElement} */
@@ -27849,36 +28714,67 @@ var zx = (() => {
27849
28714
  const collapsed = this._collapsedColumns.has(column.id);
27850
28715
  const headingId = uid("zx-kanban-column");
27851
28716
  const listId = uid("zx-kanban-column-list");
27852
- const columnCount = this._viewData.filter((record, index) => normalizeAxisId(this._readAxis(options.columnBy, record, index)) === column.id).length;
27853
- const wip = column.limit == null ? "none" : columnCount > column.limit ? "exceeded" : columnCount === column.limit ? "limit" : "within";
28717
+ const columnEntries = this._viewData.filter((record, index) => normalizeAxisId(this._readAxis(options.columnBy, record, index)) === column.id);
28718
+ const columnTotal = columnEntries.length;
28719
+ const visible = entries.filter((entry) => this._visibleIds.has(this._viewRecordId(entry.record)));
28720
+ const laneLimit = lane ? readColumnLaneLimit(column, lane.id) : null;
28721
+ const limit = laneLimit ?? column.limit ?? null;
28722
+ const wip = wipState(laneLimit == null ? columnTotal : entries.length, limit);
27854
28723
  const toggle = h("button", {
27855
28724
  class: "zx-kanban-view__collapse",
27856
28725
  type: "button",
27857
28726
  ariaExpanded: String(!collapsed),
27858
28727
  ariaControls: listId,
27859
- ariaLabel: `${collapsed ? "Expand" : "Collapse"} column ${column.label}`,
28728
+ ariaLabel: this._text(collapsed ? "expandColumn" : "collapseColumn", { label: column.label }),
27860
28729
  dataset: { kanbanCollapse: "column", kanbanId: column.id }
27861
28730
  }, icon(collapsed ? "chevron-right" : "chevron-down", { size: 13 }));
27862
- const indicator = options.showCounts || column.limit != null ? h("span", {
27863
- class: "zx-kanban-view__count",
27864
- ariaLabel: column.limit == null ? `${entries.length} records` : hasLanes ? `${entries.length} records in this lane, ${columnCount} of work-in-progress limit ${column.limit} in the column` : `${columnCount} records, work-in-progress limit ${column.limit}`
27865
- }, column.limit == null ? String(entries.length) : hasLanes ? `${entries.length} \xB7 ${columnCount} / ${column.limit}` : `${columnCount} / ${column.limit}`) : null;
28731
+ const custom = this._renderHook("renderColumnHeader", {
28732
+ column: { ...column },
28733
+ swimlane: lane ? { ...lane } : null,
28734
+ count: visible.length,
28735
+ total: entries.length,
28736
+ columnTotal,
28737
+ limit,
28738
+ wip,
28739
+ collapsed
28740
+ });
28741
+ const indicator = options.showCounts || limit != null ? this._columnIndicator(visible.length, entries.length, columnTotal, limit, hasLanes && laneLimit == null) : null;
27866
28742
  const heading = h(hasLanes ? "h3" : "h2", {
27867
28743
  class: "zx-kanban-view__column-heading",
27868
28744
  id: headingId
27869
- }, toggle, h("span", { class: "zx-kanban-view__column-label" }, column.label), indicator);
28745
+ }, toggle, custom ?? [h("span", { class: "zx-kanban-view__column-label" }, column.label), indicator]);
27870
28746
  const list = h("ul", {
27871
28747
  class: "zx-kanban-view__cards",
27872
28748
  id: listId,
27873
28749
  ariaLabel: `${column.label}${lane ? `, ${lane.label}` : ""}`
27874
28750
  });
27875
- entries.forEach((entry) => list.append(this._renderCard(entry.record, entry.index, column.id, lane?.id ?? null, hasLanes)));
28751
+ visible.forEach((entry) => list.append(this._renderCard(entry.record, entry.index, column.id, lane?.id ?? null, hasLanes)));
28752
+ if (!visible.length) {
28753
+ const placeholder = this._renderHook("renderColumnEmpty", {
28754
+ column: { ...column },
28755
+ swimlane: lane ? { ...lane } : null,
28756
+ count: 0,
28757
+ total: entries.length,
28758
+ columnTotal,
28759
+ limit,
28760
+ wip,
28761
+ collapsed
28762
+ }) ?? this._placeholderText();
28763
+ if (placeholder) list.append(h("li", { class: "zx-kanban-view__column-empty", role: "presentation" }, placeholder));
28764
+ }
27876
28765
  list.hidden = collapsed;
28766
+ const add = this._allowsAdd(column, lane) ? h("button", {
28767
+ class: "zx-kanban-view__add",
28768
+ type: "button",
28769
+ ariaLabel: this._text("addTo", { target: `${column.label}${lane ? `, ${lane.label}` : ""}` }),
28770
+ dataset: { kanbanAdd: column.id, kanbanLane: lane?.id ?? "" }
28771
+ }, icon("plus", { size: 13 }), h("span", {}, this._text("add"))) : null;
28772
+ if (add) add.hidden = collapsed;
27877
28773
  const section = h("section", {
27878
28774
  class: "zx-kanban-view__column",
27879
28775
  ariaLabelledby: headingId,
27880
28776
  dataset: { column: column.id, collapsed: String(collapsed), wip }
27881
- }, heading, list);
28777
+ }, heading, list, add);
27882
28778
  this._sectionMeta.set(section, { column: column.id, lane: lane?.id ?? null, list });
27883
28779
  return section;
27884
28780
  }
@@ -27886,13 +28782,14 @@ var zx = (() => {
27886
28782
  _renderCard(record, index, column, lane, hasLanes) {
27887
28783
  const options = this._kanbanOptions();
27888
28784
  const id = this._viewRecordId(record);
27889
- const grabbed = Boolean(this._keyboardMove && Object.is(this._keyboardMove.id, id));
28785
+ const grabbed = Boolean(this._keyboardMove?.ids.some((candidate) => Object.is(candidate, id)));
28786
+ const rules = resolveKanbanRules(this._kanbanRules, record, { index, column, lane });
28787
+ const multiple = grabbed && this._keyboardMove ? this._keyboardMove.ids.length : 1;
27890
28788
  const handle = h("button", {
27891
28789
  class: "zx-kanban-view__move-handle",
27892
28790
  type: "button",
27893
- draggable: true,
27894
28791
  ariaPressed: String(grabbed),
27895
- ariaLabel: `Move ${this._recordName(record, index)}`
28792
+ ariaLabel: multiple > 1 ? this._text("moveMultiple", { count: multiple }) : this._text("move", { name: this._recordName(record, index) })
27896
28793
  }, icon("drag", { size: 13 }));
27897
28794
  const card = createRecordCard(record, index, {
27898
28795
  fields: this.getFields(),
@@ -27911,15 +28808,81 @@ var zx = (() => {
27911
28808
  });
27912
28809
  card.tabIndex = 0;
27913
28810
  if (grabbed) card.dataset.grabbed = "true";
28811
+ this._applyCardContent(card, { record, id, index, column, lane, rules, selected: this._isViewSelected(id) });
28812
+ this._applyCardRules(card, rules);
28813
+ this._applyCardDescription(card, id);
27914
28814
  const meta = { record, id, index, column, lane };
27915
28815
  this._cardMeta.set(card, meta);
27916
28816
  this._cardsById.set(id, card);
27917
28817
  return card;
27918
28818
  }
27919
- /** @returns {HTMLElement} */
27920
- _emptyContent() {
28819
+ /**
28820
+ * Hands card content to `renderCard` while keeping the managed shell: the list item, its
28821
+ * selection control, the action group, and the move handle are never given away, because they
28822
+ * carry the drop, focus, and movement semantics the board is responsible for.
28823
+ * @param {HTMLElement} card Card built from the shared anatomy.
28824
+ * @param {KanbanCardContext} context Card context.
28825
+ * @returns {void}
28826
+ */
28827
+ _applyCardContent(card, context) {
28828
+ const custom = this._renderHook("renderCard", context);
28829
+ if (!custom) return;
28830
+ const body = card.querySelector(".zx-record-card__body");
28831
+ const titles = card.querySelector(".zx-record-card__titles");
28832
+ if (!body || !titles) return;
28833
+ titles.replaceChildren(custom);
28834
+ body.querySelector(".zx-record-card__eyebrow")?.remove();
28835
+ body.querySelector(".zx-record-card__metadata")?.remove();
28836
+ card.removeAttribute("aria-labelledby");
28837
+ card.setAttribute("aria-label", this._recordName(context.record, context.index));
28838
+ card.dataset.customCard = "true";
28839
+ }
28840
+ /**
28841
+ * Applies ordered card rules. The first match owns the marker tone; every match with a label adds
28842
+ * a badge, and rule descriptions join the accessible description so the marker is never the only
28843
+ * carrier of the meaning.
28844
+ * @param {HTMLElement} card Card element.
28845
+ * @param {KanbanRule[]} rules Matching rules.
28846
+ * @returns {void}
28847
+ */
28848
+ _applyCardRules(card, rules) {
28849
+ if (!rules.length) return;
28850
+ card.dataset.rule = rules.map((rule) => rule.id).join(" ");
28851
+ card.dataset.ruleTone = rules[0].tone;
28852
+ const badges = rules.filter((rule) => rule.label);
28853
+ if (badges.length) {
28854
+ const list = h("p", { class: "zx-kanban-view__rules" }, badges.map((rule) => badge({
28855
+ label: rule.label,
28856
+ icon: rule.icon ?? void 0,
28857
+ kind: rule.tone,
28858
+ size: "sm",
28859
+ variant: "soft"
28860
+ })));
28861
+ card.querySelector(".zx-record-card__body")?.append(list);
28862
+ }
28863
+ const descriptions = rules.map((rule) => rule.description).filter(Boolean).join(". ");
28864
+ if (descriptions) card.dataset.ruleDescription = descriptions;
28865
+ }
28866
+ /**
28867
+ * Composes one card's accessible description from its selection state and its rules, so that
28868
+ * refreshing selection cannot quietly drop what a rule contributed.
28869
+ * @param {HTMLElement} card Card element.
28870
+ * @param {unknown} id Record id.
28871
+ * @returns {void}
28872
+ */
28873
+ _applyCardDescription(card, id) {
28874
+ const parts = [];
28875
+ if (this.options.selectable) {
28876
+ parts.push(this._text(this._isViewSelected(id) ? "selected" : "notSelected"));
28877
+ }
28878
+ if (card.dataset.ruleDescription) parts.push(card.dataset.ruleDescription);
28879
+ if (parts.length) card.setAttribute("aria-description", parts.join(". "));
28880
+ else card.removeAttribute("aria-description");
28881
+ }
28882
+ /** @param {string} text Board-level empty message. @returns {HTMLElement} */
28883
+ _emptyContent(text4) {
27921
28884
  const supplied = typeof this.options.emptyText === "function" ? this.options.emptyText() : this.options.emptyText;
27922
- const content = supplied ?? "No records";
28885
+ const content = supplied ?? text4;
27923
28886
  return h(
27924
28887
  "div",
27925
28888
  { class: "zx-kanban-view__empty", role: "status" },
@@ -27929,6 +28892,37 @@ var zx = (() => {
27929
28892
  ) : String(content)
27930
28893
  );
27931
28894
  }
28895
+ /** @returns {string} Per-column placeholder text, or an empty string for none. */
28896
+ _placeholderText() {
28897
+ return this._text("columnEmpty");
28898
+ }
28899
+ /**
28900
+ * @param {number} count Visible records.
28901
+ * @param {number} total Records before search and filters.
28902
+ * @param {number|null} limit Applicable limit.
28903
+ * @returns {HTMLElement}
28904
+ */
28905
+ _countIndicator(count2, total, limit) {
28906
+ const filtered = count2 !== total;
28907
+ return h("span", {
28908
+ class: "zx-kanban-view__count",
28909
+ ariaLabel: limit != null ? this._text("countLimit", { count: total, limit }) : filtered ? this._text("countFiltered", { count: count2, total }) : this._text("count", { count: count2 })
28910
+ }, limit != null ? `${total} / ${limit}` : filtered ? `${count2} / ${total}` : String(count2));
28911
+ }
28912
+ /**
28913
+ * @param {number} count Visible records in this column and lane.
28914
+ * @param {number} total Records in this column and lane before search and filters.
28915
+ * @param {number} columnTotal Records across the whole column.
28916
+ * @param {number|null} limit Applicable limit.
28917
+ * @param {boolean} columnScoped Whether the limit counts the column rather than this lane.
28918
+ * @returns {HTMLElement}
28919
+ */
28920
+ _columnIndicator(count2, total, columnTotal, limit, columnScoped) {
28921
+ const filtered = count2 !== total;
28922
+ const label = limit == null ? filtered ? this._text("countFiltered", { count: count2, total }) : this._text("count", { count: count2 }) : columnScoped ? this._text("countLimitInLane", { count: total, total: columnTotal, limit }) : this._text("countLimit", { count: total, limit });
28923
+ const text4 = limit == null ? filtered ? `${count2} / ${total}` : String(count2) : columnScoped ? `${total} \xB7 ${columnTotal} / ${limit}` : `${total} / ${limit}`;
28924
+ return h("span", { class: "zx-kanban-view__count", ariaLabel: label }, text4);
28925
+ }
27932
28926
  /** @returns {KanbanColumn[]} */
27933
28927
  _rawColumns() {
27934
28928
  const options = this._kanbanOptions();
@@ -27937,7 +28931,11 @@ var zx = (() => {
27937
28931
  /** @returns {KanbanColumn[]} */
27938
28932
  _resolvedColumns() {
27939
28933
  const columns = this._rawColumns();
27940
- this._kanbanColumnOrder = reconcileKanbanOrder(columns, this._kanbanColumnOrder);
28934
+ this._kanbanColumnOrder = reconcileKanbanPreference(
28935
+ columns,
28936
+ this._kanbanColumnOrder,
28937
+ this._kanbanColumns === null
28938
+ );
27941
28939
  return orderDescriptors(columns, this._kanbanColumnOrder);
27942
28940
  }
27943
28941
  /** @returns {KanbanSwimlane[]} */
@@ -27949,7 +28947,12 @@ var zx = (() => {
27949
28947
  /** @returns {KanbanSwimlane[]} */
27950
28948
  _resolvedSwimlanes() {
27951
28949
  const lanes = this._rawSwimlanes();
27952
- this._kanbanSwimlaneOrder = reconcileKanbanOrder(lanes, this._kanbanSwimlaneOrder);
28950
+ const preserveUnknown = this._kanbanSwimlanes === null && this._kanbanOptions().swimlaneBy != null;
28951
+ this._kanbanSwimlaneOrder = reconcileKanbanPreference(
28952
+ lanes,
28953
+ this._kanbanSwimlaneOrder,
28954
+ preserveUnknown
28955
+ );
27953
28956
  return orderDescriptors(lanes, this._kanbanSwimlaneOrder);
27954
28957
  }
27955
28958
  /** @param {KanbanAccessor} accessor @param {KanbanRecord} record @param {number} index @returns {unknown} */
@@ -27973,13 +28976,84 @@ var zx = (() => {
27973
28976
  /** @param {unknown} id @returns {KanbanMovePoint|null} */
27974
28977
  _recordLocation(id) {
27975
28978
  const options = this._kanbanOptions();
27976
- const index = this._viewData.findIndex((record2) => Object.is(this._viewRecordId(record2), id));
28979
+ const index = this._viewData.findIndex((record) => Object.is(this._viewRecordId(record), id));
27977
28980
  if (index < 0) return null;
27978
- const record = this._viewData[index];
27979
- const column = normalizeAxisId(this._readAxis(options.columnBy, record, index));
27980
- const lane = options.swimlaneBy == null ? null : normalizeAxisId(this._readAxis(options.swimlaneBy, record, index));
27981
- const within = this._recordsIn(column, lane).findIndex((entry) => Object.is(this._viewRecordId(entry.record), id));
27982
- return { column, lane, index: within };
28981
+ return locateKanbanRecord(this._viewData, index, {
28982
+ recordId: options.recordId,
28983
+ columnBy: (record, recordIndex) => this._readAxis(options.columnBy, record, recordIndex),
28984
+ swimlaneBy: options.swimlaneBy == null ? null : (record, recordIndex) => this._readAxis(options.swimlaneBy, record, recordIndex)
28985
+ });
28986
+ }
28987
+ /**
28988
+ * Recomputes which records survive the search and the filter predicate.
28989
+ * @returns {void}
28990
+ */
28991
+ _resolveVisibility() {
28992
+ const terms = kanbanSearchTerms(this._kanbanSearch);
28993
+ const filter = this._kanbanFilter;
28994
+ const sources = terms.length ? this._searchSources() : null;
28995
+ this._visibleIds = /* @__PURE__ */ new Set();
28996
+ this._viewData.forEach((record, index) => {
28997
+ if (filter && filter(record, index) !== true) return;
28998
+ if (sources && !matchesKanbanSearch(this._searchableText(record, index, sources), terms)) return;
28999
+ this._visibleIds.add(this._viewRecordId(record));
29000
+ });
29001
+ }
29002
+ /**
29003
+ * Resolves what a search looks at, once. `searchFields` names them explicitly; otherwise the
29004
+ * visible fields are searched, plus the title and subtitle sources — a card is findable by the
29005
+ * name it shows even when that field is hidden from the metadata list.
29006
+ * @returns {{fields:import('../view/record-view.js').ViewField[],readers:((record:KanbanRecord,index:number)=>unknown)[]}}
29007
+ */
29008
+ _searchSources() {
29009
+ const options = this._kanbanOptions();
29010
+ const configured = Array.isArray(options.searchFields) ? options.searchFields.map(String) : null;
29011
+ const all = this.getFields();
29012
+ if (configured) return { fields: all.filter((field) => configured.includes(field.id)), readers: [] };
29013
+ const fields = this.getVisibleFields();
29014
+ const known = new Set(fields.map((field) => field.id));
29015
+ const readers = [];
29016
+ for (const source of [options.titleField, options.subtitleField]) {
29017
+ if (source == null) continue;
29018
+ if (typeof source === "function") {
29019
+ readers.push(source);
29020
+ continue;
29021
+ }
29022
+ if (known.has(source)) continue;
29023
+ const field = all.find((candidate) => candidate.id === source);
29024
+ if (field) fields.push(field);
29025
+ else readers.push((record) => record?.[source]);
29026
+ }
29027
+ return { fields, readers };
29028
+ }
29029
+ /**
29030
+ * Builds the text one record is searched through. Field `render` output is deliberately ignored:
29031
+ * it may be a DOM node, and matching what a formatter produced rather than what the record holds
29032
+ * would make the same query behave differently in two views over the same data.
29033
+ * @param {KanbanRecord} record Record.
29034
+ * @param {number} index Display index.
29035
+ * @param {{fields:import('../view/record-view.js').ViewField[],readers:((record:KanbanRecord,index:number)=>unknown)[]}} sources Resolved sources.
29036
+ * @returns {string} Searchable text.
29037
+ */
29038
+ _searchableText(record, index, sources) {
29039
+ const parts = sources.fields.map((field) => stringifyValue(readViewField(field, record, index)));
29040
+ for (const reader of sources.readers) parts.push(stringifyValue(reader(record, index)));
29041
+ return parts.filter(Boolean).join(" ");
29042
+ }
29043
+ /**
29044
+ * Translates a position among rendered cards into a position among all records in one bucket.
29045
+ * @param {string} column Column id.
29046
+ * @param {string|null} lane Lane id.
29047
+ * @param {number} visibleIndex Position among visible cards.
29048
+ * @param {Set<unknown>} excluded Ids being moved, which the visible list already omits.
29049
+ * @returns {number} Absolute position.
29050
+ */
29051
+ _absoluteIndex(column, lane, visibleIndex, excluded) {
29052
+ const entries = this._recordsIn(column, lane).filter((entry) => !excluded.has(this._viewRecordId(entry.record)));
29053
+ return absoluteKanbanIndex(
29054
+ entries.map((entry) => this._visibleIds.has(this._viewRecordId(entry.record))),
29055
+ visibleIndex
29056
+ );
27983
29057
  }
27984
29058
  /**
27985
29059
  * @param {KanbanRecord} record @param {KanbanMovePoint} from @param {KanbanMovePoint} to
@@ -28001,8 +29075,327 @@ var zx = (() => {
28001
29075
  if (options.swimlaneBy != null && normalizeAxisId(this._readAxis(options.swimlaneBy, clone, currentIndex)) !== to.lane) return null;
28002
29076
  return clone;
28003
29077
  }
29078
+ /**
29079
+ * Runs transition, eligibility, and capacity policy for a whole move. Every distinct origin is
29080
+ * checked, so a multi-card move drawn from two columns cannot slip past a transition rule that
29081
+ * only the second origin violates.
29082
+ * @param {KanbanRecord[]} records Moving records.
29083
+ * @param {KanbanMovePoint[]} origins Their origins.
29084
+ * @param {KanbanColumn} column Destination column.
29085
+ * @param {KanbanSwimlane|null} lane Destination lane.
29086
+ * @param {KanbanMovePoint} to Destination point.
29087
+ * @param {KanbanMoveContext} context Predicate context.
29088
+ * @param {Set<unknown>} excluded Moving ids, excluded from destination counts.
29089
+ * @returns {KanbanMoveEvaluation} Decision.
29090
+ */
29091
+ _evaluateMove(records, origins, column, lane, to, context, excluded) {
29092
+ const options = this._kanbanOptions();
29093
+ for (const origin of origins) {
29094
+ const blocked = allowsKanbanTransition(column, lane, origin);
29095
+ if (blocked) {
29096
+ return {
29097
+ allowed: false,
29098
+ reason: blocked,
29099
+ limit: null,
29100
+ count: 0,
29101
+ limitExceeded: false,
29102
+ limitReached: false,
29103
+ policy: column.wipPolicy ?? (options.wipPolicy === "block" ? "block" : "warn")
29104
+ };
29105
+ }
29106
+ }
29107
+ const excludes = (record) => !excluded.has(this._viewRecordId(record));
29108
+ const columnCount = this._viewData.filter((record, index) => normalizeAxisId(this._readAxis(options.columnBy, record, index)) === to.column && excludes(record)).length;
29109
+ const laneCount = options.swimlaneBy == null ? 0 : this._viewData.filter((record, index) => normalizeAxisId(this._readAxis(options.swimlaneBy, record, index)) === to.lane && excludes(record)).length;
29110
+ const cellCount = this._recordsIn(to.column, to.lane).filter((entry) => excludes(entry.record)).length;
29111
+ return evaluateKanbanMove({
29112
+ records,
29113
+ column,
29114
+ swimlane: lane,
29115
+ context,
29116
+ from: origins[0],
29117
+ columnCount,
29118
+ laneCount,
29119
+ cellCount,
29120
+ policy: options.wipPolicy === "block" ? "block" : "warn"
29121
+ });
29122
+ }
29123
+ /**
29124
+ * Announces and reports a refused move. Nothing is written and no `recordmove` is emitted, so a
29125
+ * listener can neither observe nor undo a move that never happened.
29126
+ * @param {KanbanRejectReason} reason Refusal reason.
29127
+ * @param {{ids:unknown[],records:KanbanRecord[],from:KanbanMovePoint|null,to:KanbanMovePoint|null,column:KanbanColumn|null,swimlane:KanbanSwimlane|null,evaluation?:KanbanMoveEvaluation}} detail Context.
29128
+ * @returns {void}
29129
+ * @fires KanbanView#movereject
29130
+ */
29131
+ _rejectMove(reason, detail) {
29132
+ const target = detail.to ? this._targetName(detail.to) : "";
29133
+ const name = this._movingName(detail.records);
29134
+ const message = reason === "destination" ? this._text("rejectDestination") : reason === "grouping" ? this._text("rejectGrouping") : reason === "transition" || reason === "lane-transition" ? this._text("rejectTransition", { target, origin: detail.from ? this._targetName(detail.from) : "" }) : reason === "wip" || reason === "lane-wip" ? this._text("rejectWip", { target, limit: detail.evaluation?.limit ?? "" }) : this._text("rejectAccept", { target, name });
29135
+ this._announce(message);
29136
+ this.emit("movereject", {
29137
+ reason,
29138
+ ids: [...detail.ids],
29139
+ records: [...detail.records],
29140
+ record: detail.records[0] ?? null,
29141
+ id: detail.ids[0],
29142
+ from: detail.from ? { ...detail.from } : null,
29143
+ to: detail.to ? { ...detail.to } : null,
29144
+ column: detail.column ? { ...detail.column } : null,
29145
+ swimlane: detail.swimlane ? { ...detail.swimlane } : null,
29146
+ limit: detail.evaluation?.limit ?? null,
29147
+ count: detail.evaluation?.count ?? 0,
29148
+ message
29149
+ });
29150
+ }
29151
+ /**
29152
+ * Installs a committed local record order, remembering the previous one for undo.
29153
+ * @param {KanbanRecord[]} records Next record order.
29154
+ * @returns {void}
29155
+ */
29156
+ _commitLocalRecords(records) {
29157
+ const options = this._kanbanOptions();
29158
+ if (options.history !== false) {
29159
+ this._kanbanHistory.push({ records: [...this._viewData], selection: this.getSelectionIds() });
29160
+ this._syncHistoryControls();
29161
+ }
29162
+ this._viewData = records;
29163
+ if (options.sortMode === "local" && this.getSort()) this.setSort(null);
29164
+ else this._refreshView("move");
29165
+ this.emit("datachange", { records: this.getData() });
29166
+ if (options.history !== false) this._emitHistory();
29167
+ }
29168
+ /**
29169
+ * @param {'undo'|'redo'} direction Travel direction.
29170
+ * @returns {this}
29171
+ */
29172
+ _travelHistory(direction) {
29173
+ if (this._kanbanOptions().history === false) return this;
29174
+ const current = { records: [...this._viewData], selection: this.getSelectionIds() };
29175
+ const entry = direction === "undo" ? this._kanbanHistory.undo(current) : this._kanbanHistory.redo(current);
29176
+ if (!entry) {
29177
+ this._announce(this._text(direction === "undo" ? "nothingToUndo" : "nothingToRedo"));
29178
+ return this;
29179
+ }
29180
+ this._viewData = [...entry.records];
29181
+ this._refreshView(direction);
29182
+ this.setSelection(entry.selection);
29183
+ this._syncHistoryControls();
29184
+ this.emit("datachange", { records: this.getData() });
29185
+ this._emitHistory();
29186
+ this._announce(this._text(direction === "undo" ? "undone" : "redone"));
29187
+ return this;
29188
+ }
29189
+ /** Drops both stacks without announcing, for host-driven data replacement. @returns {void} */
29190
+ _resetHistory() {
29191
+ if (!this._kanbanHistory) return;
29192
+ const had = this._kanbanHistory.canUndo() || this._kanbanHistory.canRedo();
29193
+ this._kanbanHistory.clear();
29194
+ this._syncHistoryControls();
29195
+ if (had) this._emitHistory();
29196
+ }
29197
+ /** @returns {void} */
29198
+ _emitHistory() {
29199
+ this.emit("historychange", {
29200
+ canUndo: this.canUndo(),
29201
+ canRedo: this.canRedo(),
29202
+ depth: this._kanbanHistory.depth()
29203
+ });
29204
+ }
29205
+ /** @returns {void} */
29206
+ _syncHistoryControls() {
29207
+ if (this._undoButton) this._undoButton.disabled = !this.canUndo();
29208
+ if (this._redoButton) this._redoButton.disabled = !this.canRedo();
29209
+ }
29210
+ /** @returns {HTMLElement} Built-in search control. */
29211
+ _createSearchControl() {
29212
+ const input = (
29213
+ /** @type {HTMLInputElement} */
29214
+ h("input", {
29215
+ class: "zx-kanban-view__search-input",
29216
+ type: "search",
29217
+ value: this._kanbanSearch,
29218
+ ariaLabel: this._text("search"),
29219
+ placeholder: this._text("searchPlaceholder")
29220
+ })
29221
+ );
29222
+ this._searchInput = input;
29223
+ this.listen(input, "input", () => {
29224
+ clearTimeout(this._searchTimer);
29225
+ this._searchTimer = setTimeout(() => {
29226
+ this._searchTimer = 0;
29227
+ if (!this._kanbanDestroyed) this.setSearch(input.value);
29228
+ }, SEARCH_DEBOUNCE);
29229
+ });
29230
+ this.listen(input, "keydown", (event) => {
29231
+ if (
29232
+ /** @type {KeyboardEvent} */
29233
+ event.key !== "Escape" || !input.value
29234
+ ) return;
29235
+ event.stopPropagation();
29236
+ clearTimeout(this._searchTimer);
29237
+ this._searchTimer = 0;
29238
+ this.setSearch("");
29239
+ });
29240
+ return h("div", { class: "zx-kanban-view__search" }, icon("search", { size: 13 }), input);
29241
+ }
29242
+ /** @returns {HTMLElement} Built-in history controls. */
29243
+ _createHistoryControls() {
29244
+ this._undoButton = /** @type {HTMLButtonElement} */
29245
+ h("button", {
29246
+ class: "zx-button zx-button--sm",
29247
+ type: "button",
29248
+ disabled: true,
29249
+ dataset: { kanbanHistory: "undo" }
29250
+ }, this._text("undo"));
29251
+ this._redoButton = /** @type {HTMLButtonElement} */
29252
+ h("button", {
29253
+ class: "zx-button zx-button--sm",
29254
+ type: "button",
29255
+ disabled: true,
29256
+ dataset: { kanbanHistory: "redo" }
29257
+ }, this._text("redo"));
29258
+ const group = h(
29259
+ "div",
29260
+ { class: "zx-kanban-view__history", role: "group" },
29261
+ this._undoButton,
29262
+ this._redoButton
29263
+ );
29264
+ this.listen(group, "click", (event) => {
29265
+ const control = (
29266
+ /** @type {Element|null} */
29267
+ event.target?.closest?.("[data-kanban-history]")
29268
+ );
29269
+ if (!control) return;
29270
+ if (
29271
+ /** @type {HTMLElement} */
29272
+ control.dataset.kanbanHistory === "undo"
29273
+ ) this.undo();
29274
+ else this.redo();
29275
+ });
29276
+ return group;
29277
+ }
29278
+ /** Attaches the card context menu. @returns {void} */
29279
+ _createContextMenu() {
29280
+ this._kanbanMenu = new ContextMenu(this._board, {
29281
+ selector: ".zx-record-card",
29282
+ items: (context) => this._menuItems(context),
29283
+ onselect: ({ detail }) => this._handleMenuSelect(detail)
29284
+ });
29285
+ }
29286
+ /**
29287
+ * Builds the menu for one card: every destination the same policy path would accept, plus any
29288
+ * application items. Destinations the board would refuse are shown disabled rather than hidden,
29289
+ * so a rule is discoverable instead of silently removing a command.
29290
+ * @param {Element|null} context Card element the menu opened on.
29291
+ * @returns {(MenuItem|'-')[]} Menu entries.
29292
+ */
29293
+ _menuItems(context) {
29294
+ const meta = context ? this._cardMeta.get(context) : null;
29295
+ if (!meta) return [];
29296
+ const options = this._kanbanOptions();
29297
+ const ids = this._movingIds(meta.id);
29298
+ const items = [];
29299
+ for (const column of this._resolvedColumns()) {
29300
+ if (column.id === meta.column) continue;
29301
+ items.push({
29302
+ label: this._text("menuMoveTo", { target: column.label }),
29303
+ value: { kind: "column", id: column.id, ids },
29304
+ disabled: !this._canMove(ids, { column: column.id })
29305
+ });
29306
+ }
29307
+ if (options.swimlaneBy != null) {
29308
+ for (const lane of this._resolvedSwimlanes()) {
29309
+ if (lane.id === meta.lane) continue;
29310
+ items.push({
29311
+ label: this._text("menuMoveToLane", { target: lane.label }),
29312
+ value: { kind: "lane", id: lane.id, ids },
29313
+ disabled: !this._canMove(ids, { lane: lane.id })
29314
+ });
29315
+ }
29316
+ }
29317
+ const extra = typeof options.contextMenu === "function" ? options.contextMenu({
29318
+ record: meta.record,
29319
+ id: meta.id,
29320
+ index: meta.index,
29321
+ column: meta.column,
29322
+ lane: meta.lane,
29323
+ rules: resolveKanbanRules(this._kanbanRules, meta.record, meta),
29324
+ selected: this._isViewSelected(meta.id)
29325
+ }) : Array.isArray(options.contextMenu) ? options.contextMenu : [];
29326
+ if (Array.isArray(extra) && extra.length) items.push("-", ...extra);
29327
+ return items;
29328
+ }
29329
+ /**
29330
+ * @param {{value:unknown,item:MenuItem,context:Element|null}} detail Selection detail.
29331
+ * @returns {void}
29332
+ */
29333
+ _handleMenuSelect(detail) {
29334
+ const value = (
29335
+ /** @type {{kind?:string,id?:string,ids?:unknown[]}|null} */
29336
+ detail.value
29337
+ );
29338
+ if (!value || typeof value !== "object" || !value.kind || !Array.isArray(value.ids)) return;
29339
+ this.moveRecords(value.ids, value.kind === "lane" ? { lane: value.id } : { column: value.id });
29340
+ }
29341
+ /**
29342
+ * Dry-runs the policy path for a destination without emitting or committing anything.
29343
+ * @param {unknown[]} ids Moving ids.
29344
+ * @param {KanbanDestination} destination Destination.
29345
+ * @returns {boolean} Whether the move would be allowed.
29346
+ */
29347
+ _canMove(ids, destination) {
29348
+ const options = this._kanbanOptions();
29349
+ const moving = this._orderedIds(ids);
29350
+ if (!moving.length) return false;
29351
+ const records = moving.map((id) => (
29352
+ /** @type {KanbanRecord} */
29353
+ this.getRecord(id)
29354
+ ));
29355
+ const origins = moving.map((id) => (
29356
+ /** @type {KanbanMovePoint} */
29357
+ this._recordLocation(id)
29358
+ ));
29359
+ const from = origins[0];
29360
+ const columnId = destination.column == null ? from.column : String(destination.column);
29361
+ const laneId = options.swimlaneBy == null ? null : destination.lane === void 0 ? from.lane : normalizeAxisId(destination.lane);
29362
+ const column = this._resolvedColumns().find((candidate) => candidate.id === columnId);
29363
+ const lane = options.swimlaneBy == null ? null : this._resolvedSwimlanes().find((candidate) => candidate.id === laneId) ?? null;
29364
+ if (!column || options.swimlaneBy != null && !lane) return false;
29365
+ const excluded = new Set(moving);
29366
+ const to = { column: columnId, lane: laneId, index: 0 };
29367
+ const context = {
29368
+ id: moving[0],
29369
+ from: { ...from },
29370
+ to: { ...to },
29371
+ column: { ...column },
29372
+ swimlane: lane ? { ...lane } : null
29373
+ };
29374
+ return this._evaluateMove(records, origins, column, lane, to, context, excluded).allowed;
29375
+ }
29376
+ /**
29377
+ * Resolves the ids one gesture moves: the whole selection when the grabbed card belongs to it,
29378
+ * otherwise just that card. Picking up an unselected card never drags the selection along.
29379
+ * @param {unknown} id Grabbed record id.
29380
+ * @returns {unknown[]} Ids to move, in board order.
29381
+ */
29382
+ _movingIds(id) {
29383
+ const options = this._kanbanOptions();
29384
+ if (options.multiMove === false || options.selectable !== "multi" || !this._isViewSelected(id)) return [id];
29385
+ const selection = this.getSelectionIds();
29386
+ return selection.length > 1 ? selection : [id];
29387
+ }
29388
+ /** @param {unknown[]} ids Requested ids. @returns {unknown[]} Existing ids in board order. */
29389
+ _orderedIds(ids) {
29390
+ const requested = new Set(ids);
29391
+ return this._viewData.map((record) => this._viewRecordId(record)).filter((id) => requested.has(id));
29392
+ }
28004
29393
  /** @param {MouseEvent} event @returns {void} */
28005
29394
  _handleBoardClick(event) {
29395
+ if (this._suppressClick) {
29396
+ this._suppressClick = false;
29397
+ return;
29398
+ }
28006
29399
  const target = elementTarget(event);
28007
29400
  const collapse = target?.closest("[data-kanban-collapse]");
28008
29401
  if (collapse) {
@@ -28014,6 +29407,23 @@ var zx = (() => {
28014
29407
  else this.toggleSwimlane(String(element.dataset.kanbanId));
28015
29408
  return;
28016
29409
  }
29410
+ const add = target?.closest("[data-kanban-add]");
29411
+ if (add) {
29412
+ const element = (
29413
+ /** @type {HTMLElement} */
29414
+ add
29415
+ );
29416
+ const columnId = String(element.dataset.kanbanAdd);
29417
+ const laneId = element.dataset.kanbanLane || null;
29418
+ this.emit("recordadd", {
29419
+ column: this._resolvedColumns().find((candidate) => candidate.id === columnId) ?? null,
29420
+ swimlane: laneId == null ? null : this._resolvedSwimlanes().find((candidate) => candidate.id === laneId) ?? null,
29421
+ columnId,
29422
+ laneId,
29423
+ event
29424
+ });
29425
+ return;
29426
+ }
28017
29427
  const meta = this._metaForTarget(target);
28018
29428
  if (!meta) return;
28019
29429
  const selection = (
@@ -28062,7 +29472,14 @@ var zx = (() => {
28062
29472
  }
28063
29473
  /** @param {KeyboardEvent} event @returns {void} */
28064
29474
  _handleBoardKeydown(event) {
29475
+ if (this._dragIds.length && event.key === "Escape") {
29476
+ event.preventDefault();
29477
+ this._clearDrag();
29478
+ this._announce(this._text("moveCanceledPlain"));
29479
+ return;
29480
+ }
28065
29481
  const target = elementTarget(event);
29482
+ if (this._handleHistoryKeydown(event, target)) return;
28066
29483
  const handle = target?.closest(".zx-kanban-view__move-handle");
28067
29484
  const meta = this._metaForTarget(handle);
28068
29485
  if (handle && meta) {
@@ -28086,12 +29503,33 @@ var zx = (() => {
28086
29503
  this.toggleSelection(cardMeta.id);
28087
29504
  }
28088
29505
  }
29506
+ /**
29507
+ * @param {KeyboardEvent} event Keyboard event.
29508
+ * @param {Element|null} target Event target.
29509
+ * @returns {boolean} Whether the event was handled as a history command.
29510
+ */
29511
+ _handleHistoryKeydown(event, target) {
29512
+ if (this._kanbanOptions().history === false || !(event.ctrlKey || event.metaKey)) return false;
29513
+ if (target?.closest('input, textarea, [contenteditable="true"]')) return false;
29514
+ const key = event.key.toLowerCase();
29515
+ if (key === "z" && !event.shiftKey && this.canUndo()) {
29516
+ event.preventDefault();
29517
+ this.undo();
29518
+ return true;
29519
+ }
29520
+ if ((key === "y" || key === "z" && event.shiftKey) && this.canRedo()) {
29521
+ event.preventDefault();
29522
+ this.redo();
29523
+ return true;
29524
+ }
29525
+ return false;
29526
+ }
28089
29527
  /** @param {KeyboardEvent} event @param {HTMLElement} handle @param {KanbanCardMeta} meta @returns {void} */
28090
29528
  _handleMoveKeydown(event, handle, meta) {
28091
29529
  if (event.key === " " || event.key === "Enter") {
28092
29530
  event.preventDefault();
28093
- if (this._keyboardMove && Object.is(this._keyboardMove.id, meta.id)) {
28094
- const destination = { ...this._keyboardMove.to };
29531
+ if (this._keyboardMove?.ids.some((candidate) => Object.is(candidate, meta.id))) {
29532
+ const move2 = this._keyboardMove;
28095
29533
  this._keyboardMove = null;
28096
29534
  handle.setAttribute("aria-pressed", "false");
28097
29535
  const card = (
@@ -28100,22 +29538,24 @@ var zx = (() => {
28100
29538
  );
28101
29539
  if (card) delete card.dataset.grabbed;
28102
29540
  this._syncKeyboardTarget();
28103
- this.moveRecord(meta.id, destination);
29541
+ this._commitInteractionMove(move2.ids, move2.to);
28104
29542
  } else {
28105
29543
  const from = this._recordLocation(meta.id);
28106
29544
  if (!from) return;
28107
- this._keyboardMove = { id: meta.id, from: { ...from }, to: { ...from } };
29545
+ const ids = this._movingIds(meta.id);
29546
+ const start = { ...from, index: this._visibleIndex(meta.id) };
29547
+ this._keyboardMove = { ids, from: { ...from }, to: start };
28108
29548
  handle.setAttribute("aria-pressed", "true");
28109
29549
  const card = handle.closest(".zx-record-card");
28110
29550
  if (card) card.dataset.grabbed = "true";
28111
29551
  this._syncKeyboardTarget();
28112
- this._announce(`Grabbed ${this._recordName(meta.record, meta.index)}. Use left and right for columns, up and down for position, and Alt plus up or down for swim lanes.`);
29552
+ this._announce(ids.length > 1 ? this._text("grabbedMultiple", { count: ids.length }) : this._text("grabbed", { name: this._recordName(meta.record, meta.index) }));
28113
29553
  }
28114
29554
  return;
28115
29555
  }
28116
29556
  if (event.key === "Escape" && this._keyboardMove) {
28117
29557
  event.preventDefault();
28118
- const record = this.getRecord(this._keyboardMove.id);
29558
+ const records = this._keyboardMove.ids.map((id) => this.getRecord(id)).filter(Boolean);
28119
29559
  this._keyboardMove = null;
28120
29560
  handle.setAttribute("aria-pressed", "false");
28121
29561
  const card = (
@@ -28124,10 +29564,13 @@ var zx = (() => {
28124
29564
  );
28125
29565
  if (card) delete card.dataset.grabbed;
28126
29566
  this._syncKeyboardTarget();
28127
- this._announce(`Move canceled${record ? ` for ${this._recordName(record, meta.index)}` : ""}.`);
29567
+ this._announce(records.length ? this._text("moveCanceled", { name: this._movingName(
29568
+ /** @type {KanbanRecord[]} */
29569
+ records
29570
+ ) }) : this._text("moveCanceledPlain"));
28128
29571
  return;
28129
29572
  }
28130
- if (!this._keyboardMove || !Object.is(this._keyboardMove.id, meta.id) || !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return;
29573
+ if (!this._keyboardMove || !this._keyboardMove.ids.some((candidate) => Object.is(candidate, meta.id)) || !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return;
28131
29574
  event.preventDefault();
28132
29575
  const move = this._keyboardMove;
28133
29576
  const laneModifier = event.altKey || event.ctrlKey || event.metaKey;
@@ -28144,10 +29587,14 @@ var zx = (() => {
28144
29587
  } else if (event.key === "ArrowUp" || event.key === "ArrowDown") {
28145
29588
  move.to.index += event.key === "ArrowUp" ? -1 : 1;
28146
29589
  }
28147
- const targetCount = this._recordsIn(move.to.column, move.to.lane).filter((entry) => !Object.is(this._viewRecordId(entry.record), move.id)).length;
28148
- move.to.index = normalizeMoveIndex(move.to.index, targetCount);
29590
+ const targetCount = this._visibleCount(move.to.column, move.to.lane, new Set(move.ids));
29591
+ move.to.index = clampKanbanIndex(move.to.index, targetCount);
28149
29592
  this._syncKeyboardTarget();
28150
- this._announce(`Target ${this._targetName(move.to)}, position ${move.to.index + 1} of ${targetCount + 1}.`);
29593
+ this._announce(this._text("target", {
29594
+ target: this._targetName(move.to),
29595
+ position: move.to.index + 1,
29596
+ total: targetCount + 1
29597
+ }));
28151
29598
  }
28152
29599
  /** @param {Event} event @returns {void} */
28153
29600
  _handlePreviewError(event) {
@@ -28163,80 +29610,246 @@ var zx = (() => {
28163
29610
  if (preview) preview.dataset.failed = "true";
28164
29611
  image.hidden = true;
28165
29612
  }
28166
- /** @param {DragEvent} event @returns {void} */
28167
- _handleDragStart(event) {
28168
- const handle = elementTarget(event)?.closest(".zx-kanban-view__move-handle");
28169
- const meta = this._metaForTarget(handle);
28170
- if (!handle || !meta) {
28171
- event.preventDefault();
28172
- return;
29613
+ /**
29614
+ * Records a candidate drag. Nothing moves yet: a press that never travels stays a click, and a
29615
+ * touch on the card body stays a scroll until the long press elapses.
29616
+ * @param {PointerEvent} event Pointer event.
29617
+ * @returns {void}
29618
+ */
29619
+ _handlePointerDown(event) {
29620
+ this._suppressClick = false;
29621
+ if (event.button != null && event.button !== 0) return;
29622
+ const target = elementTarget(event);
29623
+ const handle = target?.closest(".zx-kanban-view__move-handle");
29624
+ const meta = this._metaForTarget(handle ?? target);
29625
+ if (!meta) return;
29626
+ if (!handle) {
29627
+ if (this._kanbanOptions().dragFrom !== "card") return;
29628
+ if (target?.closest(INTERACTIVE) || target?.closest(".zx-record-card__selection")) return;
29629
+ }
29630
+ const ids = this._movingIds(meta.id);
29631
+ this._pointerStart = {
29632
+ x: event.clientX,
29633
+ y: event.clientY,
29634
+ id: event.pointerId,
29635
+ ids,
29636
+ touch: event.pointerType === "touch"
29637
+ };
29638
+ this._dragPoint = { x: event.clientX, y: event.clientY };
29639
+ clearTimeout(this._pointerHold);
29640
+ if (event.pointerType !== "touch" || handle) return;
29641
+ this._pointerHold = setTimeout(() => this._beginDrag(this._dragPoint.x, this._dragPoint.y), TOUCH_HOLD);
29642
+ }
29643
+ /** @param {PointerEvent} event @returns {void} */
29644
+ _handlePointerMove(event) {
29645
+ const start = this._pointerStart;
29646
+ if (!this._dragIds.length) {
29647
+ if (!start || event.pointerId !== start.id) return;
29648
+ const distance = Math.hypot(event.clientX - start.x, event.clientY - start.y);
29649
+ this._dragPoint = { x: event.clientX, y: event.clientY };
29650
+ if (distance <= DRAG_THRESHOLD) return;
29651
+ if (start.touch && this._pointerHold) {
29652
+ clearTimeout(this._pointerHold);
29653
+ this._pointerHold = 0;
29654
+ this._pointerStart = null;
29655
+ return;
29656
+ }
29657
+ this._beginDrag(event.clientX, event.clientY);
29658
+ if (!this._dragIds.length) return;
28173
29659
  }
28174
- this._dragId = meta.id;
28175
- const card = handle.closest(".zx-record-card");
28176
- if (card) card.dataset.dragging = "true";
28177
- event.dataTransfer?.setData("text/plain", "zx-kanban-record");
28178
- if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
29660
+ if (this._dragPointer != null && event.pointerId !== this._dragPointer) return;
29661
+ event.preventDefault();
29662
+ this._dragPoint = { x: event.clientX, y: event.clientY };
29663
+ this._positionDragPreview();
29664
+ this._updateDropTarget(event.clientX, event.clientY);
28179
29665
  }
28180
- /** @param {DragEvent} event @returns {void} */
28181
- _handleDragOver(event) {
28182
- if (this._dragId == null) return;
28183
- const section = elementTarget(event)?.closest(".zx-kanban-view__column");
28184
- const sectionMeta = section ? this._sectionMeta.get(section) : null;
28185
- if (!sectionMeta) return;
29666
+ /** @param {PointerEvent} event @returns {void} */
29667
+ _handlePointerUp(event) {
29668
+ if (this._dragIds.length && this._dragPointer != null && event.pointerId !== this._dragPointer) return;
29669
+ clearTimeout(this._pointerHold);
29670
+ this._pointerHold = 0;
29671
+ this._pointerStart = null;
29672
+ if (!this._dragIds.length) return;
28186
29673
  event.preventDefault();
28187
- const destination = this._pointerDestination(event, sectionMeta);
28188
- this._dragDestination = destination;
29674
+ const ids = [...this._dragIds];
29675
+ const destination = this._dragDestination ? { ...this._dragDestination } : null;
29676
+ this._clearDrag();
29677
+ this._suppressClick = true;
29678
+ if (destination) this._commitInteractionMove(ids, destination);
29679
+ }
29680
+ /**
29681
+ * Starts a pointer drag: the floating preview appears, the board stops selecting text, and the
29682
+ * auto-scroll loop begins.
29683
+ * @param {number} x Pointer client x.
29684
+ * @param {number} y Pointer client y.
29685
+ * @returns {void}
29686
+ */
29687
+ _beginDrag(x, y) {
29688
+ const start = this._pointerStart;
29689
+ if (!start || this._dragIds.length) return;
29690
+ this._pointerHold = 0;
29691
+ this._dragIds = start.ids.filter((id) => this.getRecord(id));
29692
+ if (!this._dragIds.length) return;
29693
+ this._dragPoint = { x, y };
29694
+ this._board.dataset.dragging = "true";
29695
+ for (const id of this._dragIds) {
29696
+ const card = this._cardsById.get(id);
29697
+ if (card) card.dataset.dragging = "true";
29698
+ }
29699
+ this._dragPointer = start.id;
29700
+ try {
29701
+ this._board.setPointerCapture(start.id);
29702
+ this._dragCaptured = true;
29703
+ } catch {
29704
+ }
29705
+ globalThis.getSelection?.()?.removeAllRanges();
29706
+ this._dragPreview = this._createDragPreview();
29707
+ this.el.append(this._dragPreview);
29708
+ this._positionDragPreview();
29709
+ this._updateDropTarget(x, y);
29710
+ if (this._kanbanOptions().autoScroll !== false) this._scheduleAutoScroll();
29711
+ }
29712
+ /** @returns {HTMLElement} Floating drag preview. */
29713
+ _createDragPreview() {
29714
+ const records = this._dragIds.map((id) => (
29715
+ /** @type {KanbanRecord} */
29716
+ this.getRecord(id)
29717
+ ));
29718
+ const custom = this._renderHook("renderDragPreview", {
29719
+ records,
29720
+ ids: [...this._dragIds],
29721
+ count: this._dragIds.length
29722
+ });
29723
+ const content = custom ?? (this._dragIds.length > 1 ? this._text("dragPreview", { count: this._dragIds.length }) : this._movingName(records));
29724
+ return h("div", {
29725
+ class: "zx-kanban-view__drag-preview",
29726
+ ariaHidden: "true",
29727
+ dataset: { count: String(this._dragIds.length) }
29728
+ }, content);
29729
+ }
29730
+ /** @returns {void} */
29731
+ _positionDragPreview() {
29732
+ if (!this._dragPreview) return;
29733
+ this._dragPreview.style.transform = `translate3d(${Math.round(this._dragPoint.x)}px, ${Math.round(this._dragPoint.y)}px, 0)`;
29734
+ }
29735
+ /**
29736
+ * Resolves and marks the drop destination under the pointer. Pointer capture retargets events to
29737
+ * the board, so the element under the pointer is looked up rather than read off the event.
29738
+ * @param {number} x Pointer client x.
29739
+ * @param {number} y Pointer client y.
29740
+ * @returns {void}
29741
+ */
29742
+ _updateDropTarget(x, y) {
29743
+ const element = document.elementFromPoint(x, y);
29744
+ const section = element?.closest(".zx-kanban-view__column");
29745
+ const sectionMeta = section && this._board.contains(section) ? this._sectionMeta.get(section) : null;
28189
29746
  this._clearDropMarkers();
28190
- const card = elementTarget(event)?.closest(".zx-record-card");
28191
- const cardMeta = card ? this._cardMeta.get(card) : null;
28192
- if (card && cardMeta && !Object.is(cardMeta.id, this._dragId)) {
28193
- const rectangle = card.getBoundingClientRect();
28194
- card.dataset.drop = event.clientY >= rectangle.top + rectangle.height / 2 ? "after" : "before";
28195
- } else {
28196
- sectionMeta.list.dataset.drop = "append";
29747
+ if (!sectionMeta || this._collapsedColumns.has(sectionMeta.column)) {
29748
+ this._dragDestination = null;
29749
+ return;
28197
29750
  }
28198
- if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
29751
+ const cards = this._draggableCards(sectionMeta.list);
29752
+ const index = cards.findIndex((card) => {
29753
+ const rectangle = card.getBoundingClientRect();
29754
+ return y < rectangle.top + rectangle.height / 2;
29755
+ });
29756
+ const position2 = index < 0 ? cards.length : index;
29757
+ if (cards[position2]) cards[position2].dataset.drop = "before";
29758
+ else if (cards.length) cards[cards.length - 1].dataset.drop = "after";
29759
+ else sectionMeta.list.dataset.drop = "append";
29760
+ this._dragDestination = { column: sectionMeta.column, lane: sectionMeta.lane, index: position2 };
28199
29761
  }
28200
- /** @param {DragEvent} event @returns {void} */
28201
- _handleDrop(event) {
28202
- if (this._dragId == null || !this._dragDestination) return;
28203
- event.preventDefault();
28204
- const id = this._dragId;
28205
- const destination = { ...this._dragDestination };
28206
- this._clearDrag();
28207
- this.moveRecord(id, destination);
28208
- }
28209
- /** @param {DragEvent} event @param {KanbanSectionMeta} section @returns {KanbanMovePoint} */
28210
- _pointerDestination(event, section) {
28211
- const cards = [...section.list.querySelectorAll(":scope > .zx-record-card")].filter((card) => !Object.is(this._cardMeta.get(card)?.id, this._dragId));
28212
- const target = elementTarget(event)?.closest(".zx-record-card");
28213
- const targetMeta = target ? this._cardMeta.get(target) : null;
28214
- if (target && targetMeta && Object.is(targetMeta.id, this._dragId)) {
28215
- const from = this._recordLocation(this._dragId);
28216
- return { column: section.column, lane: section.lane, index: from?.index ?? cards.length };
28217
- }
28218
- const targetIndex = target ? cards.indexOf(target) : -1;
28219
- if (targetIndex < 0) return { column: section.column, lane: section.lane, index: cards.length };
28220
- const rectangle = target.getBoundingClientRect();
28221
- return {
28222
- column: section.column,
28223
- lane: section.lane,
28224
- index: targetIndex + (event.clientY >= rectangle.top + rectangle.height / 2 ? 1 : 0)
28225
- };
29762
+ /** @returns {void} */
29763
+ _scheduleAutoScroll() {
29764
+ if (this._dragFrame) return;
29765
+ this._dragFrame = requestAnimationFrame(() => {
29766
+ this._dragFrame = 0;
29767
+ if (!this._dragIds.length) return;
29768
+ this._autoScroll();
29769
+ this._scheduleAutoScroll();
29770
+ });
29771
+ }
29772
+ /** Scrolls the board horizontally and the hovered column vertically near their edges. @returns {void} */
29773
+ _autoScroll() {
29774
+ const { x, y } = this._dragPoint;
29775
+ const board = this._board.getBoundingClientRect();
29776
+ if (x < board.left + SCROLL_EDGE) this._board.scrollLeft -= SCROLL_SPEED;
29777
+ else if (x > board.right - SCROLL_EDGE) this._board.scrollLeft += SCROLL_SPEED;
29778
+ const list = document.elementFromPoint(x, y)?.closest(".zx-kanban-view__cards");
29779
+ if (!list || !this._board.contains(list) || list.scrollHeight <= list.clientHeight) return;
29780
+ const bounds = list.getBoundingClientRect();
29781
+ if (y < bounds.top + SCROLL_EDGE) list.scrollTop -= SCROLL_SPEED;
29782
+ else if (y > bounds.bottom - SCROLL_EDGE) list.scrollTop += SCROLL_SPEED;
29783
+ }
29784
+ /**
29785
+ * Commits a move whose index came from rendered cards. Search and filters hide records without
29786
+ * removing them, so the visible position is translated back before anything is proposed.
29787
+ * @param {unknown[]} ids Moving ids.
29788
+ * @param {KanbanMovePoint} point Destination in visible-card space.
29789
+ * @returns {void}
29790
+ */
29791
+ _commitInteractionMove(ids, point) {
29792
+ const excluded = new Set(ids);
29793
+ this.moveRecords(ids, {
29794
+ column: point.column,
29795
+ lane: point.lane,
29796
+ index: this._absoluteIndex(point.column, point.lane, point.index, excluded)
29797
+ });
28226
29798
  }
28227
29799
  /** @returns {void} */
28228
29800
  _clearDrag() {
28229
- this._clearDropMarkers();
28230
- for (const card of this._board.querySelectorAll("[data-dragging]")) delete /** @type {HTMLElement} */
28231
- card.dataset.dragging;
28232
- this._dragId = null;
29801
+ if (this._dragFrame) cancelAnimationFrame(this._dragFrame);
29802
+ this._dragFrame = 0;
29803
+ clearTimeout(this._pointerHold);
29804
+ this._pointerHold = 0;
29805
+ this._pointerStart = null;
29806
+ this._dragPreview?.remove();
29807
+ this._dragPreview = null;
29808
+ this._dragIds = [];
28233
29809
  this._dragDestination = null;
29810
+ if (this._board) {
29811
+ if (this._dragCaptured && this._dragPointer != null) {
29812
+ try {
29813
+ this._board.releasePointerCapture(this._dragPointer);
29814
+ } catch {
29815
+ }
29816
+ }
29817
+ this._clearDropMarkers();
29818
+ delete this._board.dataset.dragging;
29819
+ for (const card of this._board.querySelectorAll("[data-dragging]")) delete /** @type {HTMLElement} */
29820
+ card.dataset.dragging;
29821
+ }
29822
+ this._dragPointer = null;
29823
+ this._dragCaptured = false;
28234
29824
  }
28235
29825
  /** @returns {void} */
28236
29826
  _clearDropMarkers() {
28237
29827
  for (const element of this._board.querySelectorAll("[data-drop]")) delete /** @type {HTMLElement} */
28238
29828
  element.dataset.drop;
28239
29829
  }
29830
+ /** @param {HTMLElement} list Card list. @returns {HTMLElement[]} Cards excluding the ones in flight. */
29831
+ _draggableCards(list) {
29832
+ const moving = new Set(this._dragIds.length ? this._dragIds : this._keyboardMove?.ids ?? []);
29833
+ return (
29834
+ /** @type {HTMLElement[]} */
29835
+ [...list.querySelectorAll(":scope > .zx-record-card")].filter((card) => !hasId(moving, this._cardMeta.get(card)?.id))
29836
+ );
29837
+ }
29838
+ /** @param {string} column @param {string|null} lane @param {Set<unknown>} excluded @returns {number} */
29839
+ _visibleCount(column, lane, excluded) {
29840
+ return this._recordsIn(column, lane).filter((entry) => {
29841
+ const id = this._viewRecordId(entry.record);
29842
+ return !hasId(excluded, id) && this._visibleIds.has(id);
29843
+ }).length;
29844
+ }
29845
+ /** @param {unknown} id Record id. @returns {number} Position among visible cards in its bucket. */
29846
+ _visibleIndex(id) {
29847
+ const location = this._recordLocation(id);
29848
+ if (!location) return 0;
29849
+ const visible = this._recordsIn(location.column, location.lane).filter((entry) => this._visibleIds.has(this._viewRecordId(entry.record)));
29850
+ const position2 = visible.findIndex((entry) => Object.is(this._viewRecordId(entry.record), id));
29851
+ return position2 < 0 ? visible.length : position2;
29852
+ }
28240
29853
  /** @returns {void} */
28241
29854
  _syncKeyboardTarget() {
28242
29855
  for (const element of this._board.querySelectorAll("[data-keyboard-target]")) {
@@ -28251,7 +29864,7 @@ var zx = (() => {
28251
29864
  });
28252
29865
  const sectionMeta = section ? this._sectionMeta.get(section) : null;
28253
29866
  if (!sectionMeta) return;
28254
- const cards = [...sectionMeta.list.querySelectorAll(":scope > .zx-record-card")].filter((card) => !Object.is(this._cardMeta.get(card)?.id, move.id));
29867
+ const cards = this._draggableCards(sectionMeta.list);
28255
29868
  const target = cards[move.to.index];
28256
29869
  if (target) target.dataset.keyboardTarget = "before";
28257
29870
  else sectionMeta.list.dataset.keyboardTarget = "append";
@@ -28261,7 +29874,7 @@ var zx = (() => {
28261
29874
  for (const [id, card] of this._cardsById) {
28262
29875
  const selected = this._isViewSelected(id);
28263
29876
  card.dataset.selected = String(selected);
28264
- if (this.options.selectable) card.setAttribute("aria-selected", String(selected));
29877
+ this._applyCardDescription(card, id);
28265
29878
  const input = (
28266
29879
  /** @type {HTMLInputElement|null} */
28267
29880
  card.querySelector("[data-record-selection]")
@@ -28281,6 +29894,50 @@ var zx = (() => {
28281
29894
  this._cardsById.get(id)?.querySelector(".zx-kanban-view__move-handle") ?? null
28282
29895
  );
28283
29896
  }
29897
+ /**
29898
+ * Resolves a collapse control after a board refresh, retaining the same lane copy for columns.
29899
+ * @param {'column'|'swimlane'} axis Collapse axis.
29900
+ * @param {string} id Descriptor id.
29901
+ * @param {string|null} [lane=null] Column copy's lane.
29902
+ * @returns {HTMLElement|null}
29903
+ */
29904
+ _collapseControl(axis, id, lane = null) {
29905
+ return (
29906
+ /** @type {HTMLElement|null} */
29907
+ [...this._board.querySelectorAll("[data-kanban-collapse]")].find((candidate) => {
29908
+ const control = (
29909
+ /** @type {HTMLElement} */
29910
+ candidate
29911
+ );
29912
+ if (control.dataset.kanbanCollapse !== axis || control.dataset.kanbanId !== id) return false;
29913
+ if (axis !== "column") return true;
29914
+ const section = control.closest(".zx-kanban-view__column");
29915
+ return section ? this._sectionMeta.get(section)?.lane === lane : false;
29916
+ }) ?? null
29917
+ );
29918
+ }
29919
+ /**
29920
+ * Runs one presentation hook, ignoring anything that is not a node so a hook cannot smuggle
29921
+ * markup in as a string.
29922
+ * @param {'renderCard'|'renderColumnHeader'|'renderSwimlaneHeader'|'renderColumnEmpty'|'renderDragPreview'} name Hook name.
29923
+ * @param {any} context Hook context.
29924
+ * @returns {Node|null} Node to adopt, or null for the default presentation.
29925
+ */
29926
+ _renderHook(name, context) {
29927
+ const hook = this._kanbanOptions()[name];
29928
+ if (typeof hook !== "function") return null;
29929
+ const result = hook(context);
29930
+ return result && typeof result === "object" && "nodeType" in result ? (
29931
+ /** @type {Node} */
29932
+ result
29933
+ ) : null;
29934
+ }
29935
+ /** @param {KanbanColumn} column @param {KanbanSwimlane|null} lane @returns {boolean} */
29936
+ _allowsAdd(column, lane) {
29937
+ const allow = this._kanbanOptions().allowAdd;
29938
+ if (typeof allow === "function") return allow(column, lane) === true;
29939
+ return allow === true;
29940
+ }
28284
29941
  /** @param {KanbanRecord} record @param {number} index @returns {string} */
28285
29942
  _recordName(record, index) {
28286
29943
  const source = this._kanbanOptions().titleField;
@@ -28288,10 +29945,16 @@ var zx = (() => {
28288
29945
  if (value && typeof value === "object" && "nodeType" in value) {
28289
29946
  return (
28290
29947
  /** @type {Node} */
28291
- value.textContent?.trim() || "record"
29948
+ value.textContent?.trim() || this._text("record")
28292
29949
  );
28293
29950
  }
28294
- return String(value ?? "record");
29951
+ return String(value ?? this._text("record"));
29952
+ }
29953
+ /** @param {KanbanRecord[]} records Moving records. @returns {string} */
29954
+ _movingName(records) {
29955
+ if (!records.length) return this._text("record");
29956
+ if (records.length === 1) return this._recordName(records[0], this._viewData.indexOf(records[0]));
29957
+ return this._text("dragPreview", { count: records.length });
28295
29958
  }
28296
29959
  /** @param {KanbanMovePoint} point @returns {string} */
28297
29960
  _targetName(point) {
@@ -28299,6 +29962,26 @@ var zx = (() => {
28299
29962
  const lane = point.lane == null ? null : this._resolvedSwimlanes().find((candidate) => candidate.id === point.lane);
28300
29963
  return `${column?.label ?? point.column}${lane ? `, ${lane.label}` : ""}`;
28301
29964
  }
29965
+ /** @param {KanbanMoveEvaluation} evaluation Capacity report. @returns {string} */
29966
+ _wipAnnouncement(evaluation) {
29967
+ if (evaluation.limit == null) return "";
29968
+ if (evaluation.limitExceeded) return this._text("limitExceeded", { limit: evaluation.limit });
29969
+ return evaluation.limitReached ? this._text("limitReached", { limit: evaluation.limit }) : "";
29970
+ }
29971
+ /**
29972
+ * Resolves one localizable string.
29973
+ * @param {keyof typeof DEFAULT_LABELS|string} key Label key.
29974
+ * @param {Record<string, unknown>} [args] Placeholder values.
29975
+ * @returns {string} Resolved text.
29976
+ */
29977
+ _text(key, args) {
29978
+ const overrides = this._kanbanOptions().labels;
29979
+ const source = overrides && typeof overrides === "object" && key in overrides ? overrides[key] : (
29980
+ /** @type {Record<string,string>} */
29981
+ DEFAULT_LABELS[key]
29982
+ );
29983
+ return printf(source == null ? String(key) : String(source), args);
29984
+ }
28302
29985
  /** @param {string} message @returns {void} */
28303
29986
  _announce(message) {
28304
29987
  const token = ++this._announcementToken;
@@ -28338,6 +30021,9 @@ var zx = (() => {
28338
30021
  if (source.accept != null && typeof source.accept !== "function") {
28339
30022
  throw new TypeError(`Kanban ${axis} ${id} accept must be a function`);
28340
30023
  }
30024
+ if (source.from != null && !Array.isArray(source.from)) {
30025
+ throw new TypeError(`Kanban ${axis} ${id} from must be an array or null`);
30026
+ }
28341
30027
  seen.add(id);
28342
30028
  const normalized = (
28343
30029
  /** @type {Record<string, any>} */
@@ -28345,11 +30031,21 @@ var zx = (() => {
28345
30031
  ...source,
28346
30032
  id,
28347
30033
  label: String(source.label ?? axisLabel(source.id)),
30034
+ limit: normalizeLimit2(source.limit),
30035
+ from: Array.isArray(source.from) ? source.from.map(String) : null,
28348
30036
  ...Object.prototype.hasOwnProperty.call(source, "value") ? {} : { value: source.id }
28349
30037
  }
28350
30038
  );
28351
- if (axis === "column") normalized.limit = normalizeLimit2(source.limit);
28352
- else delete normalized.limit;
30039
+ if (axis === "column") {
30040
+ const laneLimits = normalizeLaneLimits(source.laneLimits);
30041
+ if (laneLimits) normalized.laneLimits = laneLimits;
30042
+ if (source.wipPolicy === "block" || source.wipPolicy === "warn") {
30043
+ normalized.wipPolicy = source.wipPolicy;
30044
+ }
30045
+ } else {
30046
+ delete normalized.laneLimits;
30047
+ delete normalized.wipPolicy;
30048
+ }
28353
30049
  return normalized;
28354
30050
  })
28355
30051
  );
@@ -28372,37 +30068,11 @@ var zx = (() => {
28372
30068
  const preferred = normalizeOrder(order).filter((id) => allowed.has(id));
28373
30069
  return [...new Set(preferred), ...available.filter((id) => !preferred.includes(id))];
28374
30070
  }
28375
- function reorderKanbanData(records, id, movedRecord, options) {
28376
- if (!Array.isArray(records) || !movedRecord || typeof movedRecord !== "object") return null;
28377
- const sourceIndex = records.findIndex((record) => Object.is(viewRecordId(record, options.recordId), id));
28378
- if (sourceIndex < 0) return null;
28379
- const source = records[sourceIndex];
28380
- const sourceColumn = normalizeAxisId(readKanbanAccessor(source, sourceIndex, options.columnBy));
28381
- const sourceLane = options.swimlaneBy == null ? null : normalizeAxisId(readKanbanAccessor(source, sourceIndex, options.swimlaneBy));
28382
- const sourceGroup = records.filter((record, index) => normalizeAxisId(readKanbanAccessor(record, index, options.columnBy)) === sourceColumn && (options.swimlaneBy == null || normalizeAxisId(readKanbanAccessor(record, index, options.swimlaneBy)) === sourceLane));
28383
- const from = {
28384
- column: sourceColumn,
28385
- lane: sourceLane,
28386
- index: sourceGroup.findIndex((record) => Object.is(viewRecordId(record, options.recordId), id))
28387
- };
28388
- const next = records.filter((_, index) => index !== sourceIndex);
28389
- const destinationRecords = next.filter((record, index) => normalizeAxisId(readKanbanAccessor(record, index, options.columnBy)) === options.destination.column && (options.swimlaneBy == null || normalizeAxisId(readKanbanAccessor(record, index, options.swimlaneBy)) === options.destination.lane));
28390
- const targetIndex = normalizeMoveIndex(options.destination.index, destinationRecords.length);
28391
- let insertion = next.length;
28392
- if (destinationRecords[targetIndex]) insertion = next.indexOf(destinationRecords[targetIndex]);
28393
- else if (destinationRecords.length) insertion = next.indexOf(destinationRecords.at(-1)) + 1;
28394
- next.splice(insertion, 0, movedRecord);
28395
- return {
28396
- records: next,
28397
- from,
28398
- to: { column: options.destination.column, lane: options.swimlaneBy == null ? null : options.destination.lane, index: targetIndex }
28399
- };
28400
- }
28401
- function readKanbanAccessor(record, index, accessor) {
28402
- return typeof accessor === "function" ? accessor(record, index) : accessor == null ? null : record?.[accessor];
28403
- }
28404
- function normalizeAxisId(value) {
28405
- return value == null ? "" : String(value);
30071
+ function reconcileKanbanPreference(descriptors, order, preserveUnknown = false) {
30072
+ if (!preserveUnknown) return reconcileKanbanOrder(descriptors, order);
30073
+ const preferred = normalizeOrder(order);
30074
+ const available = descriptors.map((descriptor) => descriptor.id);
30075
+ return [...preferred, ...available.filter((id) => !preferred.includes(id))];
28406
30076
  }
28407
30077
  function axisLabel(value) {
28408
30078
  if (value == null || String(value).trim() === "") return "Unassigned";
@@ -28414,13 +30084,42 @@ var zx = (() => {
28414
30084
  if (!Number.isFinite(number2) || number2 < 0) throw new TypeError("Kanban column limit must be a non-negative number");
28415
30085
  return Math.floor(number2);
28416
30086
  }
28417
- function normalizeMoveIndex(value, maximum) {
28418
- if (value == null || !Number.isFinite(Number(value))) return maximum;
28419
- return Math.max(0, Math.min(maximum, Math.trunc(Number(value))));
30087
+ function normalizeLaneLimits(value) {
30088
+ if (value == null) return void 0;
30089
+ if (typeof value !== "object" || Array.isArray(value)) {
30090
+ throw new TypeError("Kanban column laneLimits must be an object");
30091
+ }
30092
+ const result = {};
30093
+ for (const [lane, limit] of Object.entries(
30094
+ /** @type {Record<string, unknown>} */
30095
+ value
30096
+ )) {
30097
+ const normalized = normalizeLimit2(limit);
30098
+ if (normalized != null) result[lane] = normalized;
30099
+ }
30100
+ return result;
30101
+ }
30102
+ function readColumnLaneLimit(column, lane) {
30103
+ const limits = column.laneLimits;
30104
+ if (!limits || typeof limits !== "object") return null;
30105
+ const value = limits[lane];
30106
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
30107
+ }
30108
+ function wipState(count2, limit) {
30109
+ if (limit == null) return "none";
30110
+ return count2 > limit ? "exceeded" : count2 === limit ? "limit" : "within";
28420
30111
  }
28421
30112
  function normalizeOrder(value) {
28422
30113
  return Array.isArray(value) ? [...new Set(value.map(String))] : [];
28423
30114
  }
30115
+ function stringifyValue(value) {
30116
+ if (value == null) return "";
30117
+ if (typeof value === "object") return "nodeType" in value ? String(
30118
+ /** @type {Node} */
30119
+ value.textContent ?? ""
30120
+ ) : "";
30121
+ return String(value);
30122
+ }
28424
30123
  function orderDescriptors(descriptors, order) {
28425
30124
  const byId = new Map(descriptors.map((descriptor) => [descriptor.id, descriptor]));
28426
30125
  return order.map((id) => byId.get(id)).filter(Boolean);
@@ -28434,12 +30133,28 @@ var zx = (() => {
28434
30133
  next.splice(target + (position2 === "after" ? 1 : 0), 0, String(id));
28435
30134
  return next;
28436
30135
  }
30136
+ function mergeKanbanResolvedOrder(preference, descriptors, resolvedOrder, preserveUnknown) {
30137
+ if (!preserveUnknown) return [...resolvedOrder];
30138
+ const available = new Set(descriptors.map((descriptor) => descriptor.id));
30139
+ const replacements = [...resolvedOrder];
30140
+ let cursor = 0;
30141
+ const merged = normalizeOrder(preference).flatMap((id) => {
30142
+ if (!available.has(id)) return [id];
30143
+ return cursor < replacements.length ? [replacements[cursor++]] : [];
30144
+ });
30145
+ merged.push(...replacements.slice(cursor));
30146
+ return normalizeOrder(merged);
30147
+ }
28437
30148
  function sameMovePoint(left, right) {
28438
30149
  return left.column === right.column && left.lane === right.lane && left.index === right.index;
28439
30150
  }
28440
30151
  function sameArray3(left, right) {
28441
30152
  return left.length === right.length && left.every((value, index) => Object.is(value, right[index]));
28442
30153
  }
30154
+ function hasId(haystack, needle) {
30155
+ for (const candidate of haystack) if (Object.is(candidate, needle)) return true;
30156
+ return false;
30157
+ }
28443
30158
  function elementTarget(event) {
28444
30159
  const target = event.target;
28445
30160
  if (!target) return null;