@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.esm.js CHANGED
@@ -798,21 +798,26 @@ var Component = class extends EventTarget {
798
798
  * Dispatches a component event and a bubbling, composed `zx-*` DOM event.
799
799
  * @param {string} type Lowercase event name.
800
800
  * @param {Record<string, unknown>} [detail={}] Single event detail object.
801
+ * @param {{honorDomCancellation?:boolean}} [options={}] Mirrored DOM-event behavior. Components
802
+ * with a cancelable mutation boundary may opt into treating `preventDefault()` on the bubbling
803
+ * `zx-*` event exactly like cancellation of the component event.
801
804
  * @returns {Event & {detail: Record<string, unknown>}}
802
805
  * @fires Component#event
803
806
  */
804
- emit(type, detail = {}) {
807
+ emit(type, detail = {}, options = {}) {
805
808
  if (detail === null || typeof detail !== "object" || Array.isArray(detail)) {
806
809
  throw new TypeError("Event detail must be an object");
807
810
  }
808
811
  const componentEvent = createCustomEvent(type, detail, { cancelable: true });
809
812
  this.dispatchEvent(componentEvent);
810
813
  if (this.el) {
811
- this.el.dispatchEvent(createCustomEvent(`zx-${type}`, detail, {
814
+ const domEvent = createCustomEvent(`zx-${type}`, detail, {
812
815
  bubbles: true,
813
816
  composed: true,
814
817
  cancelable: true
815
- }));
818
+ });
819
+ this.el.dispatchEvent(domEvent);
820
+ if (options.honorDomCancellation && domEvent.defaultPrevented) componentEvent.preventDefault();
816
821
  }
817
822
  return componentEvent;
818
823
  }
@@ -10286,6 +10291,15 @@ function restore2(element, state) {
10286
10291
  element.replaceChildren(...state.children);
10287
10292
  }
10288
10293
 
10294
+ // src/core/overlay-host.js
10295
+ var SCOPE_SELECTOR = ".zx-scope, [data-zx-theme], [data-zx-density], [data-zx-preset]";
10296
+ function overlayHost(source = null) {
10297
+ const context = source == null ? document.activeElement instanceof Element ? document.activeElement : null : resolveElement(source);
10298
+ const scope2 = context?.closest?.(SCOPE_SELECTOR) ?? null;
10299
+ if (!scope2 || scope2 === document.documentElement || scope2 === document.body) return document.body;
10300
+ return scope2;
10301
+ }
10302
+
10289
10303
  // src/components/modal/modal.js
10290
10304
  var Modal = class extends Component {
10291
10305
  static cssName = "modal";
@@ -10295,13 +10309,17 @@ var Modal = class extends Component {
10295
10309
  width: "auto",
10296
10310
  closable: true,
10297
10311
  lightDismiss: false,
10298
- destroyOnClose: false
10312
+ destroyOnClose: false,
10313
+ scope: null
10299
10314
  };
10300
- #pendingResult;
10301
- #hasPendingResult = false;
10315
+ /** @type {Map<number, {result: unknown}>} */
10316
+ #pendingResults = /* @__PURE__ */ new Map();
10302
10317
  #destroying = false;
10318
+ #presentation = 0;
10319
+ /** @type {number[]} */
10320
+ #nativeClosingPresentations = [];
10303
10321
  /**
10304
- * Creates a modal appended to `document.body`.
10322
+ * Creates a modal in the configured theme scope, or at document level when none is present.
10305
10323
  * @param {Element|string|null} [_target=null] Ignored; Modal always owns its root dialog.
10306
10324
  * @param {ModalOptions} [options={}] Modal options.
10307
10325
  */
@@ -10314,14 +10332,7 @@ var Modal = class extends Component {
10314
10332
  if (!this.options.closable || cancelEvent.defaultPrevented) event.preventDefault();
10315
10333
  });
10316
10334
  this.listen(this.el, "close", () => {
10317
- if (!this._isRealClose()) return;
10318
- this.el.dataset.state = "closed";
10319
- delete this.el.dataset.zxOverlayOrder;
10320
- const result = this.#hasPendingResult ? this.#pendingResult : this.el.returnValue || void 0;
10321
- this.#pendingResult = void 0;
10322
- this.#hasPendingResult = false;
10323
- this.emit("close", { result });
10324
- if (this.options.destroyOnClose && !this.#destroying) this.destroy();
10335
+ this._settleClose(this.#nativeClosingPresentations.shift());
10325
10336
  });
10326
10337
  this.listen(this.el, "click", (event) => {
10327
10338
  if (!this.options.lightDismiss || event.target !== this.el || !isBackdropClick(this.el, event)) return;
@@ -10337,7 +10348,7 @@ var Modal = class extends Component {
10337
10348
  * @returns {Element}
10338
10349
  */
10339
10350
  mountTarget() {
10340
- return document.body;
10351
+ return overlayHost(this.options.scope);
10341
10352
  }
10342
10353
  /**
10343
10354
  * Puts the dialog on screen. Overridden by subclasses that open non-modally.
@@ -10346,17 +10357,78 @@ var Modal = class extends Component {
10346
10357
  _show() {
10347
10358
  this.el.showModal();
10348
10359
  }
10360
+ /**
10361
+ * Removes the dialog from its current presentation layer.
10362
+ * Subclasses may replace native dialog presentation while preserving Modal lifecycle events.
10363
+ * @param {string} [result=''] Native string return value.
10364
+ * @param {number} [presentation=this.#presentation] Presentation generation being hidden.
10365
+ * @returns {void}
10366
+ */
10367
+ _hide(result = "", presentation = this.#presentation) {
10368
+ this.#nativeClosingPresentations.push(presentation);
10369
+ try {
10370
+ this.el.close(result);
10371
+ } catch (error2) {
10372
+ this.#nativeClosingPresentations.pop();
10373
+ throw error2;
10374
+ }
10375
+ }
10376
+ /**
10377
+ * Shows a new presentation and gives it a generation of its own. Close events are queued by the
10378
+ * platform, so the generation lets an event from an earlier presentation expire harmlessly if
10379
+ * the same overlay has already reopened.
10380
+ * @returns {void}
10381
+ */
10382
+ _present() {
10383
+ this._show();
10384
+ this.#presentation += 1;
10385
+ }
10386
+ /**
10387
+ * Hides the current presentation and records which generation its queued close event belongs to.
10388
+ * @param {string} [result=''] Native string return value.
10389
+ * @returns {void}
10390
+ */
10391
+ _dismiss(result = "") {
10392
+ this._hide(result, this.#presentation);
10393
+ }
10394
+ /**
10395
+ * Reports whether the current presentation layer is visible.
10396
+ * @returns {boolean}
10397
+ */
10398
+ _isShown() {
10399
+ return this.el.open;
10400
+ }
10349
10401
  /**
10350
10402
  * Whether an incoming `close` event is a real dismissal.
10351
10403
  *
10352
- * A dialog cannot move between the top layer and the page flow while it is open, so a subclass
10353
- * that re-hosts itself has to close and immediately reopen. `close` is dispatched in a queued
10354
- * task, not synchronously, so that stale event arrives once the dialog is already open again —
10355
- * late enough to clobber `data-state`, fire a `close` nobody asked for, and trip
10356
- * `destroyOnClose`. Returning false from an override drops it at the source instead.
10404
+ * `close` is dispatched in a queued task, not synchronously. By the time it arrives the overlay
10405
+ * may have reopened or crossed presentation layers, so only the event for the current hidden
10406
+ * generation may settle state, emit lifecycle events, or trigger `destroyOnClose`.
10407
+ * @param {number|undefined} closingPresentation Presentation that produced the event, if known.
10357
10408
  * @returns {boolean}
10358
10409
  */
10359
- _isRealClose() {
10410
+ _isRealClose(closingPresentation) {
10411
+ if (closingPresentation !== void 0 && closingPresentation !== this.#presentation) return false;
10412
+ return !this._isShown();
10413
+ }
10414
+ /**
10415
+ * Settles one presentation's close without allowing a queued event to invalidate a newer one.
10416
+ * Popover-based subclasses call this directly; native dialog events arrive through the listener.
10417
+ * @param {number|undefined} closingPresentation Presentation that was hidden, if known.
10418
+ * @returns {boolean} Whether this event closed the current presentation.
10419
+ */
10420
+ _settleClose(closingPresentation) {
10421
+ if (!this._isRealClose(closingPresentation)) {
10422
+ if (closingPresentation !== void 0) this.#pendingResults.delete(closingPresentation);
10423
+ return false;
10424
+ }
10425
+ this.el.dataset.state = "closed";
10426
+ delete this.el.dataset.zxOverlayOrder;
10427
+ const pending = closingPresentation === void 0 ? void 0 : this.#pendingResults.get(closingPresentation);
10428
+ if (closingPresentation !== void 0) this.#pendingResults.delete(closingPresentation);
10429
+ const result = pending ? pending.result : this.el.returnValue || void 0;
10430
+ this.emit("close", { result });
10431
+ if (this.options.destroyOnClose && !this.#destroying) this.destroy();
10360
10432
  return true;
10361
10433
  }
10362
10434
  /**
@@ -10381,10 +10453,12 @@ var Modal = class extends Component {
10381
10453
  */
10382
10454
  open() {
10383
10455
  if (this.isOpen()) return this;
10456
+ const host = this.mountTarget();
10457
+ if (this.el.parentElement !== host) host.append(this.el);
10384
10458
  this.el.dataset.zxOverlayOrder = String(nextOverlayOrder());
10385
10459
  this.el.returnValue = "";
10386
10460
  try {
10387
- this._show();
10461
+ this._present();
10388
10462
  } catch (error2) {
10389
10463
  delete this.el.dataset.zxOverlayOrder;
10390
10464
  throw error2;
@@ -10400,9 +10474,14 @@ var Modal = class extends Component {
10400
10474
  */
10401
10475
  close(result) {
10402
10476
  if (!this.isOpen()) return this;
10403
- this.#pendingResult = result;
10404
- this.#hasPendingResult = true;
10405
- this.el.close(typeof result === "string" ? result : "");
10477
+ const presentation = this.#presentation;
10478
+ this.#pendingResults.set(presentation, { result });
10479
+ try {
10480
+ this._dismiss(typeof result === "string" ? result : "");
10481
+ } catch (error2) {
10482
+ this.#pendingResults.delete(presentation);
10483
+ throw error2;
10484
+ }
10406
10485
  return this;
10407
10486
  }
10408
10487
  /**
@@ -10419,7 +10498,7 @@ var Modal = class extends Component {
10419
10498
  * @returns {boolean}
10420
10499
  */
10421
10500
  isOpen() {
10422
- return this.el.open;
10501
+ return this._isShown();
10423
10502
  }
10424
10503
  /**
10425
10504
  * Closes and removes the owned dialog. Safe to call repeatedly.
@@ -10428,7 +10507,7 @@ var Modal = class extends Component {
10428
10507
  destroy() {
10429
10508
  if (this.#destroying) return;
10430
10509
  this.#destroying = true;
10431
- if (this.isOpen()) this.el.close();
10510
+ if (this.isOpen()) this._dismiss();
10432
10511
  super.destroy();
10433
10512
  }
10434
10513
  /** @param {string|number} width @returns {void} */
@@ -10493,7 +10572,7 @@ var Dialog = class _Dialog extends Modal {
10493
10572
  /** @type {Element|null} */
10494
10573
  #opener = null;
10495
10574
  /**
10496
- * Creates a structured dialog appended to `document.body`.
10575
+ * Creates a structured dialog in the configured theme scope, or at document level when unscoped.
10497
10576
  * @param {Element|string|null} [_target=null] Ignored; Dialog always owns its root.
10498
10577
  * @param {DialogOptions} [options={}] Dialog options.
10499
10578
  */
@@ -15390,7 +15469,8 @@ var Dropdown = class extends Component {
15390
15469
  offset: 4,
15391
15470
  matchWidth: false,
15392
15471
  openOn: "click",
15393
- closeOnSelect: false
15472
+ closeOnSelect: false,
15473
+ scope: null
15394
15474
  };
15395
15475
  /** @type {Element} */
15396
15476
  #anchor;
@@ -15409,6 +15489,7 @@ var Dropdown = class extends Component {
15409
15489
  if (!resolved) throw new TypeError("Dropdown anchor could not be resolved");
15410
15490
  super(null, options);
15411
15491
  this.#anchor = resolved;
15492
+ overlayHost(this.options.scope ?? resolved).append(this.el);
15412
15493
  this.#rememberAnchorAttribute("aria-expanded");
15413
15494
  this.#rememberAnchorAttribute("aria-controls");
15414
15495
  this.#anchor.setAttribute("aria-expanded", "false");
@@ -15431,7 +15512,6 @@ var Dropdown = class extends Component {
15431
15512
  popover: "manual"
15432
15513
  });
15433
15514
  panel.dataset.state = "closed";
15434
- document.body.append(panel);
15435
15515
  return panel;
15436
15516
  }
15437
15517
  /**
@@ -15441,6 +15521,8 @@ var Dropdown = class extends Component {
15441
15521
  */
15442
15522
  open() {
15443
15523
  if (this.isOpen() || this.#destroyed) return this;
15524
+ const host = overlayHost(this.options.scope ?? this.#anchor);
15525
+ if (this.el.parentElement !== host) host.append(this.el);
15444
15526
  this.#position = position(this.#anchor, this.el, {
15445
15527
  placement: this.options.placement,
15446
15528
  offset: Number(this.options.offset),
@@ -20100,7 +20182,8 @@ var Launcher = class extends Component {
20100
20182
  emptyText: "No results",
20101
20183
  loadingText: "Searching\u2026",
20102
20184
  shortcut: "mod+k",
20103
- hints: { move: "Move", open: "Open", close: "Close" }
20185
+ hints: { move: "Move", open: "Open", close: "Close" },
20186
+ scope: null
20104
20187
  };
20105
20188
  /**
20106
20189
  * Creates an owned launcher dialog or enhances an existing `<dialog>` target.
@@ -20191,7 +20274,7 @@ var Launcher = class extends Component {
20191
20274
  )
20192
20275
  );
20193
20276
  dialog.dataset.state = "closed";
20194
- if (this._createdRoot) document.body.append(dialog);
20277
+ if (this._createdRoot) overlayHost(this.options.scope).append(dialog);
20195
20278
  this.listen(input, "input", () => {
20196
20279
  this._query = /** @type {HTMLInputElement} */
20197
20280
  input.value;
@@ -20242,6 +20325,10 @@ var Launcher = class extends Component {
20242
20325
  /** Opens the launcher and focuses/selects its query. @returns {this} @fires Launcher#open */
20243
20326
  open() {
20244
20327
  if (this.isOpen() || this._destroyed) return this;
20328
+ if (this._createdRoot) {
20329
+ const host = overlayHost(this.options.scope);
20330
+ if (this.el.parentElement !== host) host.append(this.el);
20331
+ }
20245
20332
  this.el.showModal();
20246
20333
  this.el.dataset.state = "open";
20247
20334
  this.refs.input.setAttribute("aria-expanded", "true");
@@ -21120,6 +21207,7 @@ var Tooltip = class extends Component {
21120
21207
  if (!resolved) throw new TypeError("Tooltip anchor could not be resolved");
21121
21208
  super(null, options);
21122
21209
  this.#anchor = resolved;
21210
+ overlayHost(resolved).append(this.el);
21123
21211
  this.#content = this.options.content;
21124
21212
  this.#disabled = Boolean(this.options.disabled);
21125
21213
  this.#describedBy = resolved.getAttribute("aria-describedby");
@@ -21180,7 +21268,6 @@ var Tooltip = class extends Component {
21180
21268
  popover: "manual"
21181
21269
  });
21182
21270
  panel.dataset.state = "closed";
21183
- document.body.append(panel);
21184
21271
  return panel;
21185
21272
  }
21186
21273
  /**
@@ -21192,6 +21279,8 @@ var Tooltip = class extends Component {
21192
21279
  show() {
21193
21280
  if (this.#destroyed || this.#disabled || this.isOpen()) return this;
21194
21281
  this.#cancelTimers();
21282
+ const host = overlayHost(this.#anchor);
21283
+ if (this.el.parentElement !== host) host.append(this.el);
21195
21284
  const node = resolveContent(this.#content);
21196
21285
  if (node === null) return this;
21197
21286
  this.el.replaceChildren(node);
@@ -22638,8 +22727,8 @@ var Message = class _Message extends Component {
22638
22727
  closable: true,
22639
22728
  maxVisible: 5
22640
22729
  };
22641
- /** @type {Message|null} */
22642
- static #floating = null;
22730
+ /** @type {WeakMap<Element, Message>} */
22731
+ static #floating = /* @__PURE__ */ new WeakMap();
22643
22732
  /** @returns {HTMLElement} */
22644
22733
  render() {
22645
22734
  const created = !this.el;
@@ -22718,15 +22807,16 @@ var Message = class _Message extends Component {
22718
22807
  * @returns {MessageHandle}
22719
22808
  */
22720
22809
  static show(msg, options = {}) {
22721
- return this.#floatingRegion().show(msg, options);
22810
+ return this.#floatingRegion(options.scope).show(msg, options);
22722
22811
  }
22723
22812
  /**
22724
22813
  * Shows a floating progress status.
22725
22814
  * @param {string} text Initial status text.
22815
+ * @param {{scope?: Element|string|null}} [options={}] Floating-region scope.
22726
22816
  * @returns {ProgressHandle}
22727
22817
  */
22728
- static progress(text4) {
22729
- return this.#floatingRegion()._progress(text4);
22818
+ static progress(text4, options = {}) {
22819
+ return this.#floatingRegion(options.scope)._progress(text4);
22730
22820
  }
22731
22821
  /** @returns {void} */
22732
22822
  destroy() {
@@ -22904,19 +22994,22 @@ var Message = class _Message extends Component {
22904
22994
  this._entries.delete(entry);
22905
22995
  this._queue.remove(entry);
22906
22996
  }
22907
- /** @returns {Message} */
22908
- static #floatingRegion() {
22909
- if (this.#floating) return this.#floating;
22997
+ /** @param {Element|string|null|undefined} scope @returns {Message} */
22998
+ static #floatingRegion(scope2) {
22999
+ const host = overlayHost(scope2 ?? null);
23000
+ const existing = this.#floating.get(host);
23001
+ if (existing) return existing;
22910
23002
  const region = h("div", {
22911
23003
  class: "zx-message-region",
22912
23004
  popover: "manual",
22913
23005
  role: "status",
22914
23006
  ariaLive: "polite"
22915
23007
  });
22916
- document.body.append(region);
22917
- this.#floating = new _Message(region);
23008
+ host.append(region);
23009
+ const floating = new _Message(region);
23010
+ this.#floating.set(host, floating);
22918
23011
  if (typeof region.showPopover === "function") region.showPopover();
22919
- return this.#floating;
23012
+ return floating;
22920
23013
  }
22921
23014
  };
22922
23015
  function normalizeKind3(value) {
@@ -23004,7 +23097,6 @@ var Sheet = class extends Dialog {
23004
23097
  constructor(_target = null, options = {}) {
23005
23098
  super(null, options);
23006
23099
  this._dock = null;
23007
- this._handoff = false;
23008
23100
  this.setSide(this.options.side);
23009
23101
  this.el.dataset.backdrop = BACKDROPS.has(this.options.backdrop) ? this.options.backdrop : "dim";
23010
23102
  this.el.dataset.modality = modality(this.options.modal);
@@ -23179,7 +23271,7 @@ var Sheet = class extends Dialog {
23179
23271
  * @returns {Element}
23180
23272
  */
23181
23273
  mountTarget() {
23182
- return this._dock?.el ?? document.body;
23274
+ return this._dock?.el ?? super.mountTarget();
23183
23275
  }
23184
23276
  /**
23185
23277
  * Reports whether a dock currently owns this sheet's positioning.
@@ -23205,35 +23297,23 @@ var Sheet = class extends Dialog {
23205
23297
  /**
23206
23298
  * Moves the dialog to its current mount and, when it was open, reopens it there.
23207
23299
  *
23208
- * The close is unavoidable — a dialog cannot cross between the top layer and the flow while
23209
- * open — but it is invisible: `_isRealClose()` drops the stale event, and reopening bypasses
23210
- * `open()` so no lifecycle event is emitted for what is only a change of address.
23300
+ * The close is unavoidable — a dialog cannot cross between presentation layers while open —
23301
+ * but it is invisible: Modal's presentation generation drops the stale event, and reopening
23302
+ * bypasses `open()` so no lifecycle event is emitted for what is only a change of address.
23211
23303
  * @returns {void}
23212
23304
  */
23213
23305
  _rehost() {
23214
23306
  const wasOpen = this.isOpen();
23215
- if (wasOpen) {
23216
- this._handoff = true;
23217
- this.el.close();
23218
- }
23307
+ if (wasOpen) this._dismiss();
23219
23308
  this.mountTarget().append(this.el);
23220
23309
  if (!wasOpen) return;
23221
23310
  this.el.returnValue = "";
23222
- this._show();
23311
+ this._present();
23223
23312
  this.el.dataset.state = "open";
23224
23313
  if (this._dock) delete /** @type {HTMLElement} */
23225
23314
  this.el.dataset.zxOverlayOrder;
23226
23315
  else this.el.dataset.zxOverlayOrder = String(nextOverlayOrder3());
23227
23316
  }
23228
- /**
23229
- * Drops the stale `close` a re-hosting handoff leaves behind.
23230
- * @returns {boolean}
23231
- */
23232
- _isRealClose() {
23233
- if (!this._handoff) return true;
23234
- this._handoff = false;
23235
- return false;
23236
- }
23237
23317
  /**
23238
23318
  * Opens the sheet, trapping focus first where the modality asks for it.
23239
23319
  * @returns {this}
@@ -23314,10 +23394,38 @@ var Sheet = class extends Dialog {
23314
23394
  */
23315
23395
  _show() {
23316
23396
  if (this.options.modal === true && !this._dock) {
23397
+ this.el.removeAttribute("popover");
23398
+ this.el.removeAttribute("aria-modal");
23317
23399
  super._show();
23318
23400
  return;
23319
23401
  }
23320
- this.el.show();
23402
+ this.el.setAttribute("aria-modal", "false");
23403
+ if (this._dock) {
23404
+ this.el.removeAttribute("popover");
23405
+ this.el.show();
23406
+ return;
23407
+ }
23408
+ this.el.setAttribute("popover", "manual");
23409
+ this.el.showPopover();
23410
+ }
23411
+ /**
23412
+ * Hides a native dialog or a non-modal Sheet promoted through the popover top layer.
23413
+ * @param {string} [result=''] Native string return value.
23414
+ * @param {number} [presentation] Presentation generation being hidden.
23415
+ * @returns {void}
23416
+ */
23417
+ _hide(result = "", presentation) {
23418
+ if (!this.el.matches(":popover-open")) {
23419
+ super._hide(result, presentation);
23420
+ return;
23421
+ }
23422
+ this.el.returnValue = result;
23423
+ this.el.hidePopover();
23424
+ queueMicrotask(() => this._settleClose(presentation));
23425
+ }
23426
+ /** @returns {boolean} */
23427
+ _isShown() {
23428
+ return this.el.matches(":popover-open") || super._isShown();
23321
23429
  }
23322
23430
  /**
23323
23431
  * Releases the focus trap and the owned dialog.
@@ -23642,7 +23750,11 @@ var ContextMenu = class extends Component {
23642
23750
  super(null, options);
23643
23751
  this.#target = resolved;
23644
23752
  document.body.append(this.el);
23645
- this.#dropdown = new Dropdown(this.el, { openOn: "manual", placement: "bottom-start" });
23753
+ this.#dropdown = new Dropdown(this.el, {
23754
+ openOn: "manual",
23755
+ placement: "bottom-start",
23756
+ scope: resolved
23757
+ });
23646
23758
  this.#panel = this.#dropdown.getPanel();
23647
23759
  this.#panel.classList.add("zx-context-menu");
23648
23760
  this.#panel.setAttribute("role", "menu");
@@ -23902,6 +24014,29 @@ function calendarRange(view, anchor, options = {}) {
23902
24014
  }
23903
24015
  return { start, end };
23904
24016
  }
24017
+ function calendarPageDate(view, anchor, direction, options = {}) {
24018
+ const date = validDate5(anchor, "Calendar date");
24019
+ const step = integer(direction, "direction");
24020
+ if (view === "day") date.setDate(date.getDate() + step);
24021
+ else if (view === "agenda") {
24022
+ date.setDate(date.getDate() + step * positiveInteger(options.agendaDays ?? 14, "agendaDays"));
24023
+ } else if (view === "week") date.setDate(date.getDate() + step * 7);
24024
+ else if (view === "month") return addCalendarMonths(date, step);
24025
+ else if (view === "year") return addCalendarMonths(date, step * 12);
24026
+ else throw new RangeError(`Unknown calendar view: ${view}`);
24027
+ return date;
24028
+ }
24029
+ function addCalendarMonths(value, months) {
24030
+ const date = validDate5(value, "Calendar date");
24031
+ const day = date.getDate();
24032
+ date.setDate(1);
24033
+ date.setMonth(date.getMonth() + integer(months, "month amount"));
24034
+ date.setDate(Math.min(day, daysInCalendarMonth(date.getFullYear(), date.getMonth())));
24035
+ return date;
24036
+ }
24037
+ function daysInCalendarMonth(year, month) {
24038
+ return new Date(year, month + 1, 0).getDate();
24039
+ }
23905
24040
  function normalizeCalendarEvents(records, options = {}) {
23906
24041
  if (!Array.isArray(records)) throw new TypeError("Calendar events must be an array");
23907
24042
  const readers = {
@@ -24345,7 +24480,7 @@ var Calendar = class extends Component {
24345
24480
  durationEditable: Object.prototype.hasOwnProperty.call(changes, "durationEditable") ? changes.durationEditable : current.durationEditable,
24346
24481
  data: changes.data ?? current.data
24347
24482
  };
24348
- const [next] = normalizeCalendarEvents([source]);
24483
+ const [next] = normalizeCalendarEvents([source], { dateUnit: this.options.dateUnit });
24349
24484
  next.data = source.data;
24350
24485
  this._events = this._events.map((event, eventIndex) => eventIndex === index ? next : event);
24351
24486
  this._mutationVersions.delete(String(id));
@@ -24496,7 +24631,11 @@ var Calendar = class extends Component {
24496
24631
  }).sort(compareEvents);
24497
24632
  const row2 = h(
24498
24633
  "section",
24499
- { class: "zx-calendar__agenda-day", role: "row" },
24634
+ {
24635
+ class: "zx-calendar__agenda-day",
24636
+ role: "row",
24637
+ dataset: { date: calendarDayKey(day) }
24638
+ },
24500
24639
  h("button", {
24501
24640
  type: "button",
24502
24641
  class: "zx-calendar__agenda-heading",
@@ -24506,11 +24645,13 @@ var Calendar = class extends Component {
24506
24645
  disabled: this.options.disabled
24507
24646
  }, this._format(day, { weekday: "long", day: "numeric", month: "long" }))
24508
24647
  );
24648
+ const entries = h("div", { class: "zx-calendar__agenda-events", role: "gridcell" });
24509
24649
  if (!events.length) {
24510
- row2.append(h("p", { class: "zx-calendar__agenda-empty", role: "gridcell" }, this.msg("noEvents")));
24650
+ entries.append(h("p", { class: "zx-calendar__agenda-empty" }, this.msg("noEvents")));
24511
24651
  } else {
24512
- for (const event of events) row2.append(this._eventElement(event, { variant: "agenda", day }));
24652
+ for (const event of events) entries.append(this._eventElement(event, { variant: "agenda", day }));
24513
24653
  }
24654
+ row2.append(entries);
24514
24655
  grid2.append(row2);
24515
24656
  }
24516
24657
  return grid2;
@@ -24780,6 +24921,7 @@ var Calendar = class extends Component {
24780
24921
  dataset: {
24781
24922
  eventId: key,
24782
24923
  variant: context.variant,
24924
+ allDay: event.allDay ? "true" : null,
24783
24925
  draggable: this._canMove(event) ? "true" : null
24784
24926
  },
24785
24927
  style: event.color ? { "--zx-calendar-event-color": event.color } : null
@@ -25046,7 +25188,9 @@ var Calendar = class extends Component {
25046
25188
  const item = this._eventById(control.dataset.eventId);
25047
25189
  const action = control.dataset.eventAction === "resize" ? "resize" : "move";
25048
25190
  if (!item || (action === "move" ? !this._canMove(item) : !this._canResize(item))) return;
25049
- control.setPointerCapture?.(event.pointerId);
25191
+ event.preventDefault();
25192
+ this._capturePointer(event.pointerId);
25193
+ const offset = action === "move" ? this._dragOffsetForEvent(item, event.clientX, event.clientY) : null;
25050
25194
  this._pointer = {
25051
25195
  type: "event",
25052
25196
  pointerId: event.pointerId,
@@ -25056,6 +25200,8 @@ var Calendar = class extends Component {
25056
25200
  event: cloneCalendarEvent(item),
25057
25201
  action,
25058
25202
  control,
25203
+ timeOffsetMinutes: offset?.minutes ?? 0,
25204
+ dayOffset: offset?.days ?? 0,
25059
25205
  moved: false
25060
25206
  };
25061
25207
  return;
@@ -25064,7 +25210,8 @@ var Calendar = class extends Component {
25064
25210
  if (!slot || !this.options.selectable) return;
25065
25211
  const date = this._dateFromTarget(slot);
25066
25212
  if (!date) return;
25067
- slot.setPointerCapture?.(event.pointerId);
25213
+ event.preventDefault();
25214
+ this._capturePointer(event.pointerId);
25068
25215
  this._pointer = {
25069
25216
  type: "selection",
25070
25217
  pointerId: event.pointerId,
@@ -25097,7 +25244,13 @@ var Calendar = class extends Component {
25097
25244
  const wrapper = pointer.control.closest(".zx-calendar__event");
25098
25245
  if (wrapper) {
25099
25246
  wrapper.dataset.state = "dragging";
25100
- wrapper.style.transform = `translate(${event.clientX - pointer.startX}px, ${event.clientY - pointer.startY}px)`;
25247
+ if (pointer.action === "resize") {
25248
+ wrapper.style.transform = "";
25249
+ const height = this._timedPreviewHeight(proposed);
25250
+ if (height) wrapper.style.blockSize = height;
25251
+ } else {
25252
+ wrapper.style.transform = `translate(${event.clientX - pointer.startX}px, ${event.clientY - pointer.startY}px)`;
25253
+ }
25101
25254
  wrapper.dataset.preview = this._eventTime(proposed, proposed.start);
25102
25255
  }
25103
25256
  this._announce(this.msg(
@@ -25112,6 +25265,7 @@ var Calendar = class extends Component {
25112
25265
  const pointer = this._pointer;
25113
25266
  if (!pointer || pointer.pointerId !== event.pointerId) return;
25114
25267
  this._pointer = null;
25268
+ this._releasePointerCapture(pointer.pointerId);
25115
25269
  this._clearSelectionPreview();
25116
25270
  if (!pointer.moved) return;
25117
25271
  event.preventDefault();
@@ -25129,6 +25283,7 @@ var Calendar = class extends Component {
25129
25283
  _cancelPointer(event) {
25130
25284
  if (!this._pointer || this._pointer.pointerId !== event.pointerId) return;
25131
25285
  const control = this._pointer.control;
25286
+ this._releasePointerCapture(this._pointer.pointerId);
25132
25287
  this._pointer = null;
25133
25288
  this._clearSelectionPreview();
25134
25289
  control.closest?.(".zx-calendar__event")?.removeAttribute("data-state");
@@ -25136,13 +25291,14 @@ var Calendar = class extends Component {
25136
25291
  }
25137
25292
  /** @param {CalendarEvent} event @param {'move'|'resize'} action @param {number} x @param {number} y @returns {CalendarEvent|null} */
25138
25293
  _proposalAtPoint(event, action, x, y) {
25139
- const timed = this._timeAtPoint(x, y);
25294
+ const resizeTimed = action === "resize" && !calendarEventSpansDays(event);
25295
+ const timed = this._timeAtPoint(x, y, resizeTimed ? event.start : null);
25140
25296
  if (timed && !calendarEventSpansDays(event)) {
25141
25297
  if (action === "move") {
25142
25298
  const duration = event.end.getTime() - event.start.getTime();
25143
25299
  const next3 = cloneCalendarEvent(event);
25144
- next3.start = timed;
25145
- next3.end = new Date(timed.getTime() + duration);
25300
+ next3.start = addMinutes(timed, -Number(this._pointer?.timeOffsetMinutes ?? 0));
25301
+ next3.end = new Date(next3.start.getTime() + duration);
25146
25302
  return next3;
25147
25303
  }
25148
25304
  const next2 = cloneCalendarEvent(event);
@@ -25151,33 +25307,36 @@ var Calendar = class extends Component {
25151
25307
  }
25152
25308
  const day = this._dayAtPoint(x, y);
25153
25309
  if (!day) return null;
25154
- const difference = calendarDayDifference(startOfCalendarDay(event.start), day);
25310
+ const targetDay = addCalendarDays(day, -Number(this._pointer?.dayOffset ?? 0));
25311
+ const difference = calendarDayDifference(startOfCalendarDay(event.start), targetDay);
25155
25312
  if (action === "move") return shiftCalendarEvent(event, { days: difference, minutes: 0 });
25156
25313
  const next = cloneCalendarEvent(event);
25157
25314
  const endDay = addCalendarDays(day, 1);
25158
25315
  next.end = new Date(endDay.getTime());
25159
25316
  return next.end > next.start ? next : null;
25160
25317
  }
25161
- /** @param {number} x @param {number} y @returns {Date|null} */
25162
- _timeAtPoint(x, y) {
25318
+ /** @param {number} x @param {number} y @param {Date|null} [fixedDay=null] @returns {Date|null} */
25319
+ _timeAtPoint(x, y, fixedDay = null) {
25163
25320
  const slots = this.refs.body.querySelector(".zx-calendar__slots");
25164
25321
  if (!slots) return null;
25165
25322
  const rect = slots.getBoundingClientRect();
25166
- if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom || rect.width <= 0 || rect.height <= 0) return null;
25323
+ const scroller = slots.closest(".zx-calendar__time-scroller");
25324
+ const viewport = scroller?.getBoundingClientRect();
25325
+ const left = viewport ? Math.max(rect.left, viewport.left) : rect.left;
25326
+ const right = viewport ? Math.min(rect.right, viewport.right) : rect.right;
25327
+ const top = viewport ? Math.max(rect.top, viewport.top) : rect.top;
25328
+ const bottom = viewport ? Math.min(rect.bottom, viewport.bottom) : rect.bottom;
25329
+ if (x < left || x > right || y < top || y > bottom || rect.width <= 0 || rect.height <= 0) return null;
25167
25330
  const columns = Number(slots.closest('[role="grid"]')?.getAttribute("aria-colcount")) || 1;
25168
25331
  const rows = Number(slots.closest('[role="grid"]')?.getAttribute("aria-rowcount")) || 1;
25169
- const column = Math.max(0, Math.min(columns - 1, Math.floor((x - rect.left) / rect.width * columns)));
25332
+ const fixedColumn = fixedDay ? this._timeGridDayIndex(fixedDay) : -1;
25333
+ const column = fixedColumn >= 0 ? fixedColumn : Math.max(0, Math.min(columns - 1, Math.floor((x - rect.left) / rect.width * columns)));
25170
25334
  const row2 = Math.max(0, Math.min(rows - 1, Math.floor((y - rect.top) / rect.height * rows)));
25171
25335
  const target = slots.querySelector(`[data-row-index="${row2}"][data-day-index="${column}"]`);
25172
25336
  return target ? this._dateFromTarget(target) : null;
25173
25337
  }
25174
25338
  /** @param {number} x @param {number} y @returns {Date|null} */
25175
25339
  _dayAtPoint(x, y) {
25176
- const elements = typeof document.elementsFromPoint === "function" ? document.elementsFromPoint(x, y) : [];
25177
- for (const element of elements) {
25178
- const date = element instanceof HTMLElement ? element.dataset.date : null;
25179
- if (date) return parseDayKey(date);
25180
- }
25181
25340
  const allDay = this.refs.body.querySelector(".zx-calendar__all-day-grid");
25182
25341
  if (allDay) {
25183
25342
  const rect = allDay.getBoundingClientRect();
@@ -25187,8 +25346,72 @@ var Calendar = class extends Component {
25187
25346
  return parseDayKey(cells[index]?.dataset.date);
25188
25347
  }
25189
25348
  }
25349
+ for (const monthEvents of this.refs.body.querySelectorAll(".zx-calendar__month-events")) {
25350
+ const rect = monthEvents.getBoundingClientRect();
25351
+ if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom || rect.width <= 0) continue;
25352
+ const start = parseDayKey(monthEvents.closest(".zx-calendar__month-week")?.dataset.weekStart);
25353
+ if (!start) continue;
25354
+ const index = Math.max(0, Math.min(6, Math.floor((x - rect.left) / rect.width * 7)));
25355
+ return addCalendarDays(start, index);
25356
+ }
25357
+ for (const agendaDay of this.refs.body.querySelectorAll(".zx-calendar__agenda-day")) {
25358
+ const rect = agendaDay.getBoundingClientRect();
25359
+ if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) continue;
25360
+ return parseDayKey(agendaDay.dataset.date);
25361
+ }
25362
+ const elements = typeof document.elementsFromPoint === "function" ? document.elementsFromPoint(x, y) : [];
25363
+ for (const element of elements) {
25364
+ const dateTarget = element instanceof HTMLElement ? element.closest("[data-date]") : null;
25365
+ const date = dateTarget instanceof HTMLElement ? dateTarget.dataset.date : null;
25366
+ if (date) return parseDayKey(date);
25367
+ }
25190
25368
  return null;
25191
25369
  }
25370
+ /** @param {CalendarEvent} event @param {number} x @param {number} y @returns {{minutes: number, days: number}} */
25371
+ _dragOffsetForEvent(event, x, y) {
25372
+ if (!calendarEventSpansDays(event)) {
25373
+ const timed = this._timeAtPoint(x, y);
25374
+ if (timed) {
25375
+ const slot = normalizeSlot(this.options.slotDuration);
25376
+ const duration = Math.max(0, Math.round((event.end.getTime() - event.start.getTime()) / 6e4));
25377
+ const maxOffset = Math.max(0, duration - slot);
25378
+ const minutes = Math.round((timed.getTime() - event.start.getTime()) / 6e4);
25379
+ return { minutes: Math.max(0, Math.min(maxOffset, minutes)), days: 0 };
25380
+ }
25381
+ }
25382
+ const day = this._dayAtPoint(x, y);
25383
+ const days = day ? calendarDayDifference(startOfCalendarDay(event.start), day) : 0;
25384
+ return { minutes: 0, days: Math.max(0, days) };
25385
+ }
25386
+ /** @param {Date} day @returns {number} */
25387
+ _timeGridDayIndex(day) {
25388
+ const key = calendarDayKey(day);
25389
+ const headings = [...this.refs.body.querySelectorAll(".zx-calendar__day-heading")];
25390
+ return headings.findIndex((heading) => heading.dataset.date === key);
25391
+ }
25392
+ /** @param {CalendarEvent} event @returns {string|null} */
25393
+ _timedPreviewHeight(event) {
25394
+ const grid2 = this.refs.body.querySelector(".zx-calendar__time-grid");
25395
+ if (!grid2 || calendarEventSpansDays(event)) return null;
25396
+ const min = normalizeMinute(this.options.slotMinTime, 0);
25397
+ const max = normalizeMinute(this.options.slotMaxTime, 1440, true);
25398
+ const slot = normalizeSlot(this.options.slotDuration);
25399
+ const start = event.start.getHours() * 60 + event.start.getMinutes();
25400
+ const end = event.end.getHours() * 60 + event.end.getMinutes();
25401
+ const minutes = Math.max(slot, Math.min(max, end) - Math.max(min, start));
25402
+ return `${minutes / (max - min) * 100}%`;
25403
+ }
25404
+ /** @param {number} pointerId @returns {void} */
25405
+ _capturePointer(pointerId) {
25406
+ try {
25407
+ this.el.setPointerCapture?.(pointerId);
25408
+ } catch {
25409
+ }
25410
+ }
25411
+ /** @param {number} pointerId @returns {void} */
25412
+ _releasePointerCapture(pointerId) {
25413
+ if (this.el.hasPointerCapture?.(pointerId)) this.el.releasePointerCapture?.(pointerId);
25414
+ }
25192
25415
  /** @param {Date} start @param {Date} end @returns {void} */
25193
25416
  _previewSelection(start, end) {
25194
25417
  const low = Math.min(start.getTime(), end.getTime());
@@ -25283,13 +25506,9 @@ var Calendar = class extends Component {
25283
25506
  }
25284
25507
  /** @param {number} direction @returns {this} */
25285
25508
  _page(direction) {
25286
- const date = new Date(this._date.getTime());
25287
- if (this._view === "day") date.setDate(date.getDate() + direction);
25288
- else if (this._view === "agenda") date.setDate(date.getDate() + direction * this.options.agendaDays);
25289
- else if (this._view === "week") date.setDate(date.getDate() + direction * 7);
25290
- else if (this._view === "month") date.setMonth(date.getMonth() + direction);
25291
- else date.setFullYear(date.getFullYear() + direction);
25292
- return this.setDate(date);
25509
+ return this.setDate(calendarPageDate(this._view, this._date, direction, {
25510
+ agendaDays: this.options.agendaDays
25511
+ }));
25293
25512
  }
25294
25513
  /** @param {{start: Date, end: Date}} range @returns {string} */
25295
25514
  _rangeTitle(range) {
@@ -26446,6 +26665,16 @@ function createRecordCard(record, index, options = {}) {
26446
26665
  class: "zx-record-card__heading",
26447
26666
  id: headingId
26448
26667
  }, headingContent);
26668
+ const fieldGroups = partitionRecordCardFields(visibleFields, titleSource, subtitleSource);
26669
+ const eyebrow = createEyebrow(record, index, fieldGroups.eyebrowStart, fieldGroups.eyebrowEnd);
26670
+ const titlePrefix = createFieldList(
26671
+ record,
26672
+ index,
26673
+ fieldGroups.titlePrefix,
26674
+ "zx-record-card__title-prefix",
26675
+ "title-prefix"
26676
+ );
26677
+ const titleRow = titlePrefix ? h("div", { class: "zx-record-card__title-row" }, titlePrefix, heading) : heading;
26449
26678
  const selection = selectable === "multi" ? h("label", {
26450
26679
  class: "zx-record-card__selection"
26451
26680
  }, h("input", {
@@ -26467,19 +26696,19 @@ function createRecordCard(record, index, options = {}) {
26467
26696
  h(
26468
26697
  "div",
26469
26698
  { class: "zx-record-card__titles" },
26470
- heading,
26699
+ titleRow,
26471
26700
  isEmptyContent(subtitle) ? null : h("p", { class: "zx-record-card__subtitle" }, contentNode(subtitle))
26472
26701
  ),
26473
26702
  actionArea
26474
26703
  );
26475
- const metadata = createMetadata(record, index, visibleFields, titleSource, subtitleSource);
26476
- const body = h("div", { class: "zx-record-card__body" }, header, metadata);
26704
+ const metadata = createFieldList(record, index, fieldGroups.metadata, "zx-record-card__metadata");
26705
+ const body = h("div", { class: "zx-record-card__body" }, eyebrow, header, metadata);
26477
26706
  const card = (
26478
26707
  /** @type {HTMLLIElement} */
26479
26708
  h("li", {
26480
26709
  class: "zx-record-card",
26481
26710
  tabindex: 0,
26482
- ariaSelected: selectable ? String(selected) : void 0,
26711
+ ariaDescription: selectable ? selected ? "Selected" : "Not selected" : void 0,
26483
26712
  ariaLabelledby: headingId,
26484
26713
  dataset: {
26485
26714
  recordIndex: String(index),
@@ -26490,6 +26719,20 @@ function createRecordCard(record, index, options = {}) {
26490
26719
  );
26491
26720
  return card;
26492
26721
  }
26722
+ function partitionRecordCardFields(fields, titleSource = null, subtitleSource = null) {
26723
+ const groups = { eyebrowStart: [], eyebrowEnd: [], titlePrefix: [], metadata: [] };
26724
+ const titleId = typeof titleSource === "string" ? titleSource : null;
26725
+ const subtitleId = typeof subtitleSource === "string" ? subtitleSource : null;
26726
+ for (const field of Array.isArray(fields) ? fields : []) {
26727
+ if (!field.duplicate && (field.id === titleId || field.id === subtitleId)) continue;
26728
+ const slot = resolveRecordCardFieldSlot(field);
26729
+ if (slot === "eyebrow-start") groups.eyebrowStart.push(field);
26730
+ else if (slot === "eyebrow-end") groups.eyebrowEnd.push(field);
26731
+ else if (slot === "title-prefix") groups.titlePrefix.push(field);
26732
+ else groups.metadata.push(field);
26733
+ }
26734
+ return groups;
26735
+ }
26493
26736
  function resolveRecordPreview(record, index, preview, previewAlt, fields = []) {
26494
26737
  if (preview == null) return null;
26495
26738
  const candidate = typeof preview === "function" ? preview(record, index) : readRecordSource(record, index, preview, fields, false);
@@ -26558,21 +26801,49 @@ function createPreview(preview) {
26558
26801
  dataset: { failed: String(preview.rejected) }
26559
26802
  }, fallback, image);
26560
26803
  }
26561
- function createMetadata(record, index, fields, titleSource, subtitleSource) {
26562
- const titleId = typeof titleSource === "string" ? titleSource : null;
26563
- const subtitleId = typeof subtitleSource === "string" ? subtitleSource : null;
26564
- const visible = fields.filter((field) => field.duplicate || field.id !== titleId && field.id !== subtitleId);
26565
- if (visible.length === 0) return null;
26566
- return h("dl", { class: "zx-record-card__metadata" }, visible.map((field) => h(
26804
+ function createEyebrow(record, index, eyebrowStart, eyebrowEnd) {
26805
+ const start = createFieldList(
26806
+ record,
26807
+ index,
26808
+ eyebrowStart,
26809
+ "zx-record-card__eyebrow-group",
26810
+ "eyebrow-start"
26811
+ );
26812
+ const end = createFieldList(
26813
+ record,
26814
+ index,
26815
+ eyebrowEnd,
26816
+ "zx-record-card__eyebrow-group",
26817
+ "eyebrow-end"
26818
+ );
26819
+ return start || end ? h("div", { class: "zx-record-card__eyebrow" }, start, end) : null;
26820
+ }
26821
+ function createFieldList(record, index, fields, className, slot = null) {
26822
+ if (fields.length === 0) return null;
26823
+ return h("dl", {
26824
+ class: className,
26825
+ dataset: slot ? { cardSlot: slot } : void 0
26826
+ }, fields.map((field) => h(
26567
26827
  "div",
26568
26828
  {
26569
26829
  class: "zx-record-card__field",
26570
- dataset: { fieldId: field.id }
26830
+ dataset: { fieldId: field.id, ...slot ? { cardSlot: slot } : {} }
26571
26831
  },
26572
26832
  h("dt", { class: "zx-record-card__label" }, field.label),
26573
26833
  h("dd", { class: "zx-record-card__value" }, contentNode(renderViewField(field, record, index)))
26574
26834
  )));
26575
26835
  }
26836
+ function resolveRecordCardFieldSlot(field) {
26837
+ const view = field?.view;
26838
+ if (!view || typeof view !== "object" || Array.isArray(view)) return null;
26839
+ const card = view.card;
26840
+ if (!card || typeof card !== "object" || Array.isArray(card)) return null;
26841
+ const slot = (
26842
+ /** @type {{slot?:unknown}} */
26843
+ card.slot
26844
+ );
26845
+ return slot === "eyebrow-start" || slot === "eyebrow-end" || slot === "title-prefix" ? slot : null;
26846
+ }
26576
26847
  function createAction(action) {
26577
26848
  const label = String(action.label ?? "");
26578
26849
  const title = String(action.title ?? (label || action.id));
@@ -26642,7 +26913,7 @@ function safePreviewUrl(value) {
26642
26913
  try {
26643
26914
  const base = globalThis.location?.href ?? "https://zx.invalid/";
26644
26915
  const protocol = new URL(href, base).protocol.toLowerCase();
26645
- return ["http:", "https:", "blob:", "file:"].includes(protocol) ? href : null;
26916
+ return ["http:", "https:", "blob:"].includes(protocol) ? href : null;
26646
26917
  } catch {
26647
26918
  return null;
26648
26919
  }
@@ -26686,7 +26957,7 @@ var CardView = class extends RecordView {
26686
26957
  actions: [],
26687
26958
  groupBy: null,
26688
26959
  groupOrder: [],
26689
- minCardWidth: "18rem",
26960
+ minCardWidth: "15rem",
26690
26961
  maxColumns: null,
26691
26962
  variant: "outlined",
26692
26963
  loadingCount: 6,
@@ -26960,7 +27231,7 @@ var CardView = class extends RecordView {
26960
27231
  for (const [id, card] of this._cardNodesById) {
26961
27232
  const selected = this._isViewSelected(id);
26962
27233
  card.dataset.selected = String(selected);
26963
- if (this.options.selectable) card.setAttribute("aria-selected", String(selected));
27234
+ if (this.options.selectable) card.setAttribute("aria-description", selected ? "Selected" : "Not selected");
26964
27235
  const checkbox = (
26965
27236
  /** @type {HTMLInputElement|null} */
26966
27237
  card.querySelector("[data-record-selection]")
@@ -27104,11 +27375,11 @@ function normalizeGroupId(value) {
27104
27375
  return value == null || value === "" ? "" : String(value);
27105
27376
  }
27106
27377
  function normalizeCardWidth(value) {
27107
- if (typeof value === "number") return Number.isFinite(value) && value > 0 ? `${value}px` : "18rem";
27378
+ if (typeof value === "number") return Number.isFinite(value) && value > 0 ? `${value}px` : "15rem";
27108
27379
  const text4 = String(value ?? "").trim();
27109
- if (!text4 || text4.length > 128 || /[;{}\u0000-\u001f\u007f]/.test(text4)) return "18rem";
27110
- if (/(?:url|image|image-set|cross-fade|element|expression)\s*\(/i.test(text4)) return "18rem";
27111
- if (typeof globalThis.CSS?.supports === "function" && !globalThis.CSS.supports("width", text4)) return "18rem";
27380
+ if (!text4 || text4.length > 128 || /[;{}\u0000-\u001f\u007f]/.test(text4)) return "15rem";
27381
+ if (/(?:url|image|image-set|cross-fade|element|expression)\s*\(/i.test(text4)) return "15rem";
27382
+ if (typeof globalThis.CSS?.supports === "function" && !globalThis.CSS.supports("width", text4)) return "15rem";
27112
27383
  return text4;
27113
27384
  }
27114
27385
  function normalizeMaxColumns(value) {
@@ -27146,8 +27417,284 @@ function safeDomPart(value) {
27146
27417
  return value.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "empty";
27147
27418
  }
27148
27419
 
27420
+ // src/components/kanban-view/kanban-policy.js
27421
+ var RULE_TONES = Object.freeze(["neutral", "accent", "success", "warning", "danger", "info"]);
27422
+ function normalizeKanbanRules(rules) {
27423
+ if (rules == null) return [];
27424
+ if (!Array.isArray(rules)) throw new TypeError("Kanban rules must be an array or null");
27425
+ const seen = /* @__PURE__ */ new Set();
27426
+ return rules.map((rule, index) => {
27427
+ if (!rule || typeof rule !== "object" || Array.isArray(rule)) {
27428
+ throw new TypeError(`Kanban rule at index ${index} must be an object`);
27429
+ }
27430
+ const source = (
27431
+ /** @type {Record<string, any>} */
27432
+ rule
27433
+ );
27434
+ const id = String(source.id ?? "").trim();
27435
+ if (!id) throw new TypeError(`Kanban rule at index ${index} requires an id`);
27436
+ if (seen.has(id)) throw new TypeError(`Duplicate Kanban rule: ${id}`);
27437
+ if (typeof source.when !== "function") {
27438
+ throw new TypeError(`Kanban rule ${id} requires a when predicate`);
27439
+ }
27440
+ seen.add(id);
27441
+ return {
27442
+ id,
27443
+ when: source.when,
27444
+ tone: RULE_TONES.includes(source.tone) ? source.tone : "accent",
27445
+ label: source.label == null ? "" : String(source.label),
27446
+ icon: source.icon == null ? null : String(source.icon),
27447
+ description: source.description == null ? "" : String(source.description)
27448
+ };
27449
+ });
27450
+ }
27451
+ function resolveKanbanRules(rules, record, context) {
27452
+ if (!Array.isArray(rules) || !rules.length) return [];
27453
+ return rules.filter((rule) => {
27454
+ try {
27455
+ return rule.when(record, context) === true;
27456
+ } catch {
27457
+ return false;
27458
+ }
27459
+ });
27460
+ }
27461
+ function evaluateKanbanMove(input) {
27462
+ const { records, column, swimlane, context, from } = input;
27463
+ const moving = Math.max(1, records.length);
27464
+ const policy = column?.wipPolicy === "block" || column?.wipPolicy === "warn" ? column.wipPolicy : input.policy === "block" ? "block" : "warn";
27465
+ const refuse = (reason, extra = {}) => ({
27466
+ allowed: false,
27467
+ reason,
27468
+ limit: null,
27469
+ count: 0,
27470
+ limitExceeded: false,
27471
+ limitReached: false,
27472
+ policy,
27473
+ ...extra
27474
+ });
27475
+ const blockedTransition = allowsKanbanTransition(column, swimlane, from);
27476
+ if (blockedTransition) return refuse(blockedTransition);
27477
+ for (const record of records) {
27478
+ if (column.accept && column.accept(record, context) === false) return refuse("accept");
27479
+ if (swimlane?.accept && swimlane.accept(record, context) === false) return refuse("lane-accept");
27480
+ }
27481
+ const capacities = [];
27482
+ if (column.limit != null) {
27483
+ capacities.push({ reason: "wip", limit: column.limit, count: input.columnCount + moving });
27484
+ }
27485
+ const cellLimit = swimlane ? readLaneLimit(column.laneLimits, swimlane.id) : null;
27486
+ if (cellLimit != null) {
27487
+ capacities.push({ reason: "wip", limit: cellLimit, count: input.cellCount + moving });
27488
+ }
27489
+ if (swimlane?.limit != null) {
27490
+ capacities.push({ reason: "lane-wip", limit: swimlane.limit, count: input.laneCount + moving });
27491
+ }
27492
+ const violated = capacities.find((capacity) => capacity.count > capacity.limit);
27493
+ const reported = violated ?? capacities.find((capacity) => capacity.count === capacity.limit) ?? capacities[0];
27494
+ if (violated && policy === "block") {
27495
+ return refuse(violated.reason, {
27496
+ limit: violated.limit,
27497
+ count: violated.count,
27498
+ limitExceeded: true
27499
+ });
27500
+ }
27501
+ return {
27502
+ allowed: true,
27503
+ reason: null,
27504
+ limit: reported ? reported.limit : null,
27505
+ count: reported ? reported.count : 0,
27506
+ limitExceeded: Boolean(violated),
27507
+ limitReached: Boolean(reported && !violated && reported.count === reported.limit),
27508
+ policy
27509
+ };
27510
+ }
27511
+ function allowsKanbanTransition(column, swimlane, from) {
27512
+ if (column.id !== from.column && !allowsOrigin(column.from, from.column)) return "transition";
27513
+ if (swimlane && swimlane.id !== from.lane && !allowsOrigin(swimlane.from, from.lane)) {
27514
+ return "lane-transition";
27515
+ }
27516
+ return null;
27517
+ }
27518
+ function kanbanSearchTerms(search) {
27519
+ const text4 = search == null ? "" : String(search).trim().toLowerCase();
27520
+ if (!text4) return [];
27521
+ return [...text4.matchAll(/"([^"]*)"|(\S+)/g)].map((match) => (match[1] ?? match[2] ?? "").trim()).filter(Boolean);
27522
+ }
27523
+ function matchesKanbanSearch(text4, terms) {
27524
+ if (!terms.length) return true;
27525
+ const haystack = text4.toLowerCase();
27526
+ return terms.every((term) => haystack.includes(term));
27527
+ }
27528
+ function createKanbanHistory(limit = 50) {
27529
+ const max = Number.isFinite(Number(limit)) && Number(limit) > 0 ? Math.floor(Number(limit)) : 50;
27530
+ let undoStack = [];
27531
+ let redoStack = [];
27532
+ return {
27533
+ push(entry) {
27534
+ undoStack.push(entry);
27535
+ if (undoStack.length > max) undoStack = undoStack.slice(-max);
27536
+ redoStack = [];
27537
+ },
27538
+ undo(current) {
27539
+ const entry = undoStack.pop();
27540
+ if (!entry) return null;
27541
+ redoStack.push(current);
27542
+ if (redoStack.length > max) redoStack = redoStack.slice(-max);
27543
+ return entry;
27544
+ },
27545
+ redo(current) {
27546
+ const entry = redoStack.pop();
27547
+ if (!entry) return null;
27548
+ undoStack.push(current);
27549
+ if (undoStack.length > max) undoStack = undoStack.slice(-max);
27550
+ return entry;
27551
+ },
27552
+ canUndo: () => undoStack.length > 0,
27553
+ canRedo: () => redoStack.length > 0,
27554
+ clear() {
27555
+ undoStack = [];
27556
+ redoStack = [];
27557
+ },
27558
+ depth: () => ({ undo: undoStack.length, redo: redoStack.length })
27559
+ };
27560
+ }
27561
+ function reorderKanbanRecords(records, entries, options) {
27562
+ if (!Array.isArray(records) || !Array.isArray(entries) || !entries.length) return null;
27563
+ const sources = entries.map((entry) => ({
27564
+ ...entry,
27565
+ index: records.findIndex((record) => Object.is(viewRecordId(record, options.recordId), entry.id))
27566
+ }));
27567
+ if (sources.some((source) => source.index < 0 || !source.record || typeof source.record !== "object")) {
27568
+ return null;
27569
+ }
27570
+ sources.sort((left, right) => left.index - right.index);
27571
+ const moves = sources.map((source) => ({
27572
+ id: source.id,
27573
+ from: locateKanbanRecord(records, source.index, options)
27574
+ }));
27575
+ const movingIndices = new Set(sources.map((source) => source.index));
27576
+ const next = records.filter((_, index) => !movingIndices.has(index));
27577
+ 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));
27578
+ const targetIndex = clampKanbanIndex(options.destination.index, destinationRecords.length);
27579
+ let insertion = next.length;
27580
+ if (destinationRecords[targetIndex]) insertion = next.indexOf(destinationRecords[targetIndex]);
27581
+ else if (destinationRecords.length) insertion = next.indexOf(destinationRecords.at(-1)) + 1;
27582
+ next.splice(insertion, 0, ...sources.map((source) => source.record));
27583
+ return {
27584
+ records: next,
27585
+ moves,
27586
+ to: {
27587
+ column: options.destination.column,
27588
+ lane: options.swimlaneBy == null ? null : options.destination.lane,
27589
+ index: targetIndex
27590
+ }
27591
+ };
27592
+ }
27593
+ function locateKanbanRecord(records, index, options) {
27594
+ const record = records[index];
27595
+ const column = normalizeAxisId(readKanbanAccessor(record, index, options.columnBy));
27596
+ const lane = options.swimlaneBy == null ? null : normalizeAxisId(readKanbanAccessor(record, index, options.swimlaneBy));
27597
+ const group = records.filter((candidate, candidateIndex) => normalizeAxisId(readKanbanAccessor(candidate, candidateIndex, options.columnBy)) === column && (options.swimlaneBy == null || normalizeAxisId(readKanbanAccessor(candidate, candidateIndex, options.swimlaneBy)) === lane));
27598
+ const id = viewRecordId(record, options.recordId);
27599
+ return {
27600
+ column,
27601
+ lane,
27602
+ index: group.findIndex((candidate) => Object.is(viewRecordId(candidate, options.recordId), id))
27603
+ };
27604
+ }
27605
+ function absoluteKanbanIndex(visibility, visibleIndex) {
27606
+ const target = clampKanbanIndex(visibleIndex, visibility.filter(Boolean).length);
27607
+ let seen = 0;
27608
+ for (let index = 0; index < visibility.length; index += 1) {
27609
+ if (!visibility[index]) continue;
27610
+ if (seen === target) return index;
27611
+ seen += 1;
27612
+ }
27613
+ return visibility.length;
27614
+ }
27615
+ function readKanbanAccessor(record, index, accessor) {
27616
+ return typeof accessor === "function" ? accessor(record, index) : accessor == null ? null : record?.[accessor];
27617
+ }
27618
+ function normalizeAxisId(value) {
27619
+ return value == null ? "" : String(value);
27620
+ }
27621
+ function clampKanbanIndex(value, maximum) {
27622
+ if (value == null || !Number.isFinite(Number(value))) return maximum;
27623
+ return Math.max(0, Math.min(maximum, Math.trunc(Number(value))));
27624
+ }
27625
+ function allowsOrigin(allowed, origin) {
27626
+ if (allowed == null) return true;
27627
+ if (!Array.isArray(allowed)) return true;
27628
+ if (!allowed.length) return false;
27629
+ return allowed.some((entry) => String(entry) === "*" || String(entry) === normalizeAxisId(origin));
27630
+ }
27631
+ function readLaneLimit(laneLimits, lane) {
27632
+ if (!laneLimits || typeof laneLimits !== "object") return null;
27633
+ const value = (
27634
+ /** @type {Record<string, unknown>} */
27635
+ laneLimits[lane]
27636
+ );
27637
+ if (value == null || value === "") return null;
27638
+ const number2 = Number(value);
27639
+ return Number.isFinite(number2) && number2 >= 0 ? Math.floor(number2) : null;
27640
+ }
27641
+
27149
27642
  // src/components/kanban-view/kanban-view.js
27150
27643
  var INTERACTIVE = 'a, button, input, select, textarea, summary, [contenteditable="true"]';
27644
+ var DRAG_THRESHOLD = 4;
27645
+ var TOUCH_HOLD = 350;
27646
+ var SEARCH_DEBOUNCE = 120;
27647
+ var SCROLL_EDGE = 56;
27648
+ var SCROLL_SPEED = 18;
27649
+ var DEFAULT_LABELS = Object.freeze({
27650
+ fields: "Fields",
27651
+ search: "Search board",
27652
+ searchPlaceholder: "Search cards",
27653
+ clearSearch: "Clear search",
27654
+ searchSummary: "%count% of %total% cards match.",
27655
+ undo: "Undo",
27656
+ redo: "Redo",
27657
+ add: "Add card",
27658
+ addTo: "Add card to %target%",
27659
+ move: "Move %name%",
27660
+ moveMultiple: "Move %count% selected cards",
27661
+ collapseColumn: "Collapse column %label%",
27662
+ expandColumn: "Expand column %label%",
27663
+ collapseLane: "Collapse swim lane %label%",
27664
+ expandLane: "Expand swim lane %label%",
27665
+ count: "%count% records",
27666
+ countFiltered: "%count% of %total% records",
27667
+ countLimit: "%count% records, work-in-progress limit %limit%",
27668
+ countLimitInLane: "%count% records in this lane, %total% of work-in-progress limit %limit% in the column",
27669
+ empty: "No records",
27670
+ columnEmpty: "",
27671
+ selected: "Selected",
27672
+ notSelected: "Not selected",
27673
+ noMatches: "No cards match the current search.",
27674
+ record: "record",
27675
+ grabbed: "Grabbed %name%. Use left and right for columns, up and down for position, and Alt plus up or down for swim lanes.",
27676
+ grabbedMultiple: "Grabbed %count% cards. Use left and right for columns, up and down for position, and Alt plus up or down for swim lanes.",
27677
+ target: "Target %target%, position %position% of %total%.",
27678
+ moved: "Moved %name% to %target%.%wip%",
27679
+ movedMultiple: "Moved %count% cards to %target%.%wip%",
27680
+ moveRequested: "Move requested for %name% to %target%.%wip%",
27681
+ moveCanceled: "Move canceled for %name%.",
27682
+ moveCanceledPlain: "Move canceled.",
27683
+ limitReached: " Work-in-progress limit %limit% reached.",
27684
+ limitExceeded: " Work-in-progress limit %limit% exceeded.",
27685
+ rejectAccept: "Move rejected: %target% does not accept %name%.",
27686
+ rejectTransition: "Move rejected: %target% does not accept cards from %origin%.",
27687
+ rejectWip: "Move rejected: %target% is at its work-in-progress limit of %limit%.",
27688
+ rejectDestination: "Move rejected: the destination is not available.",
27689
+ rejectGrouping: "Move rejected: callback grouping requires external move mode.",
27690
+ undone: "Move undone.",
27691
+ redone: "Move redone.",
27692
+ nothingToUndo: "Nothing to undo.",
27693
+ nothingToRedo: "Nothing to redo.",
27694
+ menuMoveTo: "Move to %target%",
27695
+ menuMoveToLane: "Move to lane %target%",
27696
+ dragPreview: "%count% cards"
27697
+ });
27151
27698
  var KanbanView = class extends RecordView {
27152
27699
  static cssName = "kanban-view";
27153
27700
  /** @type {Readonly<KanbanViewOptions & RecordViewOptions>} */
@@ -27164,6 +27711,27 @@ var KanbanView = class extends RecordView {
27164
27711
  link: null,
27165
27712
  actions: [],
27166
27713
  moveMode: "local",
27714
+ dragFrom: "card",
27715
+ multiMove: true,
27716
+ autoScroll: true,
27717
+ wipPolicy: "warn",
27718
+ rules: [],
27719
+ search: "",
27720
+ searchFields: null,
27721
+ filter: null,
27722
+ searchControl: false,
27723
+ history: true,
27724
+ historyLimit: 50,
27725
+ historyControls: false,
27726
+ columnHeight: null,
27727
+ allowAdd: false,
27728
+ contextMenu: false,
27729
+ renderCard: null,
27730
+ renderColumnHeader: null,
27731
+ renderSwimlaneHeader: null,
27732
+ renderColumnEmpty: null,
27733
+ renderDragPreview: null,
27734
+ labels: {},
27167
27735
  columnOrder: [],
27168
27736
  swimlaneOrder: [],
27169
27737
  collapsedColumns: [],
@@ -27199,17 +27767,45 @@ var KanbanView = class extends RecordView {
27199
27767
  this._kanbanSwimlaneOrder = normalizeOrder(options.swimlaneOrder);
27200
27768
  this._collapsedColumns = new Set(normalizeOrder(options.collapsedColumns));
27201
27769
  this._collapsedSwimlanes = new Set(normalizeOrder(options.collapsedSwimlanes));
27770
+ this._kanbanRules = normalizeKanbanRules(options.rules);
27771
+ this._kanbanSearch = options.search == null ? "" : String(options.search);
27772
+ this._kanbanFilter = typeof options.filter === "function" ? options.filter : null;
27773
+ this._kanbanHistory = createKanbanHistory(options.historyLimit);
27202
27774
  this._keyboardMove = /** @type {KanbanKeyboardMove|null} */
27203
27775
  null;
27204
- this._dragId = null;
27776
+ this._dragIds = /** @type {unknown[]} */
27777
+ [];
27205
27778
  this._dragDestination = /** @type {KanbanMovePoint|null} */
27206
27779
  null;
27780
+ this._pointerStart = /** @type {{x:number,y:number,id:number,ids:unknown[],touch:boolean}|null} */
27781
+ null;
27782
+ this._pointerHold = 0;
27783
+ this._dragFrame = 0;
27784
+ this._dragPreview = /** @type {HTMLElement|null} */
27785
+ null;
27786
+ this._dragPointer = /** @type {number|null} */
27787
+ null;
27788
+ this._dragCaptured = false;
27789
+ this._searchTimer = 0;
27790
+ this._dragPoint = { x: 0, y: 0 };
27791
+ this._suppressClick = false;
27207
27792
  this._announcementToken = 0;
27208
27793
  this._cardMeta = /* @__PURE__ */ new WeakMap();
27209
27794
  this._sectionMeta = /* @__PURE__ */ new WeakMap();
27210
27795
  this._cardsById = /* @__PURE__ */ new Map();
27796
+ this._visibleIds = /* @__PURE__ */ new Set();
27797
+ this._kanbanMenu = /** @type {ContextMenu|null} */
27798
+ null;
27799
+ this._searchInput = /** @type {HTMLInputElement|null} */
27800
+ null;
27801
+ this._undoButton = /** @type {HTMLButtonElement|null} */
27802
+ null;
27803
+ this._redoButton = /** @type {HTMLButtonElement|null} */
27804
+ null;
27211
27805
  this._toolbar = h("div", { class: "zx-record-view__toolbar zx-kanban-view__toolbar" });
27212
- if (options.fieldControls) this._toolbar.append(this._createViewFieldControls());
27806
+ if (options.fieldControls) this._toolbar.append(this._createViewFieldControls(this._text("fields")));
27807
+ if (options.searchControl) this._toolbar.append(this._createSearchControl());
27808
+ if (options.historyControls) this._toolbar.append(this._createHistoryControls());
27213
27809
  this._board = h("div", {
27214
27810
  class: "zx-kanban-view__board",
27215
27811
  role: "region",
@@ -27222,6 +27818,10 @@ var KanbanView = class extends RecordView {
27222
27818
  ariaAtomic: "true"
27223
27819
  });
27224
27820
  root.replaceChildren(this._toolbar, this._board, this._live);
27821
+ if (options.columnHeight) {
27822
+ root.style.setProperty("--zx-kanban-column-height", String(options.columnHeight));
27823
+ root.dataset.columnScroll = "true";
27824
+ }
27225
27825
  this.listen(this._board, "click", (event) => this._handleBoardClick(
27226
27826
  /** @type {MouseEvent} */
27227
27827
  event
@@ -27235,20 +27835,22 @@ var KanbanView = class extends RecordView {
27235
27835
  event
27236
27836
  ));
27237
27837
  this.listen(this._board, "error", (event) => this._handlePreviewError(event), { capture: true });
27238
- this.listen(this._board, "dragstart", (event) => this._handleDragStart(
27239
- /** @type {DragEvent} */
27838
+ this.listen(this._board, "pointerdown", (event) => this._handlePointerDown(
27839
+ /** @type {PointerEvent} */
27240
27840
  event
27241
27841
  ));
27242
- this.listen(this._board, "dragover", (event) => this._handleDragOver(
27243
- /** @type {DragEvent} */
27842
+ this.listen(this._board, "pointermove", (event) => this._handlePointerMove(
27843
+ /** @type {PointerEvent} */
27244
27844
  event
27245
27845
  ));
27246
- this.listen(this._board, "drop", (event) => this._handleDrop(
27247
- /** @type {DragEvent} */
27846
+ this.listen(this._board, "pointerup", (event) => this._handlePointerUp(
27847
+ /** @type {PointerEvent} */
27248
27848
  event
27249
27849
  ));
27250
- this.listen(this._board, "dragend", () => this._clearDrag());
27850
+ this.listen(this._board, "pointercancel", () => this._clearDrag());
27851
+ this.listen(this._board, "dragstart", (event) => event.preventDefault());
27251
27852
  this._refreshView("init");
27853
+ if (options.contextMenu) this._createContextMenu();
27252
27854
  return root;
27253
27855
  } catch (error2) {
27254
27856
  if (this._kanbanSnapshot) restoreTarget(root, this._kanbanSnapshot);
@@ -27266,7 +27868,16 @@ var KanbanView = class extends RecordView {
27266
27868
  */
27267
27869
  setColumns(columns) {
27268
27870
  this._kanbanColumns = columns === null ? null : normalizeKanbanDescriptors(columns, "column");
27269
- this._kanbanColumnOrder = reconcileKanbanOrder(this._rawColumns(), this._kanbanColumnOrder);
27871
+ const descriptors = this._rawColumns();
27872
+ this._kanbanColumnOrder = reconcileKanbanPreference(
27873
+ descriptors,
27874
+ this._kanbanColumnOrder,
27875
+ this._kanbanColumns === null
27876
+ );
27877
+ if (this._kanbanColumns !== null) {
27878
+ const ids = new Set(descriptors.map((column) => column.id));
27879
+ this._collapsedColumns = new Set([...this._collapsedColumns].filter((id) => ids.has(id)));
27880
+ }
27270
27881
  this._refreshView("columns");
27271
27882
  this._emitKanbanState("columns");
27272
27883
  return this;
@@ -27282,7 +27893,17 @@ var KanbanView = class extends RecordView {
27282
27893
  */
27283
27894
  setSwimlanes(lanes) {
27284
27895
  this._kanbanSwimlanes = lanes === null ? null : normalizeKanbanDescriptors(lanes, "swimlane");
27285
- this._kanbanSwimlaneOrder = reconcileKanbanOrder(this._rawSwimlanes(), this._kanbanSwimlaneOrder);
27896
+ const descriptors = this._rawSwimlanes();
27897
+ const preserveUnknown = this._kanbanSwimlanes === null && this._kanbanOptions().swimlaneBy != null;
27898
+ this._kanbanSwimlaneOrder = reconcileKanbanPreference(
27899
+ descriptors,
27900
+ this._kanbanSwimlaneOrder,
27901
+ preserveUnknown
27902
+ );
27903
+ if (!preserveUnknown) {
27904
+ const ids = new Set(descriptors.map((lane) => lane.id));
27905
+ this._collapsedSwimlanes = new Set([...this._collapsedSwimlanes].filter((id) => ids.has(id)));
27906
+ }
27286
27907
  this._refreshView("swimlanes");
27287
27908
  this._emitKanbanState("swimlanes");
27288
27909
  return this;
@@ -27298,9 +27919,16 @@ var KanbanView = class extends RecordView {
27298
27919
  * @returns {this}
27299
27920
  */
27300
27921
  setColumnOrder(order, options = {}) {
27301
- const next = reconcileKanbanOrder(this._rawColumns(), order);
27302
- if (sameArray3(next, this.getColumnOrder())) return this;
27303
- this._kanbanColumnOrder = next;
27922
+ const descriptors = this._rawColumns();
27923
+ const current = this.getColumnOrder();
27924
+ const next = reconcileKanbanOrder(descriptors, order);
27925
+ if (sameArray3(next, current)) return this;
27926
+ this._kanbanColumnOrder = mergeKanbanResolvedOrder(
27927
+ this._kanbanColumnOrder,
27928
+ descriptors,
27929
+ next,
27930
+ this._kanbanColumns === null
27931
+ );
27304
27932
  this._refreshView("columnorder");
27305
27933
  if (!options.silent) this._emitKanbanState("columnorder");
27306
27934
  return this;
@@ -27326,9 +27954,17 @@ var KanbanView = class extends RecordView {
27326
27954
  * @returns {this}
27327
27955
  */
27328
27956
  setSwimlaneOrder(order, options = {}) {
27329
- const next = reconcileKanbanOrder(this._rawSwimlanes(), order);
27330
- if (sameArray3(next, this.getSwimlaneOrder())) return this;
27331
- this._kanbanSwimlaneOrder = next;
27957
+ const descriptors = this._rawSwimlanes();
27958
+ const current = this.getSwimlaneOrder();
27959
+ const next = reconcileKanbanOrder(descriptors, order);
27960
+ if (sameArray3(next, current)) return this;
27961
+ const preserveUnknown = this._kanbanSwimlanes === null && this._kanbanOptions().swimlaneBy != null;
27962
+ this._kanbanSwimlaneOrder = mergeKanbanResolvedOrder(
27963
+ this._kanbanSwimlaneOrder,
27964
+ descriptors,
27965
+ next,
27966
+ preserveUnknown
27967
+ );
27332
27968
  this._refreshView("swimlaneorder");
27333
27969
  if (!options.silent) this._emitKanbanState("swimlaneorder");
27334
27970
  return this;
@@ -27396,126 +28032,292 @@ var KanbanView = class extends RecordView {
27396
28032
  return this.setSwimlaneCollapsed(id, !this._collapsedSwimlanes.has(String(id)));
27397
28033
  }
27398
28034
  /**
27399
- * Proposes and, in local mode, commits a record move. Destination acceptance is checked before
27400
- * a cancelable `recordmove` event. WIP limits are reported but remain advisory.
28035
+ * Proposes and, in local mode, commits a record move. Equivalent to `moveRecords([id], …)`.
27401
28036
  * @param {unknown} id Record id.
27402
28037
  * @param {KanbanDestination} destination Destination.
27403
28038
  * @returns {this}
27404
28039
  * @fires KanbanView#recordmove
28040
+ * @fires KanbanView#movereject
27405
28041
  * @fires RecordView#datachange
27406
28042
  */
27407
28043
  moveRecord(id, destination) {
28044
+ return this.moveRecords([id], destination);
28045
+ }
28046
+ /**
28047
+ * Proposes and, in local mode, commits an atomic move of one or more records. Destination
28048
+ * eligibility, transition rules, and work-in-progress policy are all checked before a single
28049
+ * cancelable `recordmove` event; the move commits completely or not at all, and the moving
28050
+ * records keep their relative order at the destination.
28051
+ * @param {unknown[]} ids Record ids, in any order.
28052
+ * @param {KanbanDestination} destination Destination.
28053
+ * @returns {this}
28054
+ * @fires KanbanView#recordmove
28055
+ * @fires KanbanView#movereject
28056
+ * @fires RecordView#datachange
28057
+ */
28058
+ moveRecords(ids, destination) {
27408
28059
  const options = this._kanbanOptions();
27409
- const record = this.getRecord(id);
27410
- const from = this._recordLocation(id);
27411
- if (!record || !from || !destination || typeof destination !== "object") return this;
28060
+ if (!Array.isArray(ids) || !destination || typeof destination !== "object") return this;
28061
+ const moving = this._orderedIds(ids);
28062
+ if (!moving.length) return this;
28063
+ const records = moving.map((id) => (
28064
+ /** @type {KanbanRecord} */
28065
+ this.getRecord(id)
28066
+ ));
28067
+ const origins = moving.map((id) => (
28068
+ /** @type {KanbanMovePoint} */
28069
+ this._recordLocation(id)
28070
+ ));
28071
+ const from = origins[0];
27412
28072
  const columns = this._resolvedColumns();
27413
28073
  const lanes = this._resolvedSwimlanes();
27414
28074
  const columnId = destination.column == null ? from.column : String(destination.column);
27415
28075
  const laneId = options.swimlaneBy == null ? null : destination.lane === void 0 ? from.lane : normalizeAxisId(destination.lane);
27416
28076
  const column = columns.find((candidate) => candidate.id === columnId);
27417
- const lane = options.swimlaneBy == null ? null : lanes.find((candidate) => candidate.id === laneId);
28077
+ const lane = options.swimlaneBy == null ? null : lanes.find((candidate) => candidate.id === laneId) ?? null;
27418
28078
  if (!column || options.swimlaneBy != null && !lane) {
27419
- this._announce("Move rejected: the destination is not available.");
28079
+ this._rejectMove("destination", { ids: moving, records, from, to: null, column: null, swimlane: null });
27420
28080
  return this;
27421
28081
  }
27422
- const destinationCount = this._recordsIn(columnId, laneId).filter((candidate) => !Object.is(this._viewRecordId(candidate.record), id)).length;
27423
- const destinationColumnCount = this._viewData.filter((candidate, candidateIndex) => normalizeAxisId(this._readAxis(options.columnBy, candidate, candidateIndex)) === columnId && !Object.is(this._viewRecordId(candidate), id)).length;
27424
- const index = normalizeMoveIndex(destination.index, destinationCount);
28082
+ const excluded = new Set(moving);
28083
+ const cellCount = this._recordsIn(columnId, laneId).filter((entry) => !excluded.has(this._viewRecordId(entry.record))).length;
28084
+ const index = clampKanbanIndex(destination.index, cellCount);
27425
28085
  const to = { column: columnId, lane: laneId, index };
27426
- if (sameMovePoint(from, to)) return this;
27427
- const context = { id, from: { ...from }, to: { ...to }, column: { ...column }, swimlane: lane ? { ...lane } : null };
27428
- if (column.accept && column.accept(record, context) === false || lane?.accept && lane.accept(record, context) === false) {
27429
- this._announce(`Move rejected for ${this._recordName(record, from.index)}.`);
28086
+ if (moving.length === 1 && sameMovePoint(from, to)) return this;
28087
+ const context = {
28088
+ id: moving[0],
28089
+ from: { ...from },
28090
+ to: { ...to },
28091
+ column: { ...column },
28092
+ swimlane: lane ? { ...lane } : null
28093
+ };
28094
+ const evaluation = this._evaluateMove(records, origins, column, lane, to, context, excluded);
28095
+ if (!evaluation.allowed) {
28096
+ this._rejectMove(
28097
+ /** @type {KanbanRejectReason} */
28098
+ evaluation.reason,
28099
+ {
28100
+ ids: moving,
28101
+ records,
28102
+ from,
28103
+ to,
28104
+ column,
28105
+ swimlane: lane,
28106
+ evaluation
28107
+ }
28108
+ );
27430
28109
  return this;
27431
28110
  }
27432
- let movedRecord = record;
27433
- if (options.moveMode === "local") {
27434
- movedRecord = this._localMovedRecord(record, from, to, column, lane);
27435
- if (!movedRecord) {
27436
- this._announce("Move rejected: callback grouping requires external move mode.");
28111
+ const entries = [];
28112
+ for (let position2 = 0; position2 < moving.length; position2 += 1) {
28113
+ const record = records[position2];
28114
+ const moved = options.moveMode === "local" ? this._localMovedRecord(record, origins[position2], to, column, lane) : record;
28115
+ if (!moved) {
28116
+ this._rejectMove("grouping", { ids: moving, records, from, to, column, swimlane: lane });
27437
28117
  return this;
27438
28118
  }
28119
+ entries.push({ id: moving[position2], record: moved });
27439
28120
  }
27440
- const next = reorderKanbanData(this._viewData, id, movedRecord, {
28121
+ const next = reorderKanbanRecords(this._viewData, entries, {
27441
28122
  recordId: options.recordId,
27442
28123
  columnBy: (candidate, candidateIndex) => this._readAxis(options.columnBy, candidate, candidateIndex),
27443
28124
  swimlaneBy: options.swimlaneBy == null ? null : (candidate, candidateIndex) => this._readAxis(options.swimlaneBy, candidate, candidateIndex),
27444
28125
  destination: to
27445
28126
  });
27446
28127
  if (!next) {
27447
- this._announce("Move rejected: the destination could not be resolved.");
28128
+ this._rejectMove("destination", { ids: moving, records, from, to, column, swimlane: lane });
27448
28129
  return this;
27449
28130
  }
27450
- const finalCount = destinationColumnCount + 1;
27451
- const limitExceeded = column.limit != null && finalCount > column.limit;
28131
+ const wip = this._wipAnnouncement(evaluation);
27452
28132
  const moveEvent = this.emit("recordmove", {
27453
- record,
27454
- id,
28133
+ record: records[0],
28134
+ id: moving[0],
28135
+ records: [...records],
28136
+ ids: [...moving],
28137
+ moves: next.moves.map((move) => ({ id: move.id, from: { ...move.from } })),
27455
28138
  from: { ...from },
27456
28139
  to: { ...next.to },
27457
28140
  column: { ...column },
27458
28141
  swimlane: lane ? { ...lane } : null,
27459
- limitExceeded
27460
- });
28142
+ limitExceeded: evaluation.limitExceeded
28143
+ }, { honorDomCancellation: true });
27461
28144
  if (moveEvent.defaultPrevented) {
27462
- this._announce(`Move canceled for ${this._recordName(record, from.index)}.`);
28145
+ this._announce(this._text("moveCanceled", { name: this._movingName(records) }));
27463
28146
  return this;
27464
28147
  }
27465
28148
  if (options.moveMode === "external") {
27466
- this._announce(`Move requested for ${this._recordName(record, from.index)} to ${this._targetName(next.to)}.`);
28149
+ this._announce(this._text("moveRequested", {
28150
+ name: this._movingName(records),
28151
+ target: this._targetName(next.to),
28152
+ wip
28153
+ }));
27467
28154
  return this;
27468
28155
  }
27469
- this._viewData = next.records;
27470
- this._refreshView("move");
27471
- this.emit("datachange", { records: this.getData() });
27472
- this._announce(`Moved ${this._recordName(movedRecord, next.to.index)} to ${this._targetName(next.to)}.`);
27473
- queueMicrotask(() => this._moveHandle(id)?.focus());
28156
+ this._commitLocalRecords(next.records);
28157
+ this._announce(moving.length > 1 ? this._text("movedMultiple", { count: moving.length, target: this._targetName(next.to), wip }) : this._text("moved", {
28158
+ name: this._recordName(entries[0].record, next.to.index),
28159
+ target: this._targetName(next.to),
28160
+ wip
28161
+ }));
28162
+ queueMicrotask(() => this._moveHandle(moving[0])?.focus());
27474
28163
  return this;
27475
28164
  }
27476
- /** Returns common plus board-specific serializable configuration. @returns {KanbanViewState} */
27477
- getViewState() {
27478
- return {
27479
- ...super.getViewState(),
27480
- columnOrder: this.getColumnOrder(),
27481
- swimlaneOrder: this.getSwimlaneOrder(),
27482
- collapsedColumns: this.getCollapsedColumns(),
27483
- collapsedSwimlanes: this.getCollapsedSwimlanes()
27484
- };
28165
+ /** Whether a committed local move can be reverted. @returns {boolean} */
28166
+ canUndo() {
28167
+ return this._kanbanOptions().history !== false && this._kanbanHistory.canUndo();
28168
+ }
28169
+ /** Whether a reverted local move can be reapplied. @returns {boolean} */
28170
+ canRedo() {
28171
+ return this._kanbanOptions().history !== false && this._kanbanHistory.canRedo();
27485
28172
  }
27486
28173
  /**
27487
- * Restores common and Kanban configuration. State from another record view is accepted safely.
27488
- * @param {Partial<KanbanViewState>|null} state Saved state.
27489
- * @param {{silent?:boolean}} [options={}] Event behavior.
28174
+ * Reverts the last committed local move, restoring the exact record order and selection.
27490
28175
  * @returns {this}
28176
+ * @fires RecordView#datachange
28177
+ * @fires KanbanView#historychange
27491
28178
  */
27492
- setViewState(state, options = {}) {
27493
- const source = state && typeof state === "object" ? state : {};
27494
- super.setViewState(source, { silent: true });
27495
- this._kanbanColumnOrder = reconcileKanbanOrder(
27496
- this._rawColumns(),
27497
- Array.isArray(source.columnOrder) ? source.columnOrder : this._kanbanColumnOrder
27498
- );
27499
- this._kanbanSwimlaneOrder = reconcileKanbanOrder(
27500
- this._rawSwimlanes(),
27501
- Array.isArray(source.swimlaneOrder) ? source.swimlaneOrder : this._kanbanSwimlaneOrder
27502
- );
27503
- const columnIds = new Set(this._rawColumns().map((column) => column.id));
27504
- const laneIds = new Set(this._rawSwimlanes().map((lane) => lane.id));
27505
- if (Array.isArray(source.collapsedColumns)) {
27506
- this._collapsedColumns = new Set(source.collapsedColumns.map(String).filter((id) => columnIds.has(id)));
27507
- }
27508
- if (Array.isArray(source.collapsedSwimlanes)) {
27509
- this._collapsedSwimlanes = new Set(source.collapsedSwimlanes.map(String).filter((id) => laneIds.has(id)));
27510
- }
27511
- this._refreshView("state");
27512
- if (!options.silent) this._emitKanbanState("restore");
27513
- return this;
28179
+ undo() {
28180
+ return this._travelHistory("undo");
28181
+ }
28182
+ /**
28183
+ * Reapplies the last reverted local move.
28184
+ * @returns {this}
28185
+ * @fires RecordView#datachange
28186
+ * @fires KanbanView#historychange
28187
+ */
28188
+ redo() {
28189
+ return this._travelHistory("redo");
28190
+ }
28191
+ /** Empties both history stacks. @returns {this} @fires KanbanView#historychange */
28192
+ clearHistory() {
28193
+ this._kanbanHistory.clear();
28194
+ this._syncHistoryControls();
28195
+ this._emitHistory();
28196
+ return this;
28197
+ }
28198
+ /** Returns the current board search text. @returns {string} */
28199
+ getSearch() {
28200
+ return this._kanbanSearch;
28201
+ }
28202
+ /**
28203
+ * Narrows visible cards to those matching a search. Hidden records keep their position in the
28204
+ * data, so a card dropped between two visible cards still lands between them.
28205
+ * @param {string} search Search text.
28206
+ * @param {{silent?:boolean}} [options={}] Event behavior.
28207
+ * @returns {this}
28208
+ * @fires KanbanView#searchchange
28209
+ */
28210
+ setSearch(search, options = {}) {
28211
+ const next = search == null ? "" : String(search);
28212
+ if (next === this._kanbanSearch) return this;
28213
+ this._kanbanSearch = next;
28214
+ if (this._searchInput && this._searchInput.value !== next) this._searchInput.value = next;
28215
+ this._refreshView("search");
28216
+ if (!options.silent) this.emit("searchchange", { search: next });
28217
+ return this;
28218
+ }
28219
+ /**
28220
+ * Replaces the extra card predicate. Pass null to clear it.
28221
+ * @param {((record:KanbanRecord,index:number)=>boolean)|null} filter Predicate.
28222
+ * @returns {this}
28223
+ */
28224
+ setFilter(filter) {
28225
+ if (filter != null && typeof filter !== "function") {
28226
+ throw new TypeError("Kanban filter must be a function or null");
28227
+ }
28228
+ this._kanbanFilter = filter ?? null;
28229
+ this._refreshView("filter");
28230
+ return this;
28231
+ }
28232
+ /** Returns the records currently rendered, in board order. @returns {KanbanRecord[]} */
28233
+ getVisibleRecords() {
28234
+ return this._viewData.filter((record) => this._visibleIds.has(this._viewRecordId(record)));
28235
+ }
28236
+ /**
28237
+ * Replaces every record. History is dropped because its snapshots describe records the host has
28238
+ * just replaced, and reinstating them would resurrect data the application no longer owns.
28239
+ * @param {KanbanRecord[]} records New records.
28240
+ * @returns {this}
28241
+ */
28242
+ setData(records) {
28243
+ this._resetHistory();
28244
+ return super.setData(records);
28245
+ }
28246
+ /** @param {KanbanRecord[]} records Records to append. @returns {this} */
28247
+ addData(records) {
28248
+ this._resetHistory();
28249
+ return super.addData(records);
28250
+ }
28251
+ /** @param {unknown} id Existing record id. @param {KanbanRecord} record Replacement. @returns {this} */
28252
+ updateRecord(id, record) {
28253
+ this._resetHistory();
28254
+ return super.updateRecord(id, record);
28255
+ }
28256
+ /** @param {unknown} id Record id. @returns {this} */
28257
+ removeRecord(id) {
28258
+ this._resetHistory();
28259
+ return super.removeRecord(id);
28260
+ }
28261
+ /** Returns common plus board-specific serializable configuration. @returns {KanbanViewState} */
28262
+ getViewState() {
28263
+ this.getColumnOrder();
28264
+ this.getSwimlaneOrder();
28265
+ const derivedColumns = this._kanbanColumns === null;
28266
+ const derivedSwimlanes = this._kanbanSwimlanes === null && this._kanbanOptions().swimlaneBy != null;
28267
+ return {
28268
+ ...super.getViewState(),
28269
+ columnOrder: derivedColumns ? [...this._kanbanColumnOrder] : this.getColumnOrder(),
28270
+ swimlaneOrder: derivedSwimlanes ? [...this._kanbanSwimlaneOrder] : this.getSwimlaneOrder(),
28271
+ collapsedColumns: derivedColumns ? [...this._collapsedColumns] : this.getCollapsedColumns(),
28272
+ collapsedSwimlanes: derivedSwimlanes ? [...this._collapsedSwimlanes] : this.getCollapsedSwimlanes()
28273
+ };
28274
+ }
28275
+ /**
28276
+ * Restores common and Kanban configuration. State from another record view is accepted safely.
28277
+ * @param {Partial<KanbanViewState>|null} state Saved state.
28278
+ * @param {{silent?:boolean}} [options={}] Event behavior.
28279
+ * @returns {this}
28280
+ */
28281
+ setViewState(state, options = {}) {
28282
+ const source = state && typeof state === "object" ? state : {};
28283
+ super.setViewState(source, { silent: true });
28284
+ const columns = this._rawColumns();
28285
+ const lanes = this._rawSwimlanes();
28286
+ const derivedColumns = this._kanbanColumns === null;
28287
+ const derivedSwimlanes = this._kanbanSwimlanes === null && this._kanbanOptions().swimlaneBy != null;
28288
+ this._kanbanColumnOrder = reconcileKanbanPreference(
28289
+ columns,
28290
+ Array.isArray(source.columnOrder) ? source.columnOrder : this._kanbanColumnOrder,
28291
+ derivedColumns
28292
+ );
28293
+ this._kanbanSwimlaneOrder = reconcileKanbanPreference(
28294
+ lanes,
28295
+ Array.isArray(source.swimlaneOrder) ? source.swimlaneOrder : this._kanbanSwimlaneOrder,
28296
+ derivedSwimlanes
28297
+ );
28298
+ const columnIds = new Set(columns.map((column) => column.id));
28299
+ const laneIds = new Set(lanes.map((lane) => lane.id));
28300
+ if (Array.isArray(source.collapsedColumns)) {
28301
+ const collapsed = normalizeOrder(source.collapsedColumns);
28302
+ this._collapsedColumns = new Set(derivedColumns ? collapsed : collapsed.filter((id) => columnIds.has(id)));
28303
+ }
28304
+ if (Array.isArray(source.collapsedSwimlanes)) {
28305
+ const collapsed = normalizeOrder(source.collapsedSwimlanes);
28306
+ this._collapsedSwimlanes = new Set(derivedSwimlanes ? collapsed : collapsed.filter((id) => laneIds.has(id)));
28307
+ }
28308
+ this._refreshView("state");
28309
+ if (!options.silent) this._emitKanbanState("restore");
28310
+ return this;
27514
28311
  }
27515
28312
  /** Restores enhanced targets after base lifecycle cleanup. @returns {void} */
27516
28313
  destroy() {
27517
28314
  if (this._kanbanDestroyed) return;
27518
28315
  this._kanbanDestroyed = true;
28316
+ this._clearDrag();
28317
+ clearTimeout(this._pointerHold);
28318
+ clearTimeout(this._searchTimer);
28319
+ this._kanbanMenu?.destroy();
28320
+ this._kanbanMenu = null;
27519
28321
  const root = this.el;
27520
28322
  const snapshot10 = this._kanbanSnapshot;
27521
28323
  super.destroy();
@@ -27530,25 +28332,61 @@ var KanbanView = class extends RecordView {
27530
28332
  }
27531
28333
  if (_reason === "loading") return;
27532
28334
  const options = this._kanbanOptions();
28335
+ const activeElement = document.activeElement;
27533
28336
  const activeMeta = this._metaForTarget(document.activeElement);
27534
28337
  const restoreHandle = Boolean(document.activeElement?.classList?.contains("zx-kanban-view__move-handle"));
28338
+ const activeCollapse = activeElement instanceof Element ? activeElement.closest("[data-kanban-collapse]") : null;
28339
+ const activeSection = activeCollapse?.closest(".zx-kanban-view__column");
28340
+ const activeCollapseFocus = activeCollapse && this._board.contains(activeCollapse) ? {
28341
+ axis: (
28342
+ /** @type {'column'|'swimlane'} */
28343
+ /** @type {HTMLElement} */
28344
+ activeCollapse.dataset.kanbanCollapse
28345
+ ),
28346
+ id: String(
28347
+ /** @type {HTMLElement} */
28348
+ activeCollapse.dataset.kanbanId
28349
+ ),
28350
+ lane: activeSection ? this._sectionMeta.get(activeSection)?.lane ?? null : null
28351
+ } : null;
27535
28352
  const columns = this._resolvedColumns();
27536
28353
  const lanes = this._resolvedSwimlanes();
27537
28354
  this._cardMeta = /* @__PURE__ */ new WeakMap();
27538
28355
  this._sectionMeta = /* @__PURE__ */ new WeakMap();
27539
28356
  this._cardsById = /* @__PURE__ */ new Map();
28357
+ this._resolveVisibility();
27540
28358
  const content = [];
27541
28359
  if (options.swimlaneBy == null) {
27542
28360
  content.push(this._renderColumns(columns, null, false));
27543
28361
  } else {
27544
28362
  lanes.forEach((lane) => content.push(this._renderLane(lane, columns)));
27545
28363
  }
27546
- const shownCards = this._cardsById.size;
27547
- if (shownCards === 0 && !this._viewData.length) content.push(this._emptyContent());
28364
+ if (!this._viewData.length) content.push(this._emptyContent(this._text("empty")));
28365
+ else if (!this._visibleIds.size) content.push(this._emptyContent(this._text("noMatches")));
27548
28366
  this._board.replaceChildren(...content);
27549
- if (this._keyboardMove && !this.getRecord(this._keyboardMove.id)) this._keyboardMove = null;
28367
+ if (this._keyboardMove) {
28368
+ this._keyboardMove.ids = this._keyboardMove.ids.filter((id) => this.getRecord(id));
28369
+ if (!this._keyboardMove.ids.length) this._keyboardMove = null;
28370
+ }
27550
28371
  this._syncKeyboardTarget();
27551
- if (activeMeta) queueMicrotask(() => {
28372
+ if (activeCollapseFocus || activeMeta) queueMicrotask(() => {
28373
+ if (activeCollapseFocus) {
28374
+ this._collapseControl(
28375
+ activeCollapseFocus.axis,
28376
+ activeCollapseFocus.id,
28377
+ activeCollapseFocus.lane
28378
+ )?.focus();
28379
+ return;
28380
+ }
28381
+ if (!activeMeta) return;
28382
+ if (activeMeta.lane != null && this._collapsedSwimlanes.has(activeMeta.lane)) {
28383
+ this._collapseControl("swimlane", activeMeta.lane)?.focus();
28384
+ return;
28385
+ }
28386
+ if (this._collapsedColumns.has(activeMeta.column)) {
28387
+ this._collapseControl("column", activeMeta.column, activeMeta.lane)?.focus();
28388
+ return;
28389
+ }
27552
28390
  const card = this._cardsById.get(activeMeta.id);
27553
28391
  const target = restoreHandle ? card?.querySelector(".zx-kanban-view__move-handle") : card;
27554
28392
  if (target?.isConnected) target.focus();
@@ -27560,21 +28398,36 @@ var KanbanView = class extends RecordView {
27560
28398
  const collapsed = this._collapsedSwimlanes.has(lane.id);
27561
28399
  const headingId = uid("zx-kanban-lane");
27562
28400
  const bodyId = uid("zx-kanban-lane-body");
27563
- const count2 = this._viewData.filter((record, index) => normalizeAxisId(this._readAxis(options.swimlaneBy, record, index)) === lane.id).length;
28401
+ const entries = this._viewData.filter((record, index) => normalizeAxisId(this._readAxis(options.swimlaneBy, record, index)) === lane.id);
28402
+ const total = entries.length;
28403
+ const count2 = entries.filter((record) => this._visibleIds.has(this._viewRecordId(record))).length;
27564
28404
  const toggle = h("button", {
27565
28405
  class: "zx-kanban-view__collapse",
27566
28406
  type: "button",
27567
28407
  ariaExpanded: String(!collapsed),
27568
28408
  ariaControls: bodyId,
27569
- ariaLabel: `${collapsed ? "Expand" : "Collapse"} swim lane ${lane.label}`,
28409
+ ariaLabel: this._text(collapsed ? "expandLane" : "collapseLane", { label: lane.label }),
27570
28410
  dataset: { kanbanCollapse: "swimlane", kanbanId: lane.id }
27571
28411
  }, icon(collapsed ? "chevron-right" : "chevron-down", { size: 13 }));
28412
+ const limit = lane.limit ?? null;
28413
+ const wip = wipState(total, limit);
28414
+ const custom = this._renderHook("renderSwimlaneHeader", {
28415
+ swimlane: { ...lane },
28416
+ count: count2,
28417
+ total,
28418
+ columnTotal: total,
28419
+ limit,
28420
+ wip,
28421
+ collapsed
28422
+ });
27572
28423
  const heading = h(
27573
28424
  "h2",
27574
28425
  { class: "zx-kanban-view__lane-heading", id: headingId },
27575
28426
  toggle,
27576
- h("span", {}, lane.label),
27577
- options.showCounts ? h("span", { class: "zx-kanban-view__count", ariaLabel: `${count2} records` }, String(count2)) : null
28427
+ custom ?? [
28428
+ h("span", {}, lane.label),
28429
+ options.showCounts || limit != null ? this._countIndicator(count2, total, limit) : null
28430
+ ]
27578
28431
  );
27579
28432
  const body = this._renderColumns(columns, lane, true);
27580
28433
  body.id = bodyId;
@@ -27582,7 +28435,7 @@ var KanbanView = class extends RecordView {
27582
28435
  return h("section", {
27583
28436
  class: "zx-kanban-view__lane",
27584
28437
  ariaLabelledby: headingId,
27585
- dataset: { collapsed: String(collapsed) }
28438
+ dataset: { collapsed: String(collapsed), wip }
27586
28439
  }, heading, body);
27587
28440
  }
27588
28441
  /** @param {KanbanColumn[]} columns @param {KanbanSwimlane|null} lane @param {boolean} hasLanes @returns {HTMLElement} */
@@ -27608,36 +28461,67 @@ var KanbanView = class extends RecordView {
27608
28461
  const collapsed = this._collapsedColumns.has(column.id);
27609
28462
  const headingId = uid("zx-kanban-column");
27610
28463
  const listId = uid("zx-kanban-column-list");
27611
- const columnCount = this._viewData.filter((record, index) => normalizeAxisId(this._readAxis(options.columnBy, record, index)) === column.id).length;
27612
- const wip = column.limit == null ? "none" : columnCount > column.limit ? "exceeded" : columnCount === column.limit ? "limit" : "within";
28464
+ const columnEntries = this._viewData.filter((record, index) => normalizeAxisId(this._readAxis(options.columnBy, record, index)) === column.id);
28465
+ const columnTotal = columnEntries.length;
28466
+ const visible = entries.filter((entry) => this._visibleIds.has(this._viewRecordId(entry.record)));
28467
+ const laneLimit = lane ? readColumnLaneLimit(column, lane.id) : null;
28468
+ const limit = laneLimit ?? column.limit ?? null;
28469
+ const wip = wipState(laneLimit == null ? columnTotal : entries.length, limit);
27613
28470
  const toggle = h("button", {
27614
28471
  class: "zx-kanban-view__collapse",
27615
28472
  type: "button",
27616
28473
  ariaExpanded: String(!collapsed),
27617
28474
  ariaControls: listId,
27618
- ariaLabel: `${collapsed ? "Expand" : "Collapse"} column ${column.label}`,
28475
+ ariaLabel: this._text(collapsed ? "expandColumn" : "collapseColumn", { label: column.label }),
27619
28476
  dataset: { kanbanCollapse: "column", kanbanId: column.id }
27620
28477
  }, icon(collapsed ? "chevron-right" : "chevron-down", { size: 13 }));
27621
- const indicator = options.showCounts || column.limit != null ? h("span", {
27622
- class: "zx-kanban-view__count",
27623
- 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}`
27624
- }, column.limit == null ? String(entries.length) : hasLanes ? `${entries.length} \xB7 ${columnCount} / ${column.limit}` : `${columnCount} / ${column.limit}`) : null;
28478
+ const custom = this._renderHook("renderColumnHeader", {
28479
+ column: { ...column },
28480
+ swimlane: lane ? { ...lane } : null,
28481
+ count: visible.length,
28482
+ total: entries.length,
28483
+ columnTotal,
28484
+ limit,
28485
+ wip,
28486
+ collapsed
28487
+ });
28488
+ const indicator = options.showCounts || limit != null ? this._columnIndicator(visible.length, entries.length, columnTotal, limit, hasLanes && laneLimit == null) : null;
27625
28489
  const heading = h(hasLanes ? "h3" : "h2", {
27626
28490
  class: "zx-kanban-view__column-heading",
27627
28491
  id: headingId
27628
- }, toggle, h("span", { class: "zx-kanban-view__column-label" }, column.label), indicator);
28492
+ }, toggle, custom ?? [h("span", { class: "zx-kanban-view__column-label" }, column.label), indicator]);
27629
28493
  const list = h("ul", {
27630
28494
  class: "zx-kanban-view__cards",
27631
28495
  id: listId,
27632
28496
  ariaLabel: `${column.label}${lane ? `, ${lane.label}` : ""}`
27633
28497
  });
27634
- entries.forEach((entry) => list.append(this._renderCard(entry.record, entry.index, column.id, lane?.id ?? null, hasLanes)));
28498
+ visible.forEach((entry) => list.append(this._renderCard(entry.record, entry.index, column.id, lane?.id ?? null, hasLanes)));
28499
+ if (!visible.length) {
28500
+ const placeholder = this._renderHook("renderColumnEmpty", {
28501
+ column: { ...column },
28502
+ swimlane: lane ? { ...lane } : null,
28503
+ count: 0,
28504
+ total: entries.length,
28505
+ columnTotal,
28506
+ limit,
28507
+ wip,
28508
+ collapsed
28509
+ }) ?? this._placeholderText();
28510
+ if (placeholder) list.append(h("li", { class: "zx-kanban-view__column-empty", role: "presentation" }, placeholder));
28511
+ }
27635
28512
  list.hidden = collapsed;
28513
+ const add = this._allowsAdd(column, lane) ? h("button", {
28514
+ class: "zx-kanban-view__add",
28515
+ type: "button",
28516
+ ariaLabel: this._text("addTo", { target: `${column.label}${lane ? `, ${lane.label}` : ""}` }),
28517
+ dataset: { kanbanAdd: column.id, kanbanLane: lane?.id ?? "" }
28518
+ }, icon("plus", { size: 13 }), h("span", {}, this._text("add"))) : null;
28519
+ if (add) add.hidden = collapsed;
27636
28520
  const section = h("section", {
27637
28521
  class: "zx-kanban-view__column",
27638
28522
  ariaLabelledby: headingId,
27639
28523
  dataset: { column: column.id, collapsed: String(collapsed), wip }
27640
- }, heading, list);
28524
+ }, heading, list, add);
27641
28525
  this._sectionMeta.set(section, { column: column.id, lane: lane?.id ?? null, list });
27642
28526
  return section;
27643
28527
  }
@@ -27645,13 +28529,14 @@ var KanbanView = class extends RecordView {
27645
28529
  _renderCard(record, index, column, lane, hasLanes) {
27646
28530
  const options = this._kanbanOptions();
27647
28531
  const id = this._viewRecordId(record);
27648
- const grabbed = Boolean(this._keyboardMove && Object.is(this._keyboardMove.id, id));
28532
+ const grabbed = Boolean(this._keyboardMove?.ids.some((candidate) => Object.is(candidate, id)));
28533
+ const rules = resolveKanbanRules(this._kanbanRules, record, { index, column, lane });
28534
+ const multiple = grabbed && this._keyboardMove ? this._keyboardMove.ids.length : 1;
27649
28535
  const handle = h("button", {
27650
28536
  class: "zx-kanban-view__move-handle",
27651
28537
  type: "button",
27652
- draggable: true,
27653
28538
  ariaPressed: String(grabbed),
27654
- ariaLabel: `Move ${this._recordName(record, index)}`
28539
+ ariaLabel: multiple > 1 ? this._text("moveMultiple", { count: multiple }) : this._text("move", { name: this._recordName(record, index) })
27655
28540
  }, icon("drag", { size: 13 }));
27656
28541
  const card = createRecordCard(record, index, {
27657
28542
  fields: this.getFields(),
@@ -27670,15 +28555,81 @@ var KanbanView = class extends RecordView {
27670
28555
  });
27671
28556
  card.tabIndex = 0;
27672
28557
  if (grabbed) card.dataset.grabbed = "true";
28558
+ this._applyCardContent(card, { record, id, index, column, lane, rules, selected: this._isViewSelected(id) });
28559
+ this._applyCardRules(card, rules);
28560
+ this._applyCardDescription(card, id);
27673
28561
  const meta = { record, id, index, column, lane };
27674
28562
  this._cardMeta.set(card, meta);
27675
28563
  this._cardsById.set(id, card);
27676
28564
  return card;
27677
28565
  }
27678
- /** @returns {HTMLElement} */
27679
- _emptyContent() {
28566
+ /**
28567
+ * Hands card content to `renderCard` while keeping the managed shell: the list item, its
28568
+ * selection control, the action group, and the move handle are never given away, because they
28569
+ * carry the drop, focus, and movement semantics the board is responsible for.
28570
+ * @param {HTMLElement} card Card built from the shared anatomy.
28571
+ * @param {KanbanCardContext} context Card context.
28572
+ * @returns {void}
28573
+ */
28574
+ _applyCardContent(card, context) {
28575
+ const custom = this._renderHook("renderCard", context);
28576
+ if (!custom) return;
28577
+ const body = card.querySelector(".zx-record-card__body");
28578
+ const titles = card.querySelector(".zx-record-card__titles");
28579
+ if (!body || !titles) return;
28580
+ titles.replaceChildren(custom);
28581
+ body.querySelector(".zx-record-card__eyebrow")?.remove();
28582
+ body.querySelector(".zx-record-card__metadata")?.remove();
28583
+ card.removeAttribute("aria-labelledby");
28584
+ card.setAttribute("aria-label", this._recordName(context.record, context.index));
28585
+ card.dataset.customCard = "true";
28586
+ }
28587
+ /**
28588
+ * Applies ordered card rules. The first match owns the marker tone; every match with a label adds
28589
+ * a badge, and rule descriptions join the accessible description so the marker is never the only
28590
+ * carrier of the meaning.
28591
+ * @param {HTMLElement} card Card element.
28592
+ * @param {KanbanRule[]} rules Matching rules.
28593
+ * @returns {void}
28594
+ */
28595
+ _applyCardRules(card, rules) {
28596
+ if (!rules.length) return;
28597
+ card.dataset.rule = rules.map((rule) => rule.id).join(" ");
28598
+ card.dataset.ruleTone = rules[0].tone;
28599
+ const badges = rules.filter((rule) => rule.label);
28600
+ if (badges.length) {
28601
+ const list = h("p", { class: "zx-kanban-view__rules" }, badges.map((rule) => badge({
28602
+ label: rule.label,
28603
+ icon: rule.icon ?? void 0,
28604
+ kind: rule.tone,
28605
+ size: "sm",
28606
+ variant: "soft"
28607
+ })));
28608
+ card.querySelector(".zx-record-card__body")?.append(list);
28609
+ }
28610
+ const descriptions = rules.map((rule) => rule.description).filter(Boolean).join(". ");
28611
+ if (descriptions) card.dataset.ruleDescription = descriptions;
28612
+ }
28613
+ /**
28614
+ * Composes one card's accessible description from its selection state and its rules, so that
28615
+ * refreshing selection cannot quietly drop what a rule contributed.
28616
+ * @param {HTMLElement} card Card element.
28617
+ * @param {unknown} id Record id.
28618
+ * @returns {void}
28619
+ */
28620
+ _applyCardDescription(card, id) {
28621
+ const parts = [];
28622
+ if (this.options.selectable) {
28623
+ parts.push(this._text(this._isViewSelected(id) ? "selected" : "notSelected"));
28624
+ }
28625
+ if (card.dataset.ruleDescription) parts.push(card.dataset.ruleDescription);
28626
+ if (parts.length) card.setAttribute("aria-description", parts.join(". "));
28627
+ else card.removeAttribute("aria-description");
28628
+ }
28629
+ /** @param {string} text Board-level empty message. @returns {HTMLElement} */
28630
+ _emptyContent(text4) {
27680
28631
  const supplied = typeof this.options.emptyText === "function" ? this.options.emptyText() : this.options.emptyText;
27681
- const content = supplied ?? "No records";
28632
+ const content = supplied ?? text4;
27682
28633
  return h(
27683
28634
  "div",
27684
28635
  { class: "zx-kanban-view__empty", role: "status" },
@@ -27688,6 +28639,37 @@ var KanbanView = class extends RecordView {
27688
28639
  ) : String(content)
27689
28640
  );
27690
28641
  }
28642
+ /** @returns {string} Per-column placeholder text, or an empty string for none. */
28643
+ _placeholderText() {
28644
+ return this._text("columnEmpty");
28645
+ }
28646
+ /**
28647
+ * @param {number} count Visible records.
28648
+ * @param {number} total Records before search and filters.
28649
+ * @param {number|null} limit Applicable limit.
28650
+ * @returns {HTMLElement}
28651
+ */
28652
+ _countIndicator(count2, total, limit) {
28653
+ const filtered = count2 !== total;
28654
+ return h("span", {
28655
+ class: "zx-kanban-view__count",
28656
+ ariaLabel: limit != null ? this._text("countLimit", { count: total, limit }) : filtered ? this._text("countFiltered", { count: count2, total }) : this._text("count", { count: count2 })
28657
+ }, limit != null ? `${total} / ${limit}` : filtered ? `${count2} / ${total}` : String(count2));
28658
+ }
28659
+ /**
28660
+ * @param {number} count Visible records in this column and lane.
28661
+ * @param {number} total Records in this column and lane before search and filters.
28662
+ * @param {number} columnTotal Records across the whole column.
28663
+ * @param {number|null} limit Applicable limit.
28664
+ * @param {boolean} columnScoped Whether the limit counts the column rather than this lane.
28665
+ * @returns {HTMLElement}
28666
+ */
28667
+ _columnIndicator(count2, total, columnTotal, limit, columnScoped) {
28668
+ const filtered = count2 !== total;
28669
+ 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 });
28670
+ const text4 = limit == null ? filtered ? `${count2} / ${total}` : String(count2) : columnScoped ? `${total} \xB7 ${columnTotal} / ${limit}` : `${total} / ${limit}`;
28671
+ return h("span", { class: "zx-kanban-view__count", ariaLabel: label }, text4);
28672
+ }
27691
28673
  /** @returns {KanbanColumn[]} */
27692
28674
  _rawColumns() {
27693
28675
  const options = this._kanbanOptions();
@@ -27696,7 +28678,11 @@ var KanbanView = class extends RecordView {
27696
28678
  /** @returns {KanbanColumn[]} */
27697
28679
  _resolvedColumns() {
27698
28680
  const columns = this._rawColumns();
27699
- this._kanbanColumnOrder = reconcileKanbanOrder(columns, this._kanbanColumnOrder);
28681
+ this._kanbanColumnOrder = reconcileKanbanPreference(
28682
+ columns,
28683
+ this._kanbanColumnOrder,
28684
+ this._kanbanColumns === null
28685
+ );
27700
28686
  return orderDescriptors(columns, this._kanbanColumnOrder);
27701
28687
  }
27702
28688
  /** @returns {KanbanSwimlane[]} */
@@ -27708,7 +28694,12 @@ var KanbanView = class extends RecordView {
27708
28694
  /** @returns {KanbanSwimlane[]} */
27709
28695
  _resolvedSwimlanes() {
27710
28696
  const lanes = this._rawSwimlanes();
27711
- this._kanbanSwimlaneOrder = reconcileKanbanOrder(lanes, this._kanbanSwimlaneOrder);
28697
+ const preserveUnknown = this._kanbanSwimlanes === null && this._kanbanOptions().swimlaneBy != null;
28698
+ this._kanbanSwimlaneOrder = reconcileKanbanPreference(
28699
+ lanes,
28700
+ this._kanbanSwimlaneOrder,
28701
+ preserveUnknown
28702
+ );
27712
28703
  return orderDescriptors(lanes, this._kanbanSwimlaneOrder);
27713
28704
  }
27714
28705
  /** @param {KanbanAccessor} accessor @param {KanbanRecord} record @param {number} index @returns {unknown} */
@@ -27732,13 +28723,84 @@ var KanbanView = class extends RecordView {
27732
28723
  /** @param {unknown} id @returns {KanbanMovePoint|null} */
27733
28724
  _recordLocation(id) {
27734
28725
  const options = this._kanbanOptions();
27735
- const index = this._viewData.findIndex((record2) => Object.is(this._viewRecordId(record2), id));
28726
+ const index = this._viewData.findIndex((record) => Object.is(this._viewRecordId(record), id));
27736
28727
  if (index < 0) return null;
27737
- const record = this._viewData[index];
27738
- const column = normalizeAxisId(this._readAxis(options.columnBy, record, index));
27739
- const lane = options.swimlaneBy == null ? null : normalizeAxisId(this._readAxis(options.swimlaneBy, record, index));
27740
- const within = this._recordsIn(column, lane).findIndex((entry) => Object.is(this._viewRecordId(entry.record), id));
27741
- return { column, lane, index: within };
28728
+ return locateKanbanRecord(this._viewData, index, {
28729
+ recordId: options.recordId,
28730
+ columnBy: (record, recordIndex) => this._readAxis(options.columnBy, record, recordIndex),
28731
+ swimlaneBy: options.swimlaneBy == null ? null : (record, recordIndex) => this._readAxis(options.swimlaneBy, record, recordIndex)
28732
+ });
28733
+ }
28734
+ /**
28735
+ * Recomputes which records survive the search and the filter predicate.
28736
+ * @returns {void}
28737
+ */
28738
+ _resolveVisibility() {
28739
+ const terms = kanbanSearchTerms(this._kanbanSearch);
28740
+ const filter = this._kanbanFilter;
28741
+ const sources = terms.length ? this._searchSources() : null;
28742
+ this._visibleIds = /* @__PURE__ */ new Set();
28743
+ this._viewData.forEach((record, index) => {
28744
+ if (filter && filter(record, index) !== true) return;
28745
+ if (sources && !matchesKanbanSearch(this._searchableText(record, index, sources), terms)) return;
28746
+ this._visibleIds.add(this._viewRecordId(record));
28747
+ });
28748
+ }
28749
+ /**
28750
+ * Resolves what a search looks at, once. `searchFields` names them explicitly; otherwise the
28751
+ * visible fields are searched, plus the title and subtitle sources — a card is findable by the
28752
+ * name it shows even when that field is hidden from the metadata list.
28753
+ * @returns {{fields:import('../view/record-view.js').ViewField[],readers:((record:KanbanRecord,index:number)=>unknown)[]}}
28754
+ */
28755
+ _searchSources() {
28756
+ const options = this._kanbanOptions();
28757
+ const configured = Array.isArray(options.searchFields) ? options.searchFields.map(String) : null;
28758
+ const all = this.getFields();
28759
+ if (configured) return { fields: all.filter((field) => configured.includes(field.id)), readers: [] };
28760
+ const fields = this.getVisibleFields();
28761
+ const known = new Set(fields.map((field) => field.id));
28762
+ const readers = [];
28763
+ for (const source of [options.titleField, options.subtitleField]) {
28764
+ if (source == null) continue;
28765
+ if (typeof source === "function") {
28766
+ readers.push(source);
28767
+ continue;
28768
+ }
28769
+ if (known.has(source)) continue;
28770
+ const field = all.find((candidate) => candidate.id === source);
28771
+ if (field) fields.push(field);
28772
+ else readers.push((record) => record?.[source]);
28773
+ }
28774
+ return { fields, readers };
28775
+ }
28776
+ /**
28777
+ * Builds the text one record is searched through. Field `render` output is deliberately ignored:
28778
+ * it may be a DOM node, and matching what a formatter produced rather than what the record holds
28779
+ * would make the same query behave differently in two views over the same data.
28780
+ * @param {KanbanRecord} record Record.
28781
+ * @param {number} index Display index.
28782
+ * @param {{fields:import('../view/record-view.js').ViewField[],readers:((record:KanbanRecord,index:number)=>unknown)[]}} sources Resolved sources.
28783
+ * @returns {string} Searchable text.
28784
+ */
28785
+ _searchableText(record, index, sources) {
28786
+ const parts = sources.fields.map((field) => stringifyValue(readViewField(field, record, index)));
28787
+ for (const reader of sources.readers) parts.push(stringifyValue(reader(record, index)));
28788
+ return parts.filter(Boolean).join(" ");
28789
+ }
28790
+ /**
28791
+ * Translates a position among rendered cards into a position among all records in one bucket.
28792
+ * @param {string} column Column id.
28793
+ * @param {string|null} lane Lane id.
28794
+ * @param {number} visibleIndex Position among visible cards.
28795
+ * @param {Set<unknown>} excluded Ids being moved, which the visible list already omits.
28796
+ * @returns {number} Absolute position.
28797
+ */
28798
+ _absoluteIndex(column, lane, visibleIndex, excluded) {
28799
+ const entries = this._recordsIn(column, lane).filter((entry) => !excluded.has(this._viewRecordId(entry.record)));
28800
+ return absoluteKanbanIndex(
28801
+ entries.map((entry) => this._visibleIds.has(this._viewRecordId(entry.record))),
28802
+ visibleIndex
28803
+ );
27742
28804
  }
27743
28805
  /**
27744
28806
  * @param {KanbanRecord} record @param {KanbanMovePoint} from @param {KanbanMovePoint} to
@@ -27760,8 +28822,327 @@ var KanbanView = class extends RecordView {
27760
28822
  if (options.swimlaneBy != null && normalizeAxisId(this._readAxis(options.swimlaneBy, clone, currentIndex)) !== to.lane) return null;
27761
28823
  return clone;
27762
28824
  }
28825
+ /**
28826
+ * Runs transition, eligibility, and capacity policy for a whole move. Every distinct origin is
28827
+ * checked, so a multi-card move drawn from two columns cannot slip past a transition rule that
28828
+ * only the second origin violates.
28829
+ * @param {KanbanRecord[]} records Moving records.
28830
+ * @param {KanbanMovePoint[]} origins Their origins.
28831
+ * @param {KanbanColumn} column Destination column.
28832
+ * @param {KanbanSwimlane|null} lane Destination lane.
28833
+ * @param {KanbanMovePoint} to Destination point.
28834
+ * @param {KanbanMoveContext} context Predicate context.
28835
+ * @param {Set<unknown>} excluded Moving ids, excluded from destination counts.
28836
+ * @returns {KanbanMoveEvaluation} Decision.
28837
+ */
28838
+ _evaluateMove(records, origins, column, lane, to, context, excluded) {
28839
+ const options = this._kanbanOptions();
28840
+ for (const origin of origins) {
28841
+ const blocked = allowsKanbanTransition(column, lane, origin);
28842
+ if (blocked) {
28843
+ return {
28844
+ allowed: false,
28845
+ reason: blocked,
28846
+ limit: null,
28847
+ count: 0,
28848
+ limitExceeded: false,
28849
+ limitReached: false,
28850
+ policy: column.wipPolicy ?? (options.wipPolicy === "block" ? "block" : "warn")
28851
+ };
28852
+ }
28853
+ }
28854
+ const excludes = (record) => !excluded.has(this._viewRecordId(record));
28855
+ const columnCount = this._viewData.filter((record, index) => normalizeAxisId(this._readAxis(options.columnBy, record, index)) === to.column && excludes(record)).length;
28856
+ const laneCount = options.swimlaneBy == null ? 0 : this._viewData.filter((record, index) => normalizeAxisId(this._readAxis(options.swimlaneBy, record, index)) === to.lane && excludes(record)).length;
28857
+ const cellCount = this._recordsIn(to.column, to.lane).filter((entry) => excludes(entry.record)).length;
28858
+ return evaluateKanbanMove({
28859
+ records,
28860
+ column,
28861
+ swimlane: lane,
28862
+ context,
28863
+ from: origins[0],
28864
+ columnCount,
28865
+ laneCount,
28866
+ cellCount,
28867
+ policy: options.wipPolicy === "block" ? "block" : "warn"
28868
+ });
28869
+ }
28870
+ /**
28871
+ * Announces and reports a refused move. Nothing is written and no `recordmove` is emitted, so a
28872
+ * listener can neither observe nor undo a move that never happened.
28873
+ * @param {KanbanRejectReason} reason Refusal reason.
28874
+ * @param {{ids:unknown[],records:KanbanRecord[],from:KanbanMovePoint|null,to:KanbanMovePoint|null,column:KanbanColumn|null,swimlane:KanbanSwimlane|null,evaluation?:KanbanMoveEvaluation}} detail Context.
28875
+ * @returns {void}
28876
+ * @fires KanbanView#movereject
28877
+ */
28878
+ _rejectMove(reason, detail) {
28879
+ const target = detail.to ? this._targetName(detail.to) : "";
28880
+ const name = this._movingName(detail.records);
28881
+ 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 });
28882
+ this._announce(message);
28883
+ this.emit("movereject", {
28884
+ reason,
28885
+ ids: [...detail.ids],
28886
+ records: [...detail.records],
28887
+ record: detail.records[0] ?? null,
28888
+ id: detail.ids[0],
28889
+ from: detail.from ? { ...detail.from } : null,
28890
+ to: detail.to ? { ...detail.to } : null,
28891
+ column: detail.column ? { ...detail.column } : null,
28892
+ swimlane: detail.swimlane ? { ...detail.swimlane } : null,
28893
+ limit: detail.evaluation?.limit ?? null,
28894
+ count: detail.evaluation?.count ?? 0,
28895
+ message
28896
+ });
28897
+ }
28898
+ /**
28899
+ * Installs a committed local record order, remembering the previous one for undo.
28900
+ * @param {KanbanRecord[]} records Next record order.
28901
+ * @returns {void}
28902
+ */
28903
+ _commitLocalRecords(records) {
28904
+ const options = this._kanbanOptions();
28905
+ if (options.history !== false) {
28906
+ this._kanbanHistory.push({ records: [...this._viewData], selection: this.getSelectionIds() });
28907
+ this._syncHistoryControls();
28908
+ }
28909
+ this._viewData = records;
28910
+ if (options.sortMode === "local" && this.getSort()) this.setSort(null);
28911
+ else this._refreshView("move");
28912
+ this.emit("datachange", { records: this.getData() });
28913
+ if (options.history !== false) this._emitHistory();
28914
+ }
28915
+ /**
28916
+ * @param {'undo'|'redo'} direction Travel direction.
28917
+ * @returns {this}
28918
+ */
28919
+ _travelHistory(direction) {
28920
+ if (this._kanbanOptions().history === false) return this;
28921
+ const current = { records: [...this._viewData], selection: this.getSelectionIds() };
28922
+ const entry = direction === "undo" ? this._kanbanHistory.undo(current) : this._kanbanHistory.redo(current);
28923
+ if (!entry) {
28924
+ this._announce(this._text(direction === "undo" ? "nothingToUndo" : "nothingToRedo"));
28925
+ return this;
28926
+ }
28927
+ this._viewData = [...entry.records];
28928
+ this._refreshView(direction);
28929
+ this.setSelection(entry.selection);
28930
+ this._syncHistoryControls();
28931
+ this.emit("datachange", { records: this.getData() });
28932
+ this._emitHistory();
28933
+ this._announce(this._text(direction === "undo" ? "undone" : "redone"));
28934
+ return this;
28935
+ }
28936
+ /** Drops both stacks without announcing, for host-driven data replacement. @returns {void} */
28937
+ _resetHistory() {
28938
+ if (!this._kanbanHistory) return;
28939
+ const had = this._kanbanHistory.canUndo() || this._kanbanHistory.canRedo();
28940
+ this._kanbanHistory.clear();
28941
+ this._syncHistoryControls();
28942
+ if (had) this._emitHistory();
28943
+ }
28944
+ /** @returns {void} */
28945
+ _emitHistory() {
28946
+ this.emit("historychange", {
28947
+ canUndo: this.canUndo(),
28948
+ canRedo: this.canRedo(),
28949
+ depth: this._kanbanHistory.depth()
28950
+ });
28951
+ }
28952
+ /** @returns {void} */
28953
+ _syncHistoryControls() {
28954
+ if (this._undoButton) this._undoButton.disabled = !this.canUndo();
28955
+ if (this._redoButton) this._redoButton.disabled = !this.canRedo();
28956
+ }
28957
+ /** @returns {HTMLElement} Built-in search control. */
28958
+ _createSearchControl() {
28959
+ const input = (
28960
+ /** @type {HTMLInputElement} */
28961
+ h("input", {
28962
+ class: "zx-kanban-view__search-input",
28963
+ type: "search",
28964
+ value: this._kanbanSearch,
28965
+ ariaLabel: this._text("search"),
28966
+ placeholder: this._text("searchPlaceholder")
28967
+ })
28968
+ );
28969
+ this._searchInput = input;
28970
+ this.listen(input, "input", () => {
28971
+ clearTimeout(this._searchTimer);
28972
+ this._searchTimer = setTimeout(() => {
28973
+ this._searchTimer = 0;
28974
+ if (!this._kanbanDestroyed) this.setSearch(input.value);
28975
+ }, SEARCH_DEBOUNCE);
28976
+ });
28977
+ this.listen(input, "keydown", (event) => {
28978
+ if (
28979
+ /** @type {KeyboardEvent} */
28980
+ event.key !== "Escape" || !input.value
28981
+ ) return;
28982
+ event.stopPropagation();
28983
+ clearTimeout(this._searchTimer);
28984
+ this._searchTimer = 0;
28985
+ this.setSearch("");
28986
+ });
28987
+ return h("div", { class: "zx-kanban-view__search" }, icon("search", { size: 13 }), input);
28988
+ }
28989
+ /** @returns {HTMLElement} Built-in history controls. */
28990
+ _createHistoryControls() {
28991
+ this._undoButton = /** @type {HTMLButtonElement} */
28992
+ h("button", {
28993
+ class: "zx-button zx-button--sm",
28994
+ type: "button",
28995
+ disabled: true,
28996
+ dataset: { kanbanHistory: "undo" }
28997
+ }, this._text("undo"));
28998
+ this._redoButton = /** @type {HTMLButtonElement} */
28999
+ h("button", {
29000
+ class: "zx-button zx-button--sm",
29001
+ type: "button",
29002
+ disabled: true,
29003
+ dataset: { kanbanHistory: "redo" }
29004
+ }, this._text("redo"));
29005
+ const group = h(
29006
+ "div",
29007
+ { class: "zx-kanban-view__history", role: "group" },
29008
+ this._undoButton,
29009
+ this._redoButton
29010
+ );
29011
+ this.listen(group, "click", (event) => {
29012
+ const control = (
29013
+ /** @type {Element|null} */
29014
+ event.target?.closest?.("[data-kanban-history]")
29015
+ );
29016
+ if (!control) return;
29017
+ if (
29018
+ /** @type {HTMLElement} */
29019
+ control.dataset.kanbanHistory === "undo"
29020
+ ) this.undo();
29021
+ else this.redo();
29022
+ });
29023
+ return group;
29024
+ }
29025
+ /** Attaches the card context menu. @returns {void} */
29026
+ _createContextMenu() {
29027
+ this._kanbanMenu = new ContextMenu(this._board, {
29028
+ selector: ".zx-record-card",
29029
+ items: (context) => this._menuItems(context),
29030
+ onselect: ({ detail }) => this._handleMenuSelect(detail)
29031
+ });
29032
+ }
29033
+ /**
29034
+ * Builds the menu for one card: every destination the same policy path would accept, plus any
29035
+ * application items. Destinations the board would refuse are shown disabled rather than hidden,
29036
+ * so a rule is discoverable instead of silently removing a command.
29037
+ * @param {Element|null} context Card element the menu opened on.
29038
+ * @returns {(MenuItem|'-')[]} Menu entries.
29039
+ */
29040
+ _menuItems(context) {
29041
+ const meta = context ? this._cardMeta.get(context) : null;
29042
+ if (!meta) return [];
29043
+ const options = this._kanbanOptions();
29044
+ const ids = this._movingIds(meta.id);
29045
+ const items = [];
29046
+ for (const column of this._resolvedColumns()) {
29047
+ if (column.id === meta.column) continue;
29048
+ items.push({
29049
+ label: this._text("menuMoveTo", { target: column.label }),
29050
+ value: { kind: "column", id: column.id, ids },
29051
+ disabled: !this._canMove(ids, { column: column.id })
29052
+ });
29053
+ }
29054
+ if (options.swimlaneBy != null) {
29055
+ for (const lane of this._resolvedSwimlanes()) {
29056
+ if (lane.id === meta.lane) continue;
29057
+ items.push({
29058
+ label: this._text("menuMoveToLane", { target: lane.label }),
29059
+ value: { kind: "lane", id: lane.id, ids },
29060
+ disabled: !this._canMove(ids, { lane: lane.id })
29061
+ });
29062
+ }
29063
+ }
29064
+ const extra = typeof options.contextMenu === "function" ? options.contextMenu({
29065
+ record: meta.record,
29066
+ id: meta.id,
29067
+ index: meta.index,
29068
+ column: meta.column,
29069
+ lane: meta.lane,
29070
+ rules: resolveKanbanRules(this._kanbanRules, meta.record, meta),
29071
+ selected: this._isViewSelected(meta.id)
29072
+ }) : Array.isArray(options.contextMenu) ? options.contextMenu : [];
29073
+ if (Array.isArray(extra) && extra.length) items.push("-", ...extra);
29074
+ return items;
29075
+ }
29076
+ /**
29077
+ * @param {{value:unknown,item:MenuItem,context:Element|null}} detail Selection detail.
29078
+ * @returns {void}
29079
+ */
29080
+ _handleMenuSelect(detail) {
29081
+ const value = (
29082
+ /** @type {{kind?:string,id?:string,ids?:unknown[]}|null} */
29083
+ detail.value
29084
+ );
29085
+ if (!value || typeof value !== "object" || !value.kind || !Array.isArray(value.ids)) return;
29086
+ this.moveRecords(value.ids, value.kind === "lane" ? { lane: value.id } : { column: value.id });
29087
+ }
29088
+ /**
29089
+ * Dry-runs the policy path for a destination without emitting or committing anything.
29090
+ * @param {unknown[]} ids Moving ids.
29091
+ * @param {KanbanDestination} destination Destination.
29092
+ * @returns {boolean} Whether the move would be allowed.
29093
+ */
29094
+ _canMove(ids, destination) {
29095
+ const options = this._kanbanOptions();
29096
+ const moving = this._orderedIds(ids);
29097
+ if (!moving.length) return false;
29098
+ const records = moving.map((id) => (
29099
+ /** @type {KanbanRecord} */
29100
+ this.getRecord(id)
29101
+ ));
29102
+ const origins = moving.map((id) => (
29103
+ /** @type {KanbanMovePoint} */
29104
+ this._recordLocation(id)
29105
+ ));
29106
+ const from = origins[0];
29107
+ const columnId = destination.column == null ? from.column : String(destination.column);
29108
+ const laneId = options.swimlaneBy == null ? null : destination.lane === void 0 ? from.lane : normalizeAxisId(destination.lane);
29109
+ const column = this._resolvedColumns().find((candidate) => candidate.id === columnId);
29110
+ const lane = options.swimlaneBy == null ? null : this._resolvedSwimlanes().find((candidate) => candidate.id === laneId) ?? null;
29111
+ if (!column || options.swimlaneBy != null && !lane) return false;
29112
+ const excluded = new Set(moving);
29113
+ const to = { column: columnId, lane: laneId, index: 0 };
29114
+ const context = {
29115
+ id: moving[0],
29116
+ from: { ...from },
29117
+ to: { ...to },
29118
+ column: { ...column },
29119
+ swimlane: lane ? { ...lane } : null
29120
+ };
29121
+ return this._evaluateMove(records, origins, column, lane, to, context, excluded).allowed;
29122
+ }
29123
+ /**
29124
+ * Resolves the ids one gesture moves: the whole selection when the grabbed card belongs to it,
29125
+ * otherwise just that card. Picking up an unselected card never drags the selection along.
29126
+ * @param {unknown} id Grabbed record id.
29127
+ * @returns {unknown[]} Ids to move, in board order.
29128
+ */
29129
+ _movingIds(id) {
29130
+ const options = this._kanbanOptions();
29131
+ if (options.multiMove === false || options.selectable !== "multi" || !this._isViewSelected(id)) return [id];
29132
+ const selection = this.getSelectionIds();
29133
+ return selection.length > 1 ? selection : [id];
29134
+ }
29135
+ /** @param {unknown[]} ids Requested ids. @returns {unknown[]} Existing ids in board order. */
29136
+ _orderedIds(ids) {
29137
+ const requested = new Set(ids);
29138
+ return this._viewData.map((record) => this._viewRecordId(record)).filter((id) => requested.has(id));
29139
+ }
27763
29140
  /** @param {MouseEvent} event @returns {void} */
27764
29141
  _handleBoardClick(event) {
29142
+ if (this._suppressClick) {
29143
+ this._suppressClick = false;
29144
+ return;
29145
+ }
27765
29146
  const target = elementTarget(event);
27766
29147
  const collapse = target?.closest("[data-kanban-collapse]");
27767
29148
  if (collapse) {
@@ -27773,6 +29154,23 @@ var KanbanView = class extends RecordView {
27773
29154
  else this.toggleSwimlane(String(element.dataset.kanbanId));
27774
29155
  return;
27775
29156
  }
29157
+ const add = target?.closest("[data-kanban-add]");
29158
+ if (add) {
29159
+ const element = (
29160
+ /** @type {HTMLElement} */
29161
+ add
29162
+ );
29163
+ const columnId = String(element.dataset.kanbanAdd);
29164
+ const laneId = element.dataset.kanbanLane || null;
29165
+ this.emit("recordadd", {
29166
+ column: this._resolvedColumns().find((candidate) => candidate.id === columnId) ?? null,
29167
+ swimlane: laneId == null ? null : this._resolvedSwimlanes().find((candidate) => candidate.id === laneId) ?? null,
29168
+ columnId,
29169
+ laneId,
29170
+ event
29171
+ });
29172
+ return;
29173
+ }
27776
29174
  const meta = this._metaForTarget(target);
27777
29175
  if (!meta) return;
27778
29176
  const selection = (
@@ -27821,7 +29219,14 @@ var KanbanView = class extends RecordView {
27821
29219
  }
27822
29220
  /** @param {KeyboardEvent} event @returns {void} */
27823
29221
  _handleBoardKeydown(event) {
29222
+ if (this._dragIds.length && event.key === "Escape") {
29223
+ event.preventDefault();
29224
+ this._clearDrag();
29225
+ this._announce(this._text("moveCanceledPlain"));
29226
+ return;
29227
+ }
27824
29228
  const target = elementTarget(event);
29229
+ if (this._handleHistoryKeydown(event, target)) return;
27825
29230
  const handle = target?.closest(".zx-kanban-view__move-handle");
27826
29231
  const meta = this._metaForTarget(handle);
27827
29232
  if (handle && meta) {
@@ -27845,12 +29250,33 @@ var KanbanView = class extends RecordView {
27845
29250
  this.toggleSelection(cardMeta.id);
27846
29251
  }
27847
29252
  }
29253
+ /**
29254
+ * @param {KeyboardEvent} event Keyboard event.
29255
+ * @param {Element|null} target Event target.
29256
+ * @returns {boolean} Whether the event was handled as a history command.
29257
+ */
29258
+ _handleHistoryKeydown(event, target) {
29259
+ if (this._kanbanOptions().history === false || !(event.ctrlKey || event.metaKey)) return false;
29260
+ if (target?.closest('input, textarea, [contenteditable="true"]')) return false;
29261
+ const key = event.key.toLowerCase();
29262
+ if (key === "z" && !event.shiftKey && this.canUndo()) {
29263
+ event.preventDefault();
29264
+ this.undo();
29265
+ return true;
29266
+ }
29267
+ if ((key === "y" || key === "z" && event.shiftKey) && this.canRedo()) {
29268
+ event.preventDefault();
29269
+ this.redo();
29270
+ return true;
29271
+ }
29272
+ return false;
29273
+ }
27848
29274
  /** @param {KeyboardEvent} event @param {HTMLElement} handle @param {KanbanCardMeta} meta @returns {void} */
27849
29275
  _handleMoveKeydown(event, handle, meta) {
27850
29276
  if (event.key === " " || event.key === "Enter") {
27851
29277
  event.preventDefault();
27852
- if (this._keyboardMove && Object.is(this._keyboardMove.id, meta.id)) {
27853
- const destination = { ...this._keyboardMove.to };
29278
+ if (this._keyboardMove?.ids.some((candidate) => Object.is(candidate, meta.id))) {
29279
+ const move2 = this._keyboardMove;
27854
29280
  this._keyboardMove = null;
27855
29281
  handle.setAttribute("aria-pressed", "false");
27856
29282
  const card = (
@@ -27859,22 +29285,24 @@ var KanbanView = class extends RecordView {
27859
29285
  );
27860
29286
  if (card) delete card.dataset.grabbed;
27861
29287
  this._syncKeyboardTarget();
27862
- this.moveRecord(meta.id, destination);
29288
+ this._commitInteractionMove(move2.ids, move2.to);
27863
29289
  } else {
27864
29290
  const from = this._recordLocation(meta.id);
27865
29291
  if (!from) return;
27866
- this._keyboardMove = { id: meta.id, from: { ...from }, to: { ...from } };
29292
+ const ids = this._movingIds(meta.id);
29293
+ const start = { ...from, index: this._visibleIndex(meta.id) };
29294
+ this._keyboardMove = { ids, from: { ...from }, to: start };
27867
29295
  handle.setAttribute("aria-pressed", "true");
27868
29296
  const card = handle.closest(".zx-record-card");
27869
29297
  if (card) card.dataset.grabbed = "true";
27870
29298
  this._syncKeyboardTarget();
27871
- 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.`);
29299
+ this._announce(ids.length > 1 ? this._text("grabbedMultiple", { count: ids.length }) : this._text("grabbed", { name: this._recordName(meta.record, meta.index) }));
27872
29300
  }
27873
29301
  return;
27874
29302
  }
27875
29303
  if (event.key === "Escape" && this._keyboardMove) {
27876
29304
  event.preventDefault();
27877
- const record = this.getRecord(this._keyboardMove.id);
29305
+ const records = this._keyboardMove.ids.map((id) => this.getRecord(id)).filter(Boolean);
27878
29306
  this._keyboardMove = null;
27879
29307
  handle.setAttribute("aria-pressed", "false");
27880
29308
  const card = (
@@ -27883,10 +29311,13 @@ var KanbanView = class extends RecordView {
27883
29311
  );
27884
29312
  if (card) delete card.dataset.grabbed;
27885
29313
  this._syncKeyboardTarget();
27886
- this._announce(`Move canceled${record ? ` for ${this._recordName(record, meta.index)}` : ""}.`);
29314
+ this._announce(records.length ? this._text("moveCanceled", { name: this._movingName(
29315
+ /** @type {KanbanRecord[]} */
29316
+ records
29317
+ ) }) : this._text("moveCanceledPlain"));
27887
29318
  return;
27888
29319
  }
27889
- if (!this._keyboardMove || !Object.is(this._keyboardMove.id, meta.id) || !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return;
29320
+ if (!this._keyboardMove || !this._keyboardMove.ids.some((candidate) => Object.is(candidate, meta.id)) || !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return;
27890
29321
  event.preventDefault();
27891
29322
  const move = this._keyboardMove;
27892
29323
  const laneModifier = event.altKey || event.ctrlKey || event.metaKey;
@@ -27903,10 +29334,14 @@ var KanbanView = class extends RecordView {
27903
29334
  } else if (event.key === "ArrowUp" || event.key === "ArrowDown") {
27904
29335
  move.to.index += event.key === "ArrowUp" ? -1 : 1;
27905
29336
  }
27906
- const targetCount = this._recordsIn(move.to.column, move.to.lane).filter((entry) => !Object.is(this._viewRecordId(entry.record), move.id)).length;
27907
- move.to.index = normalizeMoveIndex(move.to.index, targetCount);
29337
+ const targetCount = this._visibleCount(move.to.column, move.to.lane, new Set(move.ids));
29338
+ move.to.index = clampKanbanIndex(move.to.index, targetCount);
27908
29339
  this._syncKeyboardTarget();
27909
- this._announce(`Target ${this._targetName(move.to)}, position ${move.to.index + 1} of ${targetCount + 1}.`);
29340
+ this._announce(this._text("target", {
29341
+ target: this._targetName(move.to),
29342
+ position: move.to.index + 1,
29343
+ total: targetCount + 1
29344
+ }));
27910
29345
  }
27911
29346
  /** @param {Event} event @returns {void} */
27912
29347
  _handlePreviewError(event) {
@@ -27922,80 +29357,246 @@ var KanbanView = class extends RecordView {
27922
29357
  if (preview) preview.dataset.failed = "true";
27923
29358
  image.hidden = true;
27924
29359
  }
27925
- /** @param {DragEvent} event @returns {void} */
27926
- _handleDragStart(event) {
27927
- const handle = elementTarget(event)?.closest(".zx-kanban-view__move-handle");
27928
- const meta = this._metaForTarget(handle);
27929
- if (!handle || !meta) {
27930
- event.preventDefault();
27931
- return;
29360
+ /**
29361
+ * Records a candidate drag. Nothing moves yet: a press that never travels stays a click, and a
29362
+ * touch on the card body stays a scroll until the long press elapses.
29363
+ * @param {PointerEvent} event Pointer event.
29364
+ * @returns {void}
29365
+ */
29366
+ _handlePointerDown(event) {
29367
+ this._suppressClick = false;
29368
+ if (event.button != null && event.button !== 0) return;
29369
+ const target = elementTarget(event);
29370
+ const handle = target?.closest(".zx-kanban-view__move-handle");
29371
+ const meta = this._metaForTarget(handle ?? target);
29372
+ if (!meta) return;
29373
+ if (!handle) {
29374
+ if (this._kanbanOptions().dragFrom !== "card") return;
29375
+ if (target?.closest(INTERACTIVE) || target?.closest(".zx-record-card__selection")) return;
29376
+ }
29377
+ const ids = this._movingIds(meta.id);
29378
+ this._pointerStart = {
29379
+ x: event.clientX,
29380
+ y: event.clientY,
29381
+ id: event.pointerId,
29382
+ ids,
29383
+ touch: event.pointerType === "touch"
29384
+ };
29385
+ this._dragPoint = { x: event.clientX, y: event.clientY };
29386
+ clearTimeout(this._pointerHold);
29387
+ if (event.pointerType !== "touch" || handle) return;
29388
+ this._pointerHold = setTimeout(() => this._beginDrag(this._dragPoint.x, this._dragPoint.y), TOUCH_HOLD);
29389
+ }
29390
+ /** @param {PointerEvent} event @returns {void} */
29391
+ _handlePointerMove(event) {
29392
+ const start = this._pointerStart;
29393
+ if (!this._dragIds.length) {
29394
+ if (!start || event.pointerId !== start.id) return;
29395
+ const distance = Math.hypot(event.clientX - start.x, event.clientY - start.y);
29396
+ this._dragPoint = { x: event.clientX, y: event.clientY };
29397
+ if (distance <= DRAG_THRESHOLD) return;
29398
+ if (start.touch && this._pointerHold) {
29399
+ clearTimeout(this._pointerHold);
29400
+ this._pointerHold = 0;
29401
+ this._pointerStart = null;
29402
+ return;
29403
+ }
29404
+ this._beginDrag(event.clientX, event.clientY);
29405
+ if (!this._dragIds.length) return;
27932
29406
  }
27933
- this._dragId = meta.id;
27934
- const card = handle.closest(".zx-record-card");
27935
- if (card) card.dataset.dragging = "true";
27936
- event.dataTransfer?.setData("text/plain", "zx-kanban-record");
27937
- if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
29407
+ if (this._dragPointer != null && event.pointerId !== this._dragPointer) return;
29408
+ event.preventDefault();
29409
+ this._dragPoint = { x: event.clientX, y: event.clientY };
29410
+ this._positionDragPreview();
29411
+ this._updateDropTarget(event.clientX, event.clientY);
27938
29412
  }
27939
- /** @param {DragEvent} event @returns {void} */
27940
- _handleDragOver(event) {
27941
- if (this._dragId == null) return;
27942
- const section = elementTarget(event)?.closest(".zx-kanban-view__column");
27943
- const sectionMeta = section ? this._sectionMeta.get(section) : null;
27944
- if (!sectionMeta) return;
29413
+ /** @param {PointerEvent} event @returns {void} */
29414
+ _handlePointerUp(event) {
29415
+ if (this._dragIds.length && this._dragPointer != null && event.pointerId !== this._dragPointer) return;
29416
+ clearTimeout(this._pointerHold);
29417
+ this._pointerHold = 0;
29418
+ this._pointerStart = null;
29419
+ if (!this._dragIds.length) return;
27945
29420
  event.preventDefault();
27946
- const destination = this._pointerDestination(event, sectionMeta);
27947
- this._dragDestination = destination;
29421
+ const ids = [...this._dragIds];
29422
+ const destination = this._dragDestination ? { ...this._dragDestination } : null;
29423
+ this._clearDrag();
29424
+ this._suppressClick = true;
29425
+ if (destination) this._commitInteractionMove(ids, destination);
29426
+ }
29427
+ /**
29428
+ * Starts a pointer drag: the floating preview appears, the board stops selecting text, and the
29429
+ * auto-scroll loop begins.
29430
+ * @param {number} x Pointer client x.
29431
+ * @param {number} y Pointer client y.
29432
+ * @returns {void}
29433
+ */
29434
+ _beginDrag(x, y) {
29435
+ const start = this._pointerStart;
29436
+ if (!start || this._dragIds.length) return;
29437
+ this._pointerHold = 0;
29438
+ this._dragIds = start.ids.filter((id) => this.getRecord(id));
29439
+ if (!this._dragIds.length) return;
29440
+ this._dragPoint = { x, y };
29441
+ this._board.dataset.dragging = "true";
29442
+ for (const id of this._dragIds) {
29443
+ const card = this._cardsById.get(id);
29444
+ if (card) card.dataset.dragging = "true";
29445
+ }
29446
+ this._dragPointer = start.id;
29447
+ try {
29448
+ this._board.setPointerCapture(start.id);
29449
+ this._dragCaptured = true;
29450
+ } catch {
29451
+ }
29452
+ globalThis.getSelection?.()?.removeAllRanges();
29453
+ this._dragPreview = this._createDragPreview();
29454
+ this.el.append(this._dragPreview);
29455
+ this._positionDragPreview();
29456
+ this._updateDropTarget(x, y);
29457
+ if (this._kanbanOptions().autoScroll !== false) this._scheduleAutoScroll();
29458
+ }
29459
+ /** @returns {HTMLElement} Floating drag preview. */
29460
+ _createDragPreview() {
29461
+ const records = this._dragIds.map((id) => (
29462
+ /** @type {KanbanRecord} */
29463
+ this.getRecord(id)
29464
+ ));
29465
+ const custom = this._renderHook("renderDragPreview", {
29466
+ records,
29467
+ ids: [...this._dragIds],
29468
+ count: this._dragIds.length
29469
+ });
29470
+ const content = custom ?? (this._dragIds.length > 1 ? this._text("dragPreview", { count: this._dragIds.length }) : this._movingName(records));
29471
+ return h("div", {
29472
+ class: "zx-kanban-view__drag-preview",
29473
+ ariaHidden: "true",
29474
+ dataset: { count: String(this._dragIds.length) }
29475
+ }, content);
29476
+ }
29477
+ /** @returns {void} */
29478
+ _positionDragPreview() {
29479
+ if (!this._dragPreview) return;
29480
+ this._dragPreview.style.transform = `translate3d(${Math.round(this._dragPoint.x)}px, ${Math.round(this._dragPoint.y)}px, 0)`;
29481
+ }
29482
+ /**
29483
+ * Resolves and marks the drop destination under the pointer. Pointer capture retargets events to
29484
+ * the board, so the element under the pointer is looked up rather than read off the event.
29485
+ * @param {number} x Pointer client x.
29486
+ * @param {number} y Pointer client y.
29487
+ * @returns {void}
29488
+ */
29489
+ _updateDropTarget(x, y) {
29490
+ const element = document.elementFromPoint(x, y);
29491
+ const section = element?.closest(".zx-kanban-view__column");
29492
+ const sectionMeta = section && this._board.contains(section) ? this._sectionMeta.get(section) : null;
27948
29493
  this._clearDropMarkers();
27949
- const card = elementTarget(event)?.closest(".zx-record-card");
27950
- const cardMeta = card ? this._cardMeta.get(card) : null;
27951
- if (card && cardMeta && !Object.is(cardMeta.id, this._dragId)) {
27952
- const rectangle = card.getBoundingClientRect();
27953
- card.dataset.drop = event.clientY >= rectangle.top + rectangle.height / 2 ? "after" : "before";
27954
- } else {
27955
- sectionMeta.list.dataset.drop = "append";
29494
+ if (!sectionMeta || this._collapsedColumns.has(sectionMeta.column)) {
29495
+ this._dragDestination = null;
29496
+ return;
27956
29497
  }
27957
- if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
29498
+ const cards = this._draggableCards(sectionMeta.list);
29499
+ const index = cards.findIndex((card) => {
29500
+ const rectangle = card.getBoundingClientRect();
29501
+ return y < rectangle.top + rectangle.height / 2;
29502
+ });
29503
+ const position2 = index < 0 ? cards.length : index;
29504
+ if (cards[position2]) cards[position2].dataset.drop = "before";
29505
+ else if (cards.length) cards[cards.length - 1].dataset.drop = "after";
29506
+ else sectionMeta.list.dataset.drop = "append";
29507
+ this._dragDestination = { column: sectionMeta.column, lane: sectionMeta.lane, index: position2 };
27958
29508
  }
27959
- /** @param {DragEvent} event @returns {void} */
27960
- _handleDrop(event) {
27961
- if (this._dragId == null || !this._dragDestination) return;
27962
- event.preventDefault();
27963
- const id = this._dragId;
27964
- const destination = { ...this._dragDestination };
27965
- this._clearDrag();
27966
- this.moveRecord(id, destination);
27967
- }
27968
- /** @param {DragEvent} event @param {KanbanSectionMeta} section @returns {KanbanMovePoint} */
27969
- _pointerDestination(event, section) {
27970
- const cards = [...section.list.querySelectorAll(":scope > .zx-record-card")].filter((card) => !Object.is(this._cardMeta.get(card)?.id, this._dragId));
27971
- const target = elementTarget(event)?.closest(".zx-record-card");
27972
- const targetMeta = target ? this._cardMeta.get(target) : null;
27973
- if (target && targetMeta && Object.is(targetMeta.id, this._dragId)) {
27974
- const from = this._recordLocation(this._dragId);
27975
- return { column: section.column, lane: section.lane, index: from?.index ?? cards.length };
27976
- }
27977
- const targetIndex = target ? cards.indexOf(target) : -1;
27978
- if (targetIndex < 0) return { column: section.column, lane: section.lane, index: cards.length };
27979
- const rectangle = target.getBoundingClientRect();
27980
- return {
27981
- column: section.column,
27982
- lane: section.lane,
27983
- index: targetIndex + (event.clientY >= rectangle.top + rectangle.height / 2 ? 1 : 0)
27984
- };
29509
+ /** @returns {void} */
29510
+ _scheduleAutoScroll() {
29511
+ if (this._dragFrame) return;
29512
+ this._dragFrame = requestAnimationFrame(() => {
29513
+ this._dragFrame = 0;
29514
+ if (!this._dragIds.length) return;
29515
+ this._autoScroll();
29516
+ this._scheduleAutoScroll();
29517
+ });
29518
+ }
29519
+ /** Scrolls the board horizontally and the hovered column vertically near their edges. @returns {void} */
29520
+ _autoScroll() {
29521
+ const { x, y } = this._dragPoint;
29522
+ const board = this._board.getBoundingClientRect();
29523
+ if (x < board.left + SCROLL_EDGE) this._board.scrollLeft -= SCROLL_SPEED;
29524
+ else if (x > board.right - SCROLL_EDGE) this._board.scrollLeft += SCROLL_SPEED;
29525
+ const list = document.elementFromPoint(x, y)?.closest(".zx-kanban-view__cards");
29526
+ if (!list || !this._board.contains(list) || list.scrollHeight <= list.clientHeight) return;
29527
+ const bounds = list.getBoundingClientRect();
29528
+ if (y < bounds.top + SCROLL_EDGE) list.scrollTop -= SCROLL_SPEED;
29529
+ else if (y > bounds.bottom - SCROLL_EDGE) list.scrollTop += SCROLL_SPEED;
29530
+ }
29531
+ /**
29532
+ * Commits a move whose index came from rendered cards. Search and filters hide records without
29533
+ * removing them, so the visible position is translated back before anything is proposed.
29534
+ * @param {unknown[]} ids Moving ids.
29535
+ * @param {KanbanMovePoint} point Destination in visible-card space.
29536
+ * @returns {void}
29537
+ */
29538
+ _commitInteractionMove(ids, point) {
29539
+ const excluded = new Set(ids);
29540
+ this.moveRecords(ids, {
29541
+ column: point.column,
29542
+ lane: point.lane,
29543
+ index: this._absoluteIndex(point.column, point.lane, point.index, excluded)
29544
+ });
27985
29545
  }
27986
29546
  /** @returns {void} */
27987
29547
  _clearDrag() {
27988
- this._clearDropMarkers();
27989
- for (const card of this._board.querySelectorAll("[data-dragging]")) delete /** @type {HTMLElement} */
27990
- card.dataset.dragging;
27991
- this._dragId = null;
29548
+ if (this._dragFrame) cancelAnimationFrame(this._dragFrame);
29549
+ this._dragFrame = 0;
29550
+ clearTimeout(this._pointerHold);
29551
+ this._pointerHold = 0;
29552
+ this._pointerStart = null;
29553
+ this._dragPreview?.remove();
29554
+ this._dragPreview = null;
29555
+ this._dragIds = [];
27992
29556
  this._dragDestination = null;
29557
+ if (this._board) {
29558
+ if (this._dragCaptured && this._dragPointer != null) {
29559
+ try {
29560
+ this._board.releasePointerCapture(this._dragPointer);
29561
+ } catch {
29562
+ }
29563
+ }
29564
+ this._clearDropMarkers();
29565
+ delete this._board.dataset.dragging;
29566
+ for (const card of this._board.querySelectorAll("[data-dragging]")) delete /** @type {HTMLElement} */
29567
+ card.dataset.dragging;
29568
+ }
29569
+ this._dragPointer = null;
29570
+ this._dragCaptured = false;
27993
29571
  }
27994
29572
  /** @returns {void} */
27995
29573
  _clearDropMarkers() {
27996
29574
  for (const element of this._board.querySelectorAll("[data-drop]")) delete /** @type {HTMLElement} */
27997
29575
  element.dataset.drop;
27998
29576
  }
29577
+ /** @param {HTMLElement} list Card list. @returns {HTMLElement[]} Cards excluding the ones in flight. */
29578
+ _draggableCards(list) {
29579
+ const moving = new Set(this._dragIds.length ? this._dragIds : this._keyboardMove?.ids ?? []);
29580
+ return (
29581
+ /** @type {HTMLElement[]} */
29582
+ [...list.querySelectorAll(":scope > .zx-record-card")].filter((card) => !hasId(moving, this._cardMeta.get(card)?.id))
29583
+ );
29584
+ }
29585
+ /** @param {string} column @param {string|null} lane @param {Set<unknown>} excluded @returns {number} */
29586
+ _visibleCount(column, lane, excluded) {
29587
+ return this._recordsIn(column, lane).filter((entry) => {
29588
+ const id = this._viewRecordId(entry.record);
29589
+ return !hasId(excluded, id) && this._visibleIds.has(id);
29590
+ }).length;
29591
+ }
29592
+ /** @param {unknown} id Record id. @returns {number} Position among visible cards in its bucket. */
29593
+ _visibleIndex(id) {
29594
+ const location = this._recordLocation(id);
29595
+ if (!location) return 0;
29596
+ const visible = this._recordsIn(location.column, location.lane).filter((entry) => this._visibleIds.has(this._viewRecordId(entry.record)));
29597
+ const position2 = visible.findIndex((entry) => Object.is(this._viewRecordId(entry.record), id));
29598
+ return position2 < 0 ? visible.length : position2;
29599
+ }
27999
29600
  /** @returns {void} */
28000
29601
  _syncKeyboardTarget() {
28001
29602
  for (const element of this._board.querySelectorAll("[data-keyboard-target]")) {
@@ -28010,7 +29611,7 @@ var KanbanView = class extends RecordView {
28010
29611
  });
28011
29612
  const sectionMeta = section ? this._sectionMeta.get(section) : null;
28012
29613
  if (!sectionMeta) return;
28013
- const cards = [...sectionMeta.list.querySelectorAll(":scope > .zx-record-card")].filter((card) => !Object.is(this._cardMeta.get(card)?.id, move.id));
29614
+ const cards = this._draggableCards(sectionMeta.list);
28014
29615
  const target = cards[move.to.index];
28015
29616
  if (target) target.dataset.keyboardTarget = "before";
28016
29617
  else sectionMeta.list.dataset.keyboardTarget = "append";
@@ -28020,7 +29621,7 @@ var KanbanView = class extends RecordView {
28020
29621
  for (const [id, card] of this._cardsById) {
28021
29622
  const selected = this._isViewSelected(id);
28022
29623
  card.dataset.selected = String(selected);
28023
- if (this.options.selectable) card.setAttribute("aria-selected", String(selected));
29624
+ this._applyCardDescription(card, id);
28024
29625
  const input = (
28025
29626
  /** @type {HTMLInputElement|null} */
28026
29627
  card.querySelector("[data-record-selection]")
@@ -28040,6 +29641,50 @@ var KanbanView = class extends RecordView {
28040
29641
  this._cardsById.get(id)?.querySelector(".zx-kanban-view__move-handle") ?? null
28041
29642
  );
28042
29643
  }
29644
+ /**
29645
+ * Resolves a collapse control after a board refresh, retaining the same lane copy for columns.
29646
+ * @param {'column'|'swimlane'} axis Collapse axis.
29647
+ * @param {string} id Descriptor id.
29648
+ * @param {string|null} [lane=null] Column copy's lane.
29649
+ * @returns {HTMLElement|null}
29650
+ */
29651
+ _collapseControl(axis, id, lane = null) {
29652
+ return (
29653
+ /** @type {HTMLElement|null} */
29654
+ [...this._board.querySelectorAll("[data-kanban-collapse]")].find((candidate) => {
29655
+ const control = (
29656
+ /** @type {HTMLElement} */
29657
+ candidate
29658
+ );
29659
+ if (control.dataset.kanbanCollapse !== axis || control.dataset.kanbanId !== id) return false;
29660
+ if (axis !== "column") return true;
29661
+ const section = control.closest(".zx-kanban-view__column");
29662
+ return section ? this._sectionMeta.get(section)?.lane === lane : false;
29663
+ }) ?? null
29664
+ );
29665
+ }
29666
+ /**
29667
+ * Runs one presentation hook, ignoring anything that is not a node so a hook cannot smuggle
29668
+ * markup in as a string.
29669
+ * @param {'renderCard'|'renderColumnHeader'|'renderSwimlaneHeader'|'renderColumnEmpty'|'renderDragPreview'} name Hook name.
29670
+ * @param {any} context Hook context.
29671
+ * @returns {Node|null} Node to adopt, or null for the default presentation.
29672
+ */
29673
+ _renderHook(name, context) {
29674
+ const hook = this._kanbanOptions()[name];
29675
+ if (typeof hook !== "function") return null;
29676
+ const result = hook(context);
29677
+ return result && typeof result === "object" && "nodeType" in result ? (
29678
+ /** @type {Node} */
29679
+ result
29680
+ ) : null;
29681
+ }
29682
+ /** @param {KanbanColumn} column @param {KanbanSwimlane|null} lane @returns {boolean} */
29683
+ _allowsAdd(column, lane) {
29684
+ const allow = this._kanbanOptions().allowAdd;
29685
+ if (typeof allow === "function") return allow(column, lane) === true;
29686
+ return allow === true;
29687
+ }
28043
29688
  /** @param {KanbanRecord} record @param {number} index @returns {string} */
28044
29689
  _recordName(record, index) {
28045
29690
  const source = this._kanbanOptions().titleField;
@@ -28047,10 +29692,16 @@ var KanbanView = class extends RecordView {
28047
29692
  if (value && typeof value === "object" && "nodeType" in value) {
28048
29693
  return (
28049
29694
  /** @type {Node} */
28050
- value.textContent?.trim() || "record"
29695
+ value.textContent?.trim() || this._text("record")
28051
29696
  );
28052
29697
  }
28053
- return String(value ?? "record");
29698
+ return String(value ?? this._text("record"));
29699
+ }
29700
+ /** @param {KanbanRecord[]} records Moving records. @returns {string} */
29701
+ _movingName(records) {
29702
+ if (!records.length) return this._text("record");
29703
+ if (records.length === 1) return this._recordName(records[0], this._viewData.indexOf(records[0]));
29704
+ return this._text("dragPreview", { count: records.length });
28054
29705
  }
28055
29706
  /** @param {KanbanMovePoint} point @returns {string} */
28056
29707
  _targetName(point) {
@@ -28058,6 +29709,26 @@ var KanbanView = class extends RecordView {
28058
29709
  const lane = point.lane == null ? null : this._resolvedSwimlanes().find((candidate) => candidate.id === point.lane);
28059
29710
  return `${column?.label ?? point.column}${lane ? `, ${lane.label}` : ""}`;
28060
29711
  }
29712
+ /** @param {KanbanMoveEvaluation} evaluation Capacity report. @returns {string} */
29713
+ _wipAnnouncement(evaluation) {
29714
+ if (evaluation.limit == null) return "";
29715
+ if (evaluation.limitExceeded) return this._text("limitExceeded", { limit: evaluation.limit });
29716
+ return evaluation.limitReached ? this._text("limitReached", { limit: evaluation.limit }) : "";
29717
+ }
29718
+ /**
29719
+ * Resolves one localizable string.
29720
+ * @param {keyof typeof DEFAULT_LABELS|string} key Label key.
29721
+ * @param {Record<string, unknown>} [args] Placeholder values.
29722
+ * @returns {string} Resolved text.
29723
+ */
29724
+ _text(key, args) {
29725
+ const overrides = this._kanbanOptions().labels;
29726
+ const source = overrides && typeof overrides === "object" && key in overrides ? overrides[key] : (
29727
+ /** @type {Record<string,string>} */
29728
+ DEFAULT_LABELS[key]
29729
+ );
29730
+ return printf(source == null ? String(key) : String(source), args);
29731
+ }
28061
29732
  /** @param {string} message @returns {void} */
28062
29733
  _announce(message) {
28063
29734
  const token = ++this._announcementToken;
@@ -28097,6 +29768,9 @@ function normalizeKanbanDescriptors(descriptors, axis = "column") {
28097
29768
  if (source.accept != null && typeof source.accept !== "function") {
28098
29769
  throw new TypeError(`Kanban ${axis} ${id} accept must be a function`);
28099
29770
  }
29771
+ if (source.from != null && !Array.isArray(source.from)) {
29772
+ throw new TypeError(`Kanban ${axis} ${id} from must be an array or null`);
29773
+ }
28100
29774
  seen.add(id);
28101
29775
  const normalized = (
28102
29776
  /** @type {Record<string, any>} */
@@ -28104,11 +29778,21 @@ function normalizeKanbanDescriptors(descriptors, axis = "column") {
28104
29778
  ...source,
28105
29779
  id,
28106
29780
  label: String(source.label ?? axisLabel(source.id)),
29781
+ limit: normalizeLimit2(source.limit),
29782
+ from: Array.isArray(source.from) ? source.from.map(String) : null,
28107
29783
  ...Object.prototype.hasOwnProperty.call(source, "value") ? {} : { value: source.id }
28108
29784
  }
28109
29785
  );
28110
- if (axis === "column") normalized.limit = normalizeLimit2(source.limit);
28111
- else delete normalized.limit;
29786
+ if (axis === "column") {
29787
+ const laneLimits = normalizeLaneLimits(source.laneLimits);
29788
+ if (laneLimits) normalized.laneLimits = laneLimits;
29789
+ if (source.wipPolicy === "block" || source.wipPolicy === "warn") {
29790
+ normalized.wipPolicy = source.wipPolicy;
29791
+ }
29792
+ } else {
29793
+ delete normalized.laneLimits;
29794
+ delete normalized.wipPolicy;
29795
+ }
28112
29796
  return normalized;
28113
29797
  })
28114
29798
  );
@@ -28131,37 +29815,11 @@ function reconcileKanbanOrder(descriptors, order) {
28131
29815
  const preferred = normalizeOrder(order).filter((id) => allowed.has(id));
28132
29816
  return [...new Set(preferred), ...available.filter((id) => !preferred.includes(id))];
28133
29817
  }
28134
- function reorderKanbanData(records, id, movedRecord, options) {
28135
- if (!Array.isArray(records) || !movedRecord || typeof movedRecord !== "object") return null;
28136
- const sourceIndex = records.findIndex((record) => Object.is(viewRecordId(record, options.recordId), id));
28137
- if (sourceIndex < 0) return null;
28138
- const source = records[sourceIndex];
28139
- const sourceColumn = normalizeAxisId(readKanbanAccessor(source, sourceIndex, options.columnBy));
28140
- const sourceLane = options.swimlaneBy == null ? null : normalizeAxisId(readKanbanAccessor(source, sourceIndex, options.swimlaneBy));
28141
- const sourceGroup = records.filter((record, index) => normalizeAxisId(readKanbanAccessor(record, index, options.columnBy)) === sourceColumn && (options.swimlaneBy == null || normalizeAxisId(readKanbanAccessor(record, index, options.swimlaneBy)) === sourceLane));
28142
- const from = {
28143
- column: sourceColumn,
28144
- lane: sourceLane,
28145
- index: sourceGroup.findIndex((record) => Object.is(viewRecordId(record, options.recordId), id))
28146
- };
28147
- const next = records.filter((_, index) => index !== sourceIndex);
28148
- 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));
28149
- const targetIndex = normalizeMoveIndex(options.destination.index, destinationRecords.length);
28150
- let insertion = next.length;
28151
- if (destinationRecords[targetIndex]) insertion = next.indexOf(destinationRecords[targetIndex]);
28152
- else if (destinationRecords.length) insertion = next.indexOf(destinationRecords.at(-1)) + 1;
28153
- next.splice(insertion, 0, movedRecord);
28154
- return {
28155
- records: next,
28156
- from,
28157
- to: { column: options.destination.column, lane: options.swimlaneBy == null ? null : options.destination.lane, index: targetIndex }
28158
- };
28159
- }
28160
- function readKanbanAccessor(record, index, accessor) {
28161
- return typeof accessor === "function" ? accessor(record, index) : accessor == null ? null : record?.[accessor];
28162
- }
28163
- function normalizeAxisId(value) {
28164
- return value == null ? "" : String(value);
29818
+ function reconcileKanbanPreference(descriptors, order, preserveUnknown = false) {
29819
+ if (!preserveUnknown) return reconcileKanbanOrder(descriptors, order);
29820
+ const preferred = normalizeOrder(order);
29821
+ const available = descriptors.map((descriptor) => descriptor.id);
29822
+ return [...preferred, ...available.filter((id) => !preferred.includes(id))];
28165
29823
  }
28166
29824
  function axisLabel(value) {
28167
29825
  if (value == null || String(value).trim() === "") return "Unassigned";
@@ -28173,13 +29831,42 @@ function normalizeLimit2(value) {
28173
29831
  if (!Number.isFinite(number2) || number2 < 0) throw new TypeError("Kanban column limit must be a non-negative number");
28174
29832
  return Math.floor(number2);
28175
29833
  }
28176
- function normalizeMoveIndex(value, maximum) {
28177
- if (value == null || !Number.isFinite(Number(value))) return maximum;
28178
- return Math.max(0, Math.min(maximum, Math.trunc(Number(value))));
29834
+ function normalizeLaneLimits(value) {
29835
+ if (value == null) return void 0;
29836
+ if (typeof value !== "object" || Array.isArray(value)) {
29837
+ throw new TypeError("Kanban column laneLimits must be an object");
29838
+ }
29839
+ const result = {};
29840
+ for (const [lane, limit] of Object.entries(
29841
+ /** @type {Record<string, unknown>} */
29842
+ value
29843
+ )) {
29844
+ const normalized = normalizeLimit2(limit);
29845
+ if (normalized != null) result[lane] = normalized;
29846
+ }
29847
+ return result;
29848
+ }
29849
+ function readColumnLaneLimit(column, lane) {
29850
+ const limits = column.laneLimits;
29851
+ if (!limits || typeof limits !== "object") return null;
29852
+ const value = limits[lane];
29853
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
29854
+ }
29855
+ function wipState(count2, limit) {
29856
+ if (limit == null) return "none";
29857
+ return count2 > limit ? "exceeded" : count2 === limit ? "limit" : "within";
28179
29858
  }
28180
29859
  function normalizeOrder(value) {
28181
29860
  return Array.isArray(value) ? [...new Set(value.map(String))] : [];
28182
29861
  }
29862
+ function stringifyValue(value) {
29863
+ if (value == null) return "";
29864
+ if (typeof value === "object") return "nodeType" in value ? String(
29865
+ /** @type {Node} */
29866
+ value.textContent ?? ""
29867
+ ) : "";
29868
+ return String(value);
29869
+ }
28183
29870
  function orderDescriptors(descriptors, order) {
28184
29871
  const byId = new Map(descriptors.map((descriptor) => [descriptor.id, descriptor]));
28185
29872
  return order.map((id) => byId.get(id)).filter(Boolean);
@@ -28193,12 +29880,28 @@ function moveOrderedId(order, id, targetId, position2) {
28193
29880
  next.splice(target + (position2 === "after" ? 1 : 0), 0, String(id));
28194
29881
  return next;
28195
29882
  }
29883
+ function mergeKanbanResolvedOrder(preference, descriptors, resolvedOrder, preserveUnknown) {
29884
+ if (!preserveUnknown) return [...resolvedOrder];
29885
+ const available = new Set(descriptors.map((descriptor) => descriptor.id));
29886
+ const replacements = [...resolvedOrder];
29887
+ let cursor = 0;
29888
+ const merged = normalizeOrder(preference).flatMap((id) => {
29889
+ if (!available.has(id)) return [id];
29890
+ return cursor < replacements.length ? [replacements[cursor++]] : [];
29891
+ });
29892
+ merged.push(...replacements.slice(cursor));
29893
+ return normalizeOrder(merged);
29894
+ }
28196
29895
  function sameMovePoint(left, right) {
28197
29896
  return left.column === right.column && left.lane === right.lane && left.index === right.index;
28198
29897
  }
28199
29898
  function sameArray3(left, right) {
28200
29899
  return left.length === right.length && left.every((value, index) => Object.is(value, right[index]));
28201
29900
  }
29901
+ function hasId(haystack, needle) {
29902
+ for (const candidate of haystack) if (Object.is(candidate, needle)) return true;
29903
+ return false;
29904
+ }
28202
29905
  function elementTarget(event) {
28203
29906
  const target = event.target;
28204
29907
  if (!target) return null;
@@ -32798,9 +34501,12 @@ export {
32798
34501
  TreeView,
32799
34502
  ValueList,
32800
34503
  __,
34504
+ absoluteKanbanIndex,
32801
34505
  addCalendarDays,
34506
+ addCalendarMonths,
32802
34507
  addDays,
32803
34508
  addMonths,
34509
+ allowsKanbanTransition,
32804
34510
  appIcon,
32805
34511
  appendCompact,
32806
34512
  appendCompactChild,
@@ -32819,6 +34525,7 @@ export {
32819
34525
  calendarDayKey,
32820
34526
  calendarEventIntersects,
32821
34527
  calendarEventSpansDays,
34528
+ calendarPageDate,
32822
34529
  calendarRange,
32823
34530
  chartSummaryTable,
32824
34531
  clamp,
@@ -32832,6 +34539,7 @@ export {
32832
34539
  configureIcons,
32833
34540
  copyButton,
32834
34541
  copyToClipboard,
34542
+ createKanbanHistory,
32835
34543
  debounce,
32836
34544
  deepMerge,
32837
34545
  defaultDateRangePresets,
@@ -32841,6 +34549,7 @@ export {
32841
34549
  emptyFilterAst,
32842
34550
  emptyState,
32843
34551
  escapeRegExp,
34552
+ evaluateKanbanMove,
32844
34553
  faFamilies,
32845
34554
  faIconClasses,
32846
34555
  faNames,
@@ -32875,15 +34584,19 @@ export {
32875
34584
  isSameDay,
32876
34585
  isTruncated,
32877
34586
  joinTime,
34587
+ kanbanSearchTerms,
32878
34588
  kitUrl,
32879
34589
  layoutCalendarSpans,
32880
34590
  layoutTimedCalendarEvents,
32881
34591
  loadFontAwesome,
32882
34592
  loadFontAwesomeKit,
34593
+ locateKanbanRecord,
32883
34594
  matchBreakpoint,
34595
+ matchesKanbanSearch,
32884
34596
  moveViewField,
32885
34597
  normalizeAppItems,
32886
34598
  normalizeCalendarEvents,
34599
+ normalizeKanbanRules,
32887
34600
  normalizeRange,
32888
34601
  normalizeViewFields,
32889
34602
  normalizeViewState,
@@ -32908,8 +34621,10 @@ export {
32908
34621
  registerFieldAdapters,
32909
34622
  registerIcons,
32910
34623
  renderViewField,
34624
+ reorderKanbanRecords,
32911
34625
  reorderTableRows,
32912
34626
  resolveElement,
34627
+ resolveKanbanRules,
32913
34628
  resolveSize,
32914
34629
  rovingTabindex,
32915
34630
  setLanguage,