@zeyos/zx 4.3.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 (34) hide show
  1. package/CHANGELOG.md +111 -1
  2. package/README.md +0 -3
  3. package/dist/types/components/calendar/calendar-model.d.ts +22 -0
  4. package/dist/types/components/card-view/card-view.d.ts +1 -1
  5. package/dist/types/components/card-view/record-card.d.ts +47 -5
  6. package/dist/types/components/kanban-view/kanban-policy.d.ts +268 -0
  7. package/dist/types/components/kanban-view/kanban-view.d.ts +669 -24
  8. package/dist/types/core/component.d.ts +6 -1
  9. package/dist/types/index.d.ts +2 -1
  10. package/dist/zx-zeyos.esm.js +35 -11
  11. package/dist/zx-zeyos.esm.min.js +1 -1
  12. package/dist/zx.css +597 -78
  13. package/dist/zx.esm.js +1758 -238
  14. package/dist/zx.esm.min.js +3 -3
  15. package/dist/zx.global.js +1758 -238
  16. package/dist/zx.global.min.js +3 -3
  17. package/dist/zx.min.css +1 -1
  18. package/docs/api.json +214 -2
  19. package/docs/llms.md +30 -30
  20. package/docs/llms.txt +0 -2
  21. package/package.json +1 -2
  22. package/src/components/calendar/calendar-model.js +45 -0
  23. package/src/components/calendar/calendar.css +30 -10
  24. package/src/components/calendar/calendar.js +8 -10
  25. package/src/components/card-view/card-view.css +211 -9
  26. package/src/components/card-view/card-view.js +7 -7
  27. package/src/components/card-view/record-card.js +92 -19
  28. package/src/components/kanban-view/kanban-policy.js +412 -0
  29. package/src/components/kanban-view/kanban-view.css +480 -36
  30. package/src/components/kanban-view/kanban-view.js +1514 -235
  31. package/src/components/message/message.css +9 -10
  32. package/src/core/component.js +8 -3
  33. package/src/index.js +8 -3
  34. 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
  }
@@ -24009,6 +24014,29 @@ function calendarRange(view, anchor, options = {}) {
24009
24014
  }
24010
24015
  return { start, end };
24011
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
+ }
24012
24040
  function normalizeCalendarEvents(records, options = {}) {
24013
24041
  if (!Array.isArray(records)) throw new TypeError("Calendar events must be an array");
24014
24042
  const readers = {
@@ -24452,7 +24480,7 @@ var Calendar = class extends Component {
24452
24480
  durationEditable: Object.prototype.hasOwnProperty.call(changes, "durationEditable") ? changes.durationEditable : current.durationEditable,
24453
24481
  data: changes.data ?? current.data
24454
24482
  };
24455
- const [next] = normalizeCalendarEvents([source]);
24483
+ const [next] = normalizeCalendarEvents([source], { dateUnit: this.options.dateUnit });
24456
24484
  next.data = source.data;
24457
24485
  this._events = this._events.map((event, eventIndex) => eventIndex === index ? next : event);
24458
24486
  this._mutationVersions.delete(String(id));
@@ -25478,13 +25506,9 @@ var Calendar = class extends Component {
25478
25506
  }
25479
25507
  /** @param {number} direction @returns {this} */
25480
25508
  _page(direction) {
25481
- const date = new Date(this._date.getTime());
25482
- if (this._view === "day") date.setDate(date.getDate() + direction);
25483
- else if (this._view === "agenda") date.setDate(date.getDate() + direction * this.options.agendaDays);
25484
- else if (this._view === "week") date.setDate(date.getDate() + direction * 7);
25485
- else if (this._view === "month") date.setMonth(date.getMonth() + direction);
25486
- else date.setFullYear(date.getFullYear() + direction);
25487
- return this.setDate(date);
25509
+ return this.setDate(calendarPageDate(this._view, this._date, direction, {
25510
+ agendaDays: this.options.agendaDays
25511
+ }));
25488
25512
  }
25489
25513
  /** @param {{start: Date, end: Date}} range @returns {string} */
25490
25514
  _rangeTitle(range) {
@@ -26641,6 +26665,16 @@ function createRecordCard(record, index, options = {}) {
26641
26665
  class: "zx-record-card__heading",
26642
26666
  id: headingId
26643
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;
26644
26678
  const selection = selectable === "multi" ? h("label", {
26645
26679
  class: "zx-record-card__selection"
26646
26680
  }, h("input", {
@@ -26662,19 +26696,19 @@ function createRecordCard(record, index, options = {}) {
26662
26696
  h(
26663
26697
  "div",
26664
26698
  { class: "zx-record-card__titles" },
26665
- heading,
26699
+ titleRow,
26666
26700
  isEmptyContent(subtitle) ? null : h("p", { class: "zx-record-card__subtitle" }, contentNode(subtitle))
26667
26701
  ),
26668
26702
  actionArea
26669
26703
  );
26670
- const metadata = createMetadata(record, index, visibleFields, titleSource, subtitleSource);
26671
- 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);
26672
26706
  const card = (
26673
26707
  /** @type {HTMLLIElement} */
26674
26708
  h("li", {
26675
26709
  class: "zx-record-card",
26676
26710
  tabindex: 0,
26677
- ariaSelected: selectable ? String(selected) : void 0,
26711
+ ariaDescription: selectable ? selected ? "Selected" : "Not selected" : void 0,
26678
26712
  ariaLabelledby: headingId,
26679
26713
  dataset: {
26680
26714
  recordIndex: String(index),
@@ -26685,6 +26719,20 @@ function createRecordCard(record, index, options = {}) {
26685
26719
  );
26686
26720
  return card;
26687
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
+ }
26688
26736
  function resolveRecordPreview(record, index, preview, previewAlt, fields = []) {
26689
26737
  if (preview == null) return null;
26690
26738
  const candidate = typeof preview === "function" ? preview(record, index) : readRecordSource(record, index, preview, fields, false);
@@ -26753,21 +26801,49 @@ function createPreview(preview) {
26753
26801
  dataset: { failed: String(preview.rejected) }
26754
26802
  }, fallback, image);
26755
26803
  }
26756
- function createMetadata(record, index, fields, titleSource, subtitleSource) {
26757
- const titleId = typeof titleSource === "string" ? titleSource : null;
26758
- const subtitleId = typeof subtitleSource === "string" ? subtitleSource : null;
26759
- const visible = fields.filter((field) => field.duplicate || field.id !== titleId && field.id !== subtitleId);
26760
- if (visible.length === 0) return null;
26761
- 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(
26762
26827
  "div",
26763
26828
  {
26764
26829
  class: "zx-record-card__field",
26765
- dataset: { fieldId: field.id }
26830
+ dataset: { fieldId: field.id, ...slot ? { cardSlot: slot } : {} }
26766
26831
  },
26767
26832
  h("dt", { class: "zx-record-card__label" }, field.label),
26768
26833
  h("dd", { class: "zx-record-card__value" }, contentNode(renderViewField(field, record, index)))
26769
26834
  )));
26770
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
+ }
26771
26847
  function createAction(action) {
26772
26848
  const label = String(action.label ?? "");
26773
26849
  const title = String(action.title ?? (label || action.id));
@@ -26837,7 +26913,7 @@ function safePreviewUrl(value) {
26837
26913
  try {
26838
26914
  const base = globalThis.location?.href ?? "https://zx.invalid/";
26839
26915
  const protocol = new URL(href, base).protocol.toLowerCase();
26840
- return ["http:", "https:", "blob:", "file:"].includes(protocol) ? href : null;
26916
+ return ["http:", "https:", "blob:"].includes(protocol) ? href : null;
26841
26917
  } catch {
26842
26918
  return null;
26843
26919
  }
@@ -26881,7 +26957,7 @@ var CardView = class extends RecordView {
26881
26957
  actions: [],
26882
26958
  groupBy: null,
26883
26959
  groupOrder: [],
26884
- minCardWidth: "18rem",
26960
+ minCardWidth: "15rem",
26885
26961
  maxColumns: null,
26886
26962
  variant: "outlined",
26887
26963
  loadingCount: 6,
@@ -27155,7 +27231,7 @@ var CardView = class extends RecordView {
27155
27231
  for (const [id, card] of this._cardNodesById) {
27156
27232
  const selected = this._isViewSelected(id);
27157
27233
  card.dataset.selected = String(selected);
27158
- if (this.options.selectable) card.setAttribute("aria-selected", String(selected));
27234
+ if (this.options.selectable) card.setAttribute("aria-description", selected ? "Selected" : "Not selected");
27159
27235
  const checkbox = (
27160
27236
  /** @type {HTMLInputElement|null} */
27161
27237
  card.querySelector("[data-record-selection]")
@@ -27299,11 +27375,11 @@ function normalizeGroupId(value) {
27299
27375
  return value == null || value === "" ? "" : String(value);
27300
27376
  }
27301
27377
  function normalizeCardWidth(value) {
27302
- 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";
27303
27379
  const text4 = String(value ?? "").trim();
27304
- if (!text4 || text4.length > 128 || /[;{}\u0000-\u001f\u007f]/.test(text4)) return "18rem";
27305
- if (/(?:url|image|image-set|cross-fade|element|expression)\s*\(/i.test(text4)) return "18rem";
27306
- 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";
27307
27383
  return text4;
27308
27384
  }
27309
27385
  function normalizeMaxColumns(value) {
@@ -27341,8 +27417,284 @@ function safeDomPart(value) {
27341
27417
  return value.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "empty";
27342
27418
  }
27343
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
+
27344
27642
  // src/components/kanban-view/kanban-view.js
27345
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
+ });
27346
27698
  var KanbanView = class extends RecordView {
27347
27699
  static cssName = "kanban-view";
27348
27700
  /** @type {Readonly<KanbanViewOptions & RecordViewOptions>} */
@@ -27359,6 +27711,27 @@ var KanbanView = class extends RecordView {
27359
27711
  link: null,
27360
27712
  actions: [],
27361
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: {},
27362
27735
  columnOrder: [],
27363
27736
  swimlaneOrder: [],
27364
27737
  collapsedColumns: [],
@@ -27394,17 +27767,45 @@ var KanbanView = class extends RecordView {
27394
27767
  this._kanbanSwimlaneOrder = normalizeOrder(options.swimlaneOrder);
27395
27768
  this._collapsedColumns = new Set(normalizeOrder(options.collapsedColumns));
27396
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);
27397
27774
  this._keyboardMove = /** @type {KanbanKeyboardMove|null} */
27398
27775
  null;
27399
- this._dragId = null;
27776
+ this._dragIds = /** @type {unknown[]} */
27777
+ [];
27400
27778
  this._dragDestination = /** @type {KanbanMovePoint|null} */
27401
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;
27402
27792
  this._announcementToken = 0;
27403
27793
  this._cardMeta = /* @__PURE__ */ new WeakMap();
27404
27794
  this._sectionMeta = /* @__PURE__ */ new WeakMap();
27405
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;
27406
27805
  this._toolbar = h("div", { class: "zx-record-view__toolbar zx-kanban-view__toolbar" });
27407
- 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());
27408
27809
  this._board = h("div", {
27409
27810
  class: "zx-kanban-view__board",
27410
27811
  role: "region",
@@ -27417,6 +27818,10 @@ var KanbanView = class extends RecordView {
27417
27818
  ariaAtomic: "true"
27418
27819
  });
27419
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
+ }
27420
27825
  this.listen(this._board, "click", (event) => this._handleBoardClick(
27421
27826
  /** @type {MouseEvent} */
27422
27827
  event
@@ -27430,20 +27835,22 @@ var KanbanView = class extends RecordView {
27430
27835
  event
27431
27836
  ));
27432
27837
  this.listen(this._board, "error", (event) => this._handlePreviewError(event), { capture: true });
27433
- this.listen(this._board, "dragstart", (event) => this._handleDragStart(
27434
- /** @type {DragEvent} */
27838
+ this.listen(this._board, "pointerdown", (event) => this._handlePointerDown(
27839
+ /** @type {PointerEvent} */
27435
27840
  event
27436
27841
  ));
27437
- this.listen(this._board, "dragover", (event) => this._handleDragOver(
27438
- /** @type {DragEvent} */
27842
+ this.listen(this._board, "pointermove", (event) => this._handlePointerMove(
27843
+ /** @type {PointerEvent} */
27439
27844
  event
27440
27845
  ));
27441
- this.listen(this._board, "drop", (event) => this._handleDrop(
27442
- /** @type {DragEvent} */
27846
+ this.listen(this._board, "pointerup", (event) => this._handlePointerUp(
27847
+ /** @type {PointerEvent} */
27443
27848
  event
27444
27849
  ));
27445
- this.listen(this._board, "dragend", () => this._clearDrag());
27850
+ this.listen(this._board, "pointercancel", () => this._clearDrag());
27851
+ this.listen(this._board, "dragstart", (event) => event.preventDefault());
27446
27852
  this._refreshView("init");
27853
+ if (options.contextMenu) this._createContextMenu();
27447
27854
  return root;
27448
27855
  } catch (error2) {
27449
27856
  if (this._kanbanSnapshot) restoreTarget(root, this._kanbanSnapshot);
@@ -27461,7 +27868,16 @@ var KanbanView = class extends RecordView {
27461
27868
  */
27462
27869
  setColumns(columns) {
27463
27870
  this._kanbanColumns = columns === null ? null : normalizeKanbanDescriptors(columns, "column");
27464
- 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
+ }
27465
27881
  this._refreshView("columns");
27466
27882
  this._emitKanbanState("columns");
27467
27883
  return this;
@@ -27477,7 +27893,17 @@ var KanbanView = class extends RecordView {
27477
27893
  */
27478
27894
  setSwimlanes(lanes) {
27479
27895
  this._kanbanSwimlanes = lanes === null ? null : normalizeKanbanDescriptors(lanes, "swimlane");
27480
- 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
+ }
27481
27907
  this._refreshView("swimlanes");
27482
27908
  this._emitKanbanState("swimlanes");
27483
27909
  return this;
@@ -27493,9 +27919,16 @@ var KanbanView = class extends RecordView {
27493
27919
  * @returns {this}
27494
27920
  */
27495
27921
  setColumnOrder(order, options = {}) {
27496
- const next = reconcileKanbanOrder(this._rawColumns(), order);
27497
- if (sameArray3(next, this.getColumnOrder())) return this;
27498
- 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
+ );
27499
27932
  this._refreshView("columnorder");
27500
27933
  if (!options.silent) this._emitKanbanState("columnorder");
27501
27934
  return this;
@@ -27521,9 +27954,17 @@ var KanbanView = class extends RecordView {
27521
27954
  * @returns {this}
27522
27955
  */
27523
27956
  setSwimlaneOrder(order, options = {}) {
27524
- const next = reconcileKanbanOrder(this._rawSwimlanes(), order);
27525
- if (sameArray3(next, this.getSwimlaneOrder())) return this;
27526
- 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
+ );
27527
27968
  this._refreshView("swimlaneorder");
27528
27969
  if (!options.silent) this._emitKanbanState("swimlaneorder");
27529
27970
  return this;
@@ -27591,91 +28032,244 @@ var KanbanView = class extends RecordView {
27591
28032
  return this.setSwimlaneCollapsed(id, !this._collapsedSwimlanes.has(String(id)));
27592
28033
  }
27593
28034
  /**
27594
- * Proposes and, in local mode, commits a record move. Destination acceptance is checked before
27595
- * 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], …)`.
27596
28036
  * @param {unknown} id Record id.
27597
28037
  * @param {KanbanDestination} destination Destination.
27598
28038
  * @returns {this}
27599
28039
  * @fires KanbanView#recordmove
28040
+ * @fires KanbanView#movereject
27600
28041
  * @fires RecordView#datachange
27601
28042
  */
27602
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) {
27603
28059
  const options = this._kanbanOptions();
27604
- const record = this.getRecord(id);
27605
- const from = this._recordLocation(id);
27606
- 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];
27607
28072
  const columns = this._resolvedColumns();
27608
28073
  const lanes = this._resolvedSwimlanes();
27609
28074
  const columnId = destination.column == null ? from.column : String(destination.column);
27610
28075
  const laneId = options.swimlaneBy == null ? null : destination.lane === void 0 ? from.lane : normalizeAxisId(destination.lane);
27611
28076
  const column = columns.find((candidate) => candidate.id === columnId);
27612
- 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;
27613
28078
  if (!column || options.swimlaneBy != null && !lane) {
27614
- this._announce("Move rejected: the destination is not available.");
28079
+ this._rejectMove("destination", { ids: moving, records, from, to: null, column: null, swimlane: null });
27615
28080
  return this;
27616
28081
  }
27617
- const destinationCount = this._recordsIn(columnId, laneId).filter((candidate) => !Object.is(this._viewRecordId(candidate.record), id)).length;
27618
- const destinationColumnCount = this._viewData.filter((candidate, candidateIndex) => normalizeAxisId(this._readAxis(options.columnBy, candidate, candidateIndex)) === columnId && !Object.is(this._viewRecordId(candidate), id)).length;
27619
- 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);
27620
28085
  const to = { column: columnId, lane: laneId, index };
27621
- if (sameMovePoint(from, to)) return this;
27622
- const context = { id, from: { ...from }, to: { ...to }, column: { ...column }, swimlane: lane ? { ...lane } : null };
27623
- if (column.accept && column.accept(record, context) === false || lane?.accept && lane.accept(record, context) === false) {
27624
- 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
+ );
27625
28109
  return this;
27626
28110
  }
27627
- let movedRecord = record;
27628
- if (options.moveMode === "local") {
27629
- movedRecord = this._localMovedRecord(record, from, to, column, lane);
27630
- if (!movedRecord) {
27631
- 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 });
27632
28117
  return this;
27633
28118
  }
28119
+ entries.push({ id: moving[position2], record: moved });
27634
28120
  }
27635
- const next = reorderKanbanData(this._viewData, id, movedRecord, {
28121
+ const next = reorderKanbanRecords(this._viewData, entries, {
27636
28122
  recordId: options.recordId,
27637
28123
  columnBy: (candidate, candidateIndex) => this._readAxis(options.columnBy, candidate, candidateIndex),
27638
28124
  swimlaneBy: options.swimlaneBy == null ? null : (candidate, candidateIndex) => this._readAxis(options.swimlaneBy, candidate, candidateIndex),
27639
28125
  destination: to
27640
28126
  });
27641
28127
  if (!next) {
27642
- this._announce("Move rejected: the destination could not be resolved.");
28128
+ this._rejectMove("destination", { ids: moving, records, from, to, column, swimlane: lane });
27643
28129
  return this;
27644
28130
  }
27645
- const finalCount = destinationColumnCount + 1;
27646
- const limitExceeded = column.limit != null && finalCount > column.limit;
28131
+ const wip = this._wipAnnouncement(evaluation);
27647
28132
  const moveEvent = this.emit("recordmove", {
27648
- record,
27649
- 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 } })),
27650
28138
  from: { ...from },
27651
28139
  to: { ...next.to },
27652
28140
  column: { ...column },
27653
28141
  swimlane: lane ? { ...lane } : null,
27654
- limitExceeded
27655
- });
28142
+ limitExceeded: evaluation.limitExceeded
28143
+ }, { honorDomCancellation: true });
27656
28144
  if (moveEvent.defaultPrevented) {
27657
- this._announce(`Move canceled for ${this._recordName(record, from.index)}.`);
28145
+ this._announce(this._text("moveCanceled", { name: this._movingName(records) }));
27658
28146
  return this;
27659
28147
  }
27660
28148
  if (options.moveMode === "external") {
27661
- 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
+ }));
27662
28154
  return this;
27663
28155
  }
27664
- this._viewData = next.records;
27665
- this._refreshView("move");
27666
- this.emit("datachange", { records: this.getData() });
27667
- this._announce(`Moved ${this._recordName(movedRecord, next.to.index)} to ${this._targetName(next.to)}.`);
27668
- 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());
28163
+ return this;
28164
+ }
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();
28172
+ }
28173
+ /**
28174
+ * Reverts the last committed local move, restoring the exact record order and selection.
28175
+ * @returns {this}
28176
+ * @fires RecordView#datachange
28177
+ * @fires KanbanView#historychange
28178
+ */
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");
27669
28230
  return this;
27670
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
+ }
27671
28261
  /** Returns common plus board-specific serializable configuration. @returns {KanbanViewState} */
27672
28262
  getViewState() {
28263
+ this.getColumnOrder();
28264
+ this.getSwimlaneOrder();
28265
+ const derivedColumns = this._kanbanColumns === null;
28266
+ const derivedSwimlanes = this._kanbanSwimlanes === null && this._kanbanOptions().swimlaneBy != null;
27673
28267
  return {
27674
28268
  ...super.getViewState(),
27675
- columnOrder: this.getColumnOrder(),
27676
- swimlaneOrder: this.getSwimlaneOrder(),
27677
- collapsedColumns: this.getCollapsedColumns(),
27678
- collapsedSwimlanes: this.getCollapsedSwimlanes()
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()
27679
28273
  };
27680
28274
  }
27681
28275
  /**
@@ -27687,21 +28281,29 @@ var KanbanView = class extends RecordView {
27687
28281
  setViewState(state, options = {}) {
27688
28282
  const source = state && typeof state === "object" ? state : {};
27689
28283
  super.setViewState(source, { silent: true });
27690
- this._kanbanColumnOrder = reconcileKanbanOrder(
27691
- this._rawColumns(),
27692
- Array.isArray(source.columnOrder) ? source.columnOrder : this._kanbanColumnOrder
27693
- );
27694
- this._kanbanSwimlaneOrder = reconcileKanbanOrder(
27695
- this._rawSwimlanes(),
27696
- Array.isArray(source.swimlaneOrder) ? source.swimlaneOrder : this._kanbanSwimlaneOrder
27697
- );
27698
- const columnIds = new Set(this._rawColumns().map((column) => column.id));
27699
- const laneIds = new Set(this._rawSwimlanes().map((lane) => lane.id));
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));
27700
28300
  if (Array.isArray(source.collapsedColumns)) {
27701
- this._collapsedColumns = new Set(source.collapsedColumns.map(String).filter((id) => columnIds.has(id)));
28301
+ const collapsed = normalizeOrder(source.collapsedColumns);
28302
+ this._collapsedColumns = new Set(derivedColumns ? collapsed : collapsed.filter((id) => columnIds.has(id)));
27702
28303
  }
27703
28304
  if (Array.isArray(source.collapsedSwimlanes)) {
27704
- this._collapsedSwimlanes = new Set(source.collapsedSwimlanes.map(String).filter((id) => laneIds.has(id)));
28305
+ const collapsed = normalizeOrder(source.collapsedSwimlanes);
28306
+ this._collapsedSwimlanes = new Set(derivedSwimlanes ? collapsed : collapsed.filter((id) => laneIds.has(id)));
27705
28307
  }
27706
28308
  this._refreshView("state");
27707
28309
  if (!options.silent) this._emitKanbanState("restore");
@@ -27711,6 +28313,11 @@ var KanbanView = class extends RecordView {
27711
28313
  destroy() {
27712
28314
  if (this._kanbanDestroyed) return;
27713
28315
  this._kanbanDestroyed = true;
28316
+ this._clearDrag();
28317
+ clearTimeout(this._pointerHold);
28318
+ clearTimeout(this._searchTimer);
28319
+ this._kanbanMenu?.destroy();
28320
+ this._kanbanMenu = null;
27714
28321
  const root = this.el;
27715
28322
  const snapshot10 = this._kanbanSnapshot;
27716
28323
  super.destroy();
@@ -27725,25 +28332,61 @@ var KanbanView = class extends RecordView {
27725
28332
  }
27726
28333
  if (_reason === "loading") return;
27727
28334
  const options = this._kanbanOptions();
28335
+ const activeElement = document.activeElement;
27728
28336
  const activeMeta = this._metaForTarget(document.activeElement);
27729
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;
27730
28352
  const columns = this._resolvedColumns();
27731
28353
  const lanes = this._resolvedSwimlanes();
27732
28354
  this._cardMeta = /* @__PURE__ */ new WeakMap();
27733
28355
  this._sectionMeta = /* @__PURE__ */ new WeakMap();
27734
28356
  this._cardsById = /* @__PURE__ */ new Map();
28357
+ this._resolveVisibility();
27735
28358
  const content = [];
27736
28359
  if (options.swimlaneBy == null) {
27737
28360
  content.push(this._renderColumns(columns, null, false));
27738
28361
  } else {
27739
28362
  lanes.forEach((lane) => content.push(this._renderLane(lane, columns)));
27740
28363
  }
27741
- const shownCards = this._cardsById.size;
27742
- 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")));
27743
28366
  this._board.replaceChildren(...content);
27744
- 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
+ }
27745
28371
  this._syncKeyboardTarget();
27746
- 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
+ }
27747
28390
  const card = this._cardsById.get(activeMeta.id);
27748
28391
  const target = restoreHandle ? card?.querySelector(".zx-kanban-view__move-handle") : card;
27749
28392
  if (target?.isConnected) target.focus();
@@ -27755,21 +28398,36 @@ var KanbanView = class extends RecordView {
27755
28398
  const collapsed = this._collapsedSwimlanes.has(lane.id);
27756
28399
  const headingId = uid("zx-kanban-lane");
27757
28400
  const bodyId = uid("zx-kanban-lane-body");
27758
- 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;
27759
28404
  const toggle = h("button", {
27760
28405
  class: "zx-kanban-view__collapse",
27761
28406
  type: "button",
27762
28407
  ariaExpanded: String(!collapsed),
27763
28408
  ariaControls: bodyId,
27764
- ariaLabel: `${collapsed ? "Expand" : "Collapse"} swim lane ${lane.label}`,
28409
+ ariaLabel: this._text(collapsed ? "expandLane" : "collapseLane", { label: lane.label }),
27765
28410
  dataset: { kanbanCollapse: "swimlane", kanbanId: lane.id }
27766
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
+ });
27767
28423
  const heading = h(
27768
28424
  "h2",
27769
28425
  { class: "zx-kanban-view__lane-heading", id: headingId },
27770
28426
  toggle,
27771
- h("span", {}, lane.label),
27772
- 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
+ ]
27773
28431
  );
27774
28432
  const body = this._renderColumns(columns, lane, true);
27775
28433
  body.id = bodyId;
@@ -27777,7 +28435,7 @@ var KanbanView = class extends RecordView {
27777
28435
  return h("section", {
27778
28436
  class: "zx-kanban-view__lane",
27779
28437
  ariaLabelledby: headingId,
27780
- dataset: { collapsed: String(collapsed) }
28438
+ dataset: { collapsed: String(collapsed), wip }
27781
28439
  }, heading, body);
27782
28440
  }
27783
28441
  /** @param {KanbanColumn[]} columns @param {KanbanSwimlane|null} lane @param {boolean} hasLanes @returns {HTMLElement} */
@@ -27803,36 +28461,67 @@ var KanbanView = class extends RecordView {
27803
28461
  const collapsed = this._collapsedColumns.has(column.id);
27804
28462
  const headingId = uid("zx-kanban-column");
27805
28463
  const listId = uid("zx-kanban-column-list");
27806
- const columnCount = this._viewData.filter((record, index) => normalizeAxisId(this._readAxis(options.columnBy, record, index)) === column.id).length;
27807
- 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);
27808
28470
  const toggle = h("button", {
27809
28471
  class: "zx-kanban-view__collapse",
27810
28472
  type: "button",
27811
28473
  ariaExpanded: String(!collapsed),
27812
28474
  ariaControls: listId,
27813
- ariaLabel: `${collapsed ? "Expand" : "Collapse"} column ${column.label}`,
28475
+ ariaLabel: this._text(collapsed ? "expandColumn" : "collapseColumn", { label: column.label }),
27814
28476
  dataset: { kanbanCollapse: "column", kanbanId: column.id }
27815
28477
  }, icon(collapsed ? "chevron-right" : "chevron-down", { size: 13 }));
27816
- const indicator = options.showCounts || column.limit != null ? h("span", {
27817
- class: "zx-kanban-view__count",
27818
- 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}`
27819
- }, 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;
27820
28489
  const heading = h(hasLanes ? "h3" : "h2", {
27821
28490
  class: "zx-kanban-view__column-heading",
27822
28491
  id: headingId
27823
- }, 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]);
27824
28493
  const list = h("ul", {
27825
28494
  class: "zx-kanban-view__cards",
27826
28495
  id: listId,
27827
28496
  ariaLabel: `${column.label}${lane ? `, ${lane.label}` : ""}`
27828
28497
  });
27829
- 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
+ }
27830
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;
27831
28520
  const section = h("section", {
27832
28521
  class: "zx-kanban-view__column",
27833
28522
  ariaLabelledby: headingId,
27834
28523
  dataset: { column: column.id, collapsed: String(collapsed), wip }
27835
- }, heading, list);
28524
+ }, heading, list, add);
27836
28525
  this._sectionMeta.set(section, { column: column.id, lane: lane?.id ?? null, list });
27837
28526
  return section;
27838
28527
  }
@@ -27840,13 +28529,14 @@ var KanbanView = class extends RecordView {
27840
28529
  _renderCard(record, index, column, lane, hasLanes) {
27841
28530
  const options = this._kanbanOptions();
27842
28531
  const id = this._viewRecordId(record);
27843
- 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;
27844
28535
  const handle = h("button", {
27845
28536
  class: "zx-kanban-view__move-handle",
27846
28537
  type: "button",
27847
- draggable: true,
27848
28538
  ariaPressed: String(grabbed),
27849
- ariaLabel: `Move ${this._recordName(record, index)}`
28539
+ ariaLabel: multiple > 1 ? this._text("moveMultiple", { count: multiple }) : this._text("move", { name: this._recordName(record, index) })
27850
28540
  }, icon("drag", { size: 13 }));
27851
28541
  const card = createRecordCard(record, index, {
27852
28542
  fields: this.getFields(),
@@ -27865,15 +28555,81 @@ var KanbanView = class extends RecordView {
27865
28555
  });
27866
28556
  card.tabIndex = 0;
27867
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);
27868
28561
  const meta = { record, id, index, column, lane };
27869
28562
  this._cardMeta.set(card, meta);
27870
28563
  this._cardsById.set(id, card);
27871
28564
  return card;
27872
28565
  }
27873
- /** @returns {HTMLElement} */
27874
- _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) {
27875
28631
  const supplied = typeof this.options.emptyText === "function" ? this.options.emptyText() : this.options.emptyText;
27876
- const content = supplied ?? "No records";
28632
+ const content = supplied ?? text4;
27877
28633
  return h(
27878
28634
  "div",
27879
28635
  { class: "zx-kanban-view__empty", role: "status" },
@@ -27883,6 +28639,37 @@ var KanbanView = class extends RecordView {
27883
28639
  ) : String(content)
27884
28640
  );
27885
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
+ }
27886
28673
  /** @returns {KanbanColumn[]} */
27887
28674
  _rawColumns() {
27888
28675
  const options = this._kanbanOptions();
@@ -27891,7 +28678,11 @@ var KanbanView = class extends RecordView {
27891
28678
  /** @returns {KanbanColumn[]} */
27892
28679
  _resolvedColumns() {
27893
28680
  const columns = this._rawColumns();
27894
- this._kanbanColumnOrder = reconcileKanbanOrder(columns, this._kanbanColumnOrder);
28681
+ this._kanbanColumnOrder = reconcileKanbanPreference(
28682
+ columns,
28683
+ this._kanbanColumnOrder,
28684
+ this._kanbanColumns === null
28685
+ );
27895
28686
  return orderDescriptors(columns, this._kanbanColumnOrder);
27896
28687
  }
27897
28688
  /** @returns {KanbanSwimlane[]} */
@@ -27903,7 +28694,12 @@ var KanbanView = class extends RecordView {
27903
28694
  /** @returns {KanbanSwimlane[]} */
27904
28695
  _resolvedSwimlanes() {
27905
28696
  const lanes = this._rawSwimlanes();
27906
- 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
+ );
27907
28703
  return orderDescriptors(lanes, this._kanbanSwimlaneOrder);
27908
28704
  }
27909
28705
  /** @param {KanbanAccessor} accessor @param {KanbanRecord} record @param {number} index @returns {unknown} */
@@ -27927,13 +28723,84 @@ var KanbanView = class extends RecordView {
27927
28723
  /** @param {unknown} id @returns {KanbanMovePoint|null} */
27928
28724
  _recordLocation(id) {
27929
28725
  const options = this._kanbanOptions();
27930
- 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));
27931
28727
  if (index < 0) return null;
27932
- const record = this._viewData[index];
27933
- const column = normalizeAxisId(this._readAxis(options.columnBy, record, index));
27934
- const lane = options.swimlaneBy == null ? null : normalizeAxisId(this._readAxis(options.swimlaneBy, record, index));
27935
- const within = this._recordsIn(column, lane).findIndex((entry) => Object.is(this._viewRecordId(entry.record), id));
27936
- 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
+ );
27937
28804
  }
27938
28805
  /**
27939
28806
  * @param {KanbanRecord} record @param {KanbanMovePoint} from @param {KanbanMovePoint} to
@@ -27955,8 +28822,327 @@ var KanbanView = class extends RecordView {
27955
28822
  if (options.swimlaneBy != null && normalizeAxisId(this._readAxis(options.swimlaneBy, clone, currentIndex)) !== to.lane) return null;
27956
28823
  return clone;
27957
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
+ }
27958
29140
  /** @param {MouseEvent} event @returns {void} */
27959
29141
  _handleBoardClick(event) {
29142
+ if (this._suppressClick) {
29143
+ this._suppressClick = false;
29144
+ return;
29145
+ }
27960
29146
  const target = elementTarget(event);
27961
29147
  const collapse = target?.closest("[data-kanban-collapse]");
27962
29148
  if (collapse) {
@@ -27968,6 +29154,23 @@ var KanbanView = class extends RecordView {
27968
29154
  else this.toggleSwimlane(String(element.dataset.kanbanId));
27969
29155
  return;
27970
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
+ }
27971
29174
  const meta = this._metaForTarget(target);
27972
29175
  if (!meta) return;
27973
29176
  const selection = (
@@ -28016,7 +29219,14 @@ var KanbanView = class extends RecordView {
28016
29219
  }
28017
29220
  /** @param {KeyboardEvent} event @returns {void} */
28018
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
+ }
28019
29228
  const target = elementTarget(event);
29229
+ if (this._handleHistoryKeydown(event, target)) return;
28020
29230
  const handle = target?.closest(".zx-kanban-view__move-handle");
28021
29231
  const meta = this._metaForTarget(handle);
28022
29232
  if (handle && meta) {
@@ -28040,12 +29250,33 @@ var KanbanView = class extends RecordView {
28040
29250
  this.toggleSelection(cardMeta.id);
28041
29251
  }
28042
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
+ }
28043
29274
  /** @param {KeyboardEvent} event @param {HTMLElement} handle @param {KanbanCardMeta} meta @returns {void} */
28044
29275
  _handleMoveKeydown(event, handle, meta) {
28045
29276
  if (event.key === " " || event.key === "Enter") {
28046
29277
  event.preventDefault();
28047
- if (this._keyboardMove && Object.is(this._keyboardMove.id, meta.id)) {
28048
- const destination = { ...this._keyboardMove.to };
29278
+ if (this._keyboardMove?.ids.some((candidate) => Object.is(candidate, meta.id))) {
29279
+ const move2 = this._keyboardMove;
28049
29280
  this._keyboardMove = null;
28050
29281
  handle.setAttribute("aria-pressed", "false");
28051
29282
  const card = (
@@ -28054,22 +29285,24 @@ var KanbanView = class extends RecordView {
28054
29285
  );
28055
29286
  if (card) delete card.dataset.grabbed;
28056
29287
  this._syncKeyboardTarget();
28057
- this.moveRecord(meta.id, destination);
29288
+ this._commitInteractionMove(move2.ids, move2.to);
28058
29289
  } else {
28059
29290
  const from = this._recordLocation(meta.id);
28060
29291
  if (!from) return;
28061
- 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 };
28062
29295
  handle.setAttribute("aria-pressed", "true");
28063
29296
  const card = handle.closest(".zx-record-card");
28064
29297
  if (card) card.dataset.grabbed = "true";
28065
29298
  this._syncKeyboardTarget();
28066
- 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) }));
28067
29300
  }
28068
29301
  return;
28069
29302
  }
28070
29303
  if (event.key === "Escape" && this._keyboardMove) {
28071
29304
  event.preventDefault();
28072
- const record = this.getRecord(this._keyboardMove.id);
29305
+ const records = this._keyboardMove.ids.map((id) => this.getRecord(id)).filter(Boolean);
28073
29306
  this._keyboardMove = null;
28074
29307
  handle.setAttribute("aria-pressed", "false");
28075
29308
  const card = (
@@ -28078,10 +29311,13 @@ var KanbanView = class extends RecordView {
28078
29311
  );
28079
29312
  if (card) delete card.dataset.grabbed;
28080
29313
  this._syncKeyboardTarget();
28081
- 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"));
28082
29318
  return;
28083
29319
  }
28084
- 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;
28085
29321
  event.preventDefault();
28086
29322
  const move = this._keyboardMove;
28087
29323
  const laneModifier = event.altKey || event.ctrlKey || event.metaKey;
@@ -28098,10 +29334,14 @@ var KanbanView = class extends RecordView {
28098
29334
  } else if (event.key === "ArrowUp" || event.key === "ArrowDown") {
28099
29335
  move.to.index += event.key === "ArrowUp" ? -1 : 1;
28100
29336
  }
28101
- const targetCount = this._recordsIn(move.to.column, move.to.lane).filter((entry) => !Object.is(this._viewRecordId(entry.record), move.id)).length;
28102
- 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);
28103
29339
  this._syncKeyboardTarget();
28104
- 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
+ }));
28105
29345
  }
28106
29346
  /** @param {Event} event @returns {void} */
28107
29347
  _handlePreviewError(event) {
@@ -28117,80 +29357,246 @@ var KanbanView = class extends RecordView {
28117
29357
  if (preview) preview.dataset.failed = "true";
28118
29358
  image.hidden = true;
28119
29359
  }
28120
- /** @param {DragEvent} event @returns {void} */
28121
- _handleDragStart(event) {
28122
- const handle = elementTarget(event)?.closest(".zx-kanban-view__move-handle");
28123
- const meta = this._metaForTarget(handle);
28124
- if (!handle || !meta) {
28125
- event.preventDefault();
28126
- 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;
28127
29406
  }
28128
- this._dragId = meta.id;
28129
- const card = handle.closest(".zx-record-card");
28130
- if (card) card.dataset.dragging = "true";
28131
- event.dataTransfer?.setData("text/plain", "zx-kanban-record");
28132
- 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);
28133
29412
  }
28134
- /** @param {DragEvent} event @returns {void} */
28135
- _handleDragOver(event) {
28136
- if (this._dragId == null) return;
28137
- const section = elementTarget(event)?.closest(".zx-kanban-view__column");
28138
- const sectionMeta = section ? this._sectionMeta.get(section) : null;
28139
- 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;
28140
29420
  event.preventDefault();
28141
- const destination = this._pointerDestination(event, sectionMeta);
28142
- 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;
28143
29493
  this._clearDropMarkers();
28144
- const card = elementTarget(event)?.closest(".zx-record-card");
28145
- const cardMeta = card ? this._cardMeta.get(card) : null;
28146
- if (card && cardMeta && !Object.is(cardMeta.id, this._dragId)) {
28147
- const rectangle = card.getBoundingClientRect();
28148
- card.dataset.drop = event.clientY >= rectangle.top + rectangle.height / 2 ? "after" : "before";
28149
- } else {
28150
- sectionMeta.list.dataset.drop = "append";
29494
+ if (!sectionMeta || this._collapsedColumns.has(sectionMeta.column)) {
29495
+ this._dragDestination = null;
29496
+ return;
28151
29497
  }
28152
- 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 };
28153
29508
  }
28154
- /** @param {DragEvent} event @returns {void} */
28155
- _handleDrop(event) {
28156
- if (this._dragId == null || !this._dragDestination) return;
28157
- event.preventDefault();
28158
- const id = this._dragId;
28159
- const destination = { ...this._dragDestination };
28160
- this._clearDrag();
28161
- this.moveRecord(id, destination);
28162
- }
28163
- /** @param {DragEvent} event @param {KanbanSectionMeta} section @returns {KanbanMovePoint} */
28164
- _pointerDestination(event, section) {
28165
- const cards = [...section.list.querySelectorAll(":scope > .zx-record-card")].filter((card) => !Object.is(this._cardMeta.get(card)?.id, this._dragId));
28166
- const target = elementTarget(event)?.closest(".zx-record-card");
28167
- const targetMeta = target ? this._cardMeta.get(target) : null;
28168
- if (target && targetMeta && Object.is(targetMeta.id, this._dragId)) {
28169
- const from = this._recordLocation(this._dragId);
28170
- return { column: section.column, lane: section.lane, index: from?.index ?? cards.length };
28171
- }
28172
- const targetIndex = target ? cards.indexOf(target) : -1;
28173
- if (targetIndex < 0) return { column: section.column, lane: section.lane, index: cards.length };
28174
- const rectangle = target.getBoundingClientRect();
28175
- return {
28176
- column: section.column,
28177
- lane: section.lane,
28178
- index: targetIndex + (event.clientY >= rectangle.top + rectangle.height / 2 ? 1 : 0)
28179
- };
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
+ });
28180
29545
  }
28181
29546
  /** @returns {void} */
28182
29547
  _clearDrag() {
28183
- this._clearDropMarkers();
28184
- for (const card of this._board.querySelectorAll("[data-dragging]")) delete /** @type {HTMLElement} */
28185
- card.dataset.dragging;
28186
- 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 = [];
28187
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;
28188
29571
  }
28189
29572
  /** @returns {void} */
28190
29573
  _clearDropMarkers() {
28191
29574
  for (const element of this._board.querySelectorAll("[data-drop]")) delete /** @type {HTMLElement} */
28192
29575
  element.dataset.drop;
28193
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
+ }
28194
29600
  /** @returns {void} */
28195
29601
  _syncKeyboardTarget() {
28196
29602
  for (const element of this._board.querySelectorAll("[data-keyboard-target]")) {
@@ -28205,7 +29611,7 @@ var KanbanView = class extends RecordView {
28205
29611
  });
28206
29612
  const sectionMeta = section ? this._sectionMeta.get(section) : null;
28207
29613
  if (!sectionMeta) return;
28208
- 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);
28209
29615
  const target = cards[move.to.index];
28210
29616
  if (target) target.dataset.keyboardTarget = "before";
28211
29617
  else sectionMeta.list.dataset.keyboardTarget = "append";
@@ -28215,7 +29621,7 @@ var KanbanView = class extends RecordView {
28215
29621
  for (const [id, card] of this._cardsById) {
28216
29622
  const selected = this._isViewSelected(id);
28217
29623
  card.dataset.selected = String(selected);
28218
- if (this.options.selectable) card.setAttribute("aria-selected", String(selected));
29624
+ this._applyCardDescription(card, id);
28219
29625
  const input = (
28220
29626
  /** @type {HTMLInputElement|null} */
28221
29627
  card.querySelector("[data-record-selection]")
@@ -28235,6 +29641,50 @@ var KanbanView = class extends RecordView {
28235
29641
  this._cardsById.get(id)?.querySelector(".zx-kanban-view__move-handle") ?? null
28236
29642
  );
28237
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
+ }
28238
29688
  /** @param {KanbanRecord} record @param {number} index @returns {string} */
28239
29689
  _recordName(record, index) {
28240
29690
  const source = this._kanbanOptions().titleField;
@@ -28242,10 +29692,16 @@ var KanbanView = class extends RecordView {
28242
29692
  if (value && typeof value === "object" && "nodeType" in value) {
28243
29693
  return (
28244
29694
  /** @type {Node} */
28245
- value.textContent?.trim() || "record"
29695
+ value.textContent?.trim() || this._text("record")
28246
29696
  );
28247
29697
  }
28248
- 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 });
28249
29705
  }
28250
29706
  /** @param {KanbanMovePoint} point @returns {string} */
28251
29707
  _targetName(point) {
@@ -28253,6 +29709,26 @@ var KanbanView = class extends RecordView {
28253
29709
  const lane = point.lane == null ? null : this._resolvedSwimlanes().find((candidate) => candidate.id === point.lane);
28254
29710
  return `${column?.label ?? point.column}${lane ? `, ${lane.label}` : ""}`;
28255
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
+ }
28256
29732
  /** @param {string} message @returns {void} */
28257
29733
  _announce(message) {
28258
29734
  const token = ++this._announcementToken;
@@ -28292,6 +29768,9 @@ function normalizeKanbanDescriptors(descriptors, axis = "column") {
28292
29768
  if (source.accept != null && typeof source.accept !== "function") {
28293
29769
  throw new TypeError(`Kanban ${axis} ${id} accept must be a function`);
28294
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
+ }
28295
29774
  seen.add(id);
28296
29775
  const normalized = (
28297
29776
  /** @type {Record<string, any>} */
@@ -28299,11 +29778,21 @@ function normalizeKanbanDescriptors(descriptors, axis = "column") {
28299
29778
  ...source,
28300
29779
  id,
28301
29780
  label: String(source.label ?? axisLabel(source.id)),
29781
+ limit: normalizeLimit2(source.limit),
29782
+ from: Array.isArray(source.from) ? source.from.map(String) : null,
28302
29783
  ...Object.prototype.hasOwnProperty.call(source, "value") ? {} : { value: source.id }
28303
29784
  }
28304
29785
  );
28305
- if (axis === "column") normalized.limit = normalizeLimit2(source.limit);
28306
- 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
+ }
28307
29796
  return normalized;
28308
29797
  })
28309
29798
  );
@@ -28326,37 +29815,11 @@ function reconcileKanbanOrder(descriptors, order) {
28326
29815
  const preferred = normalizeOrder(order).filter((id) => allowed.has(id));
28327
29816
  return [...new Set(preferred), ...available.filter((id) => !preferred.includes(id))];
28328
29817
  }
28329
- function reorderKanbanData(records, id, movedRecord, options) {
28330
- if (!Array.isArray(records) || !movedRecord || typeof movedRecord !== "object") return null;
28331
- const sourceIndex = records.findIndex((record) => Object.is(viewRecordId(record, options.recordId), id));
28332
- if (sourceIndex < 0) return null;
28333
- const source = records[sourceIndex];
28334
- const sourceColumn = normalizeAxisId(readKanbanAccessor(source, sourceIndex, options.columnBy));
28335
- const sourceLane = options.swimlaneBy == null ? null : normalizeAxisId(readKanbanAccessor(source, sourceIndex, options.swimlaneBy));
28336
- const sourceGroup = records.filter((record, index) => normalizeAxisId(readKanbanAccessor(record, index, options.columnBy)) === sourceColumn && (options.swimlaneBy == null || normalizeAxisId(readKanbanAccessor(record, index, options.swimlaneBy)) === sourceLane));
28337
- const from = {
28338
- column: sourceColumn,
28339
- lane: sourceLane,
28340
- index: sourceGroup.findIndex((record) => Object.is(viewRecordId(record, options.recordId), id))
28341
- };
28342
- const next = records.filter((_, index) => index !== sourceIndex);
28343
- 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));
28344
- const targetIndex = normalizeMoveIndex(options.destination.index, destinationRecords.length);
28345
- let insertion = next.length;
28346
- if (destinationRecords[targetIndex]) insertion = next.indexOf(destinationRecords[targetIndex]);
28347
- else if (destinationRecords.length) insertion = next.indexOf(destinationRecords.at(-1)) + 1;
28348
- next.splice(insertion, 0, movedRecord);
28349
- return {
28350
- records: next,
28351
- from,
28352
- to: { column: options.destination.column, lane: options.swimlaneBy == null ? null : options.destination.lane, index: targetIndex }
28353
- };
28354
- }
28355
- function readKanbanAccessor(record, index, accessor) {
28356
- return typeof accessor === "function" ? accessor(record, index) : accessor == null ? null : record?.[accessor];
28357
- }
28358
- function normalizeAxisId(value) {
28359
- 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))];
28360
29823
  }
28361
29824
  function axisLabel(value) {
28362
29825
  if (value == null || String(value).trim() === "") return "Unassigned";
@@ -28368,13 +29831,42 @@ function normalizeLimit2(value) {
28368
29831
  if (!Number.isFinite(number2) || number2 < 0) throw new TypeError("Kanban column limit must be a non-negative number");
28369
29832
  return Math.floor(number2);
28370
29833
  }
28371
- function normalizeMoveIndex(value, maximum) {
28372
- if (value == null || !Number.isFinite(Number(value))) return maximum;
28373
- 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";
28374
29858
  }
28375
29859
  function normalizeOrder(value) {
28376
29860
  return Array.isArray(value) ? [...new Set(value.map(String))] : [];
28377
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
+ }
28378
29870
  function orderDescriptors(descriptors, order) {
28379
29871
  const byId = new Map(descriptors.map((descriptor) => [descriptor.id, descriptor]));
28380
29872
  return order.map((id) => byId.get(id)).filter(Boolean);
@@ -28388,12 +29880,28 @@ function moveOrderedId(order, id, targetId, position2) {
28388
29880
  next.splice(target + (position2 === "after" ? 1 : 0), 0, String(id));
28389
29881
  return next;
28390
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
+ }
28391
29895
  function sameMovePoint(left, right) {
28392
29896
  return left.column === right.column && left.lane === right.lane && left.index === right.index;
28393
29897
  }
28394
29898
  function sameArray3(left, right) {
28395
29899
  return left.length === right.length && left.every((value, index) => Object.is(value, right[index]));
28396
29900
  }
29901
+ function hasId(haystack, needle) {
29902
+ for (const candidate of haystack) if (Object.is(candidate, needle)) return true;
29903
+ return false;
29904
+ }
28397
29905
  function elementTarget(event) {
28398
29906
  const target = event.target;
28399
29907
  if (!target) return null;
@@ -32993,9 +34501,12 @@ export {
32993
34501
  TreeView,
32994
34502
  ValueList,
32995
34503
  __,
34504
+ absoluteKanbanIndex,
32996
34505
  addCalendarDays,
34506
+ addCalendarMonths,
32997
34507
  addDays,
32998
34508
  addMonths,
34509
+ allowsKanbanTransition,
32999
34510
  appIcon,
33000
34511
  appendCompact,
33001
34512
  appendCompactChild,
@@ -33014,6 +34525,7 @@ export {
33014
34525
  calendarDayKey,
33015
34526
  calendarEventIntersects,
33016
34527
  calendarEventSpansDays,
34528
+ calendarPageDate,
33017
34529
  calendarRange,
33018
34530
  chartSummaryTable,
33019
34531
  clamp,
@@ -33027,6 +34539,7 @@ export {
33027
34539
  configureIcons,
33028
34540
  copyButton,
33029
34541
  copyToClipboard,
34542
+ createKanbanHistory,
33030
34543
  debounce,
33031
34544
  deepMerge,
33032
34545
  defaultDateRangePresets,
@@ -33036,6 +34549,7 @@ export {
33036
34549
  emptyFilterAst,
33037
34550
  emptyState,
33038
34551
  escapeRegExp,
34552
+ evaluateKanbanMove,
33039
34553
  faFamilies,
33040
34554
  faIconClasses,
33041
34555
  faNames,
@@ -33070,15 +34584,19 @@ export {
33070
34584
  isSameDay,
33071
34585
  isTruncated,
33072
34586
  joinTime,
34587
+ kanbanSearchTerms,
33073
34588
  kitUrl,
33074
34589
  layoutCalendarSpans,
33075
34590
  layoutTimedCalendarEvents,
33076
34591
  loadFontAwesome,
33077
34592
  loadFontAwesomeKit,
34593
+ locateKanbanRecord,
33078
34594
  matchBreakpoint,
34595
+ matchesKanbanSearch,
33079
34596
  moveViewField,
33080
34597
  normalizeAppItems,
33081
34598
  normalizeCalendarEvents,
34599
+ normalizeKanbanRules,
33082
34600
  normalizeRange,
33083
34601
  normalizeViewFields,
33084
34602
  normalizeViewState,
@@ -33103,8 +34621,10 @@ export {
33103
34621
  registerFieldAdapters,
33104
34622
  registerIcons,
33105
34623
  renderViewField,
34624
+ reorderKanbanRecords,
33106
34625
  reorderTableRows,
33107
34626
  resolveElement,
34627
+ resolveKanbanRules,
33108
34628
  resolveSize,
33109
34629
  rovingTabindex,
33110
34630
  setLanguage,