@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.global.js CHANGED
@@ -91,9 +91,12 @@ var zx = (() => {
91
91
  TreeView: () => TreeView,
92
92
  ValueList: () => ValueList,
93
93
  __: () => __,
94
+ absoluteKanbanIndex: () => absoluteKanbanIndex,
94
95
  addCalendarDays: () => addCalendarDays,
96
+ addCalendarMonths: () => addCalendarMonths,
95
97
  addDays: () => addDays,
96
98
  addMonths: () => addMonths,
99
+ allowsKanbanTransition: () => allowsKanbanTransition,
97
100
  appIcon: () => appIcon,
98
101
  appendCompact: () => appendCompact,
99
102
  appendCompactChild: () => appendCompactChild,
@@ -112,6 +115,7 @@ var zx = (() => {
112
115
  calendarDayKey: () => calendarDayKey,
113
116
  calendarEventIntersects: () => calendarEventIntersects,
114
117
  calendarEventSpansDays: () => calendarEventSpansDays,
118
+ calendarPageDate: () => calendarPageDate,
115
119
  calendarRange: () => calendarRange,
116
120
  chartSummaryTable: () => chartSummaryTable,
117
121
  clamp: () => clamp,
@@ -125,6 +129,7 @@ var zx = (() => {
125
129
  configureIcons: () => configureIcons,
126
130
  copyButton: () => copyButton,
127
131
  copyToClipboard: () => copyToClipboard,
132
+ createKanbanHistory: () => createKanbanHistory,
128
133
  debounce: () => debounce,
129
134
  deepMerge: () => deepMerge,
130
135
  defaultDateRangePresets: () => defaultDateRangePresets,
@@ -134,6 +139,7 @@ var zx = (() => {
134
139
  emptyFilterAst: () => emptyFilterAst,
135
140
  emptyState: () => emptyState,
136
141
  escapeRegExp: () => escapeRegExp,
142
+ evaluateKanbanMove: () => evaluateKanbanMove,
137
143
  faFamilies: () => faFamilies,
138
144
  faIconClasses: () => faIconClasses,
139
145
  faNames: () => faNames,
@@ -168,15 +174,19 @@ var zx = (() => {
168
174
  isSameDay: () => isSameDay,
169
175
  isTruncated: () => isTruncated,
170
176
  joinTime: () => joinTime,
177
+ kanbanSearchTerms: () => kanbanSearchTerms,
171
178
  kitUrl: () => kitUrl,
172
179
  layoutCalendarSpans: () => layoutCalendarSpans,
173
180
  layoutTimedCalendarEvents: () => layoutTimedCalendarEvents,
174
181
  loadFontAwesome: () => loadFontAwesome,
175
182
  loadFontAwesomeKit: () => loadFontAwesomeKit,
183
+ locateKanbanRecord: () => locateKanbanRecord,
176
184
  matchBreakpoint: () => matchBreakpoint,
185
+ matchesKanbanSearch: () => matchesKanbanSearch,
177
186
  moveViewField: () => moveViewField,
178
187
  normalizeAppItems: () => normalizeAppItems,
179
188
  normalizeCalendarEvents: () => normalizeCalendarEvents,
189
+ normalizeKanbanRules: () => normalizeKanbanRules,
180
190
  normalizeRange: () => normalizeRange,
181
191
  normalizeViewFields: () => normalizeViewFields,
182
192
  normalizeViewState: () => normalizeViewState,
@@ -201,8 +211,10 @@ var zx = (() => {
201
211
  registerFieldAdapters: () => registerFieldAdapters,
202
212
  registerIcons: () => registerIcons,
203
213
  renderViewField: () => renderViewField,
214
+ reorderKanbanRecords: () => reorderKanbanRecords,
204
215
  reorderTableRows: () => reorderTableRows,
205
216
  resolveElement: () => resolveElement,
217
+ resolveKanbanRules: () => resolveKanbanRules,
206
218
  resolveSize: () => resolveSize,
207
219
  rovingTabindex: () => rovingTabindex,
208
220
  setLanguage: () => setLanguage,
@@ -1039,21 +1051,26 @@ var zx = (() => {
1039
1051
  * Dispatches a component event and a bubbling, composed `zx-*` DOM event.
1040
1052
  * @param {string} type Lowercase event name.
1041
1053
  * @param {Record<string, unknown>} [detail={}] Single event detail object.
1054
+ * @param {{honorDomCancellation?:boolean}} [options={}] Mirrored DOM-event behavior. Components
1055
+ * with a cancelable mutation boundary may opt into treating `preventDefault()` on the bubbling
1056
+ * `zx-*` event exactly like cancellation of the component event.
1042
1057
  * @returns {Event & {detail: Record<string, unknown>}}
1043
1058
  * @fires Component#event
1044
1059
  */
1045
- emit(type, detail = {}) {
1060
+ emit(type, detail = {}, options = {}) {
1046
1061
  if (detail === null || typeof detail !== "object" || Array.isArray(detail)) {
1047
1062
  throw new TypeError("Event detail must be an object");
1048
1063
  }
1049
1064
  const componentEvent = createCustomEvent(type, detail, { cancelable: true });
1050
1065
  this.dispatchEvent(componentEvent);
1051
1066
  if (this.el) {
1052
- this.el.dispatchEvent(createCustomEvent(`zx-${type}`, detail, {
1067
+ const domEvent = createCustomEvent(`zx-${type}`, detail, {
1053
1068
  bubbles: true,
1054
1069
  composed: true,
1055
1070
  cancelable: true
1056
- }));
1071
+ });
1072
+ this.el.dispatchEvent(domEvent);
1073
+ if (options.honorDomCancellation && domEvent.defaultPrevented) componentEvent.preventDefault();
1057
1074
  }
1058
1075
  return componentEvent;
1059
1076
  }
@@ -24250,6 +24267,29 @@ var zx = (() => {
24250
24267
  }
24251
24268
  return { start, end };
24252
24269
  }
24270
+ function calendarPageDate(view, anchor, direction, options = {}) {
24271
+ const date = validDate5(anchor, "Calendar date");
24272
+ const step = integer(direction, "direction");
24273
+ if (view === "day") date.setDate(date.getDate() + step);
24274
+ else if (view === "agenda") {
24275
+ date.setDate(date.getDate() + step * positiveInteger(options.agendaDays ?? 14, "agendaDays"));
24276
+ } else if (view === "week") date.setDate(date.getDate() + step * 7);
24277
+ else if (view === "month") return addCalendarMonths(date, step);
24278
+ else if (view === "year") return addCalendarMonths(date, step * 12);
24279
+ else throw new RangeError(`Unknown calendar view: ${view}`);
24280
+ return date;
24281
+ }
24282
+ function addCalendarMonths(value, months) {
24283
+ const date = validDate5(value, "Calendar date");
24284
+ const day = date.getDate();
24285
+ date.setDate(1);
24286
+ date.setMonth(date.getMonth() + integer(months, "month amount"));
24287
+ date.setDate(Math.min(day, daysInCalendarMonth(date.getFullYear(), date.getMonth())));
24288
+ return date;
24289
+ }
24290
+ function daysInCalendarMonth(year, month) {
24291
+ return new Date(year, month + 1, 0).getDate();
24292
+ }
24253
24293
  function normalizeCalendarEvents(records, options = {}) {
24254
24294
  if (!Array.isArray(records)) throw new TypeError("Calendar events must be an array");
24255
24295
  const readers = {
@@ -24693,7 +24733,7 @@ var zx = (() => {
24693
24733
  durationEditable: Object.prototype.hasOwnProperty.call(changes, "durationEditable") ? changes.durationEditable : current.durationEditable,
24694
24734
  data: changes.data ?? current.data
24695
24735
  };
24696
- const [next] = normalizeCalendarEvents([source]);
24736
+ const [next] = normalizeCalendarEvents([source], { dateUnit: this.options.dateUnit });
24697
24737
  next.data = source.data;
24698
24738
  this._events = this._events.map((event, eventIndex) => eventIndex === index ? next : event);
24699
24739
  this._mutationVersions.delete(String(id));
@@ -25719,13 +25759,9 @@ var zx = (() => {
25719
25759
  }
25720
25760
  /** @param {number} direction @returns {this} */
25721
25761
  _page(direction) {
25722
- const date = new Date(this._date.getTime());
25723
- if (this._view === "day") date.setDate(date.getDate() + direction);
25724
- else if (this._view === "agenda") date.setDate(date.getDate() + direction * this.options.agendaDays);
25725
- else if (this._view === "week") date.setDate(date.getDate() + direction * 7);
25726
- else if (this._view === "month") date.setMonth(date.getMonth() + direction);
25727
- else date.setFullYear(date.getFullYear() + direction);
25728
- return this.setDate(date);
25762
+ return this.setDate(calendarPageDate(this._view, this._date, direction, {
25763
+ agendaDays: this.options.agendaDays
25764
+ }));
25729
25765
  }
25730
25766
  /** @param {{start: Date, end: Date}} range @returns {string} */
25731
25767
  _rangeTitle(range) {
@@ -26882,6 +26918,16 @@ var zx = (() => {
26882
26918
  class: "zx-record-card__heading",
26883
26919
  id: headingId
26884
26920
  }, headingContent);
26921
+ const fieldGroups = partitionRecordCardFields(visibleFields, titleSource, subtitleSource);
26922
+ const eyebrow = createEyebrow(record, index, fieldGroups.eyebrowStart, fieldGroups.eyebrowEnd);
26923
+ const titlePrefix = createFieldList(
26924
+ record,
26925
+ index,
26926
+ fieldGroups.titlePrefix,
26927
+ "zx-record-card__title-prefix",
26928
+ "title-prefix"
26929
+ );
26930
+ const titleRow = titlePrefix ? h("div", { class: "zx-record-card__title-row" }, titlePrefix, heading) : heading;
26885
26931
  const selection = selectable === "multi" ? h("label", {
26886
26932
  class: "zx-record-card__selection"
26887
26933
  }, h("input", {
@@ -26903,19 +26949,19 @@ var zx = (() => {
26903
26949
  h(
26904
26950
  "div",
26905
26951
  { class: "zx-record-card__titles" },
26906
- heading,
26952
+ titleRow,
26907
26953
  isEmptyContent(subtitle) ? null : h("p", { class: "zx-record-card__subtitle" }, contentNode(subtitle))
26908
26954
  ),
26909
26955
  actionArea
26910
26956
  );
26911
- const metadata = createMetadata(record, index, visibleFields, titleSource, subtitleSource);
26912
- const body = h("div", { class: "zx-record-card__body" }, header, metadata);
26957
+ const metadata = createFieldList(record, index, fieldGroups.metadata, "zx-record-card__metadata");
26958
+ const body = h("div", { class: "zx-record-card__body" }, eyebrow, header, metadata);
26913
26959
  const card = (
26914
26960
  /** @type {HTMLLIElement} */
26915
26961
  h("li", {
26916
26962
  class: "zx-record-card",
26917
26963
  tabindex: 0,
26918
- ariaSelected: selectable ? String(selected) : void 0,
26964
+ ariaDescription: selectable ? selected ? "Selected" : "Not selected" : void 0,
26919
26965
  ariaLabelledby: headingId,
26920
26966
  dataset: {
26921
26967
  recordIndex: String(index),
@@ -26926,6 +26972,20 @@ var zx = (() => {
26926
26972
  );
26927
26973
  return card;
26928
26974
  }
26975
+ function partitionRecordCardFields(fields, titleSource = null, subtitleSource = null) {
26976
+ const groups = { eyebrowStart: [], eyebrowEnd: [], titlePrefix: [], metadata: [] };
26977
+ const titleId = typeof titleSource === "string" ? titleSource : null;
26978
+ const subtitleId = typeof subtitleSource === "string" ? subtitleSource : null;
26979
+ for (const field of Array.isArray(fields) ? fields : []) {
26980
+ if (!field.duplicate && (field.id === titleId || field.id === subtitleId)) continue;
26981
+ const slot = resolveRecordCardFieldSlot(field);
26982
+ if (slot === "eyebrow-start") groups.eyebrowStart.push(field);
26983
+ else if (slot === "eyebrow-end") groups.eyebrowEnd.push(field);
26984
+ else if (slot === "title-prefix") groups.titlePrefix.push(field);
26985
+ else groups.metadata.push(field);
26986
+ }
26987
+ return groups;
26988
+ }
26929
26989
  function resolveRecordPreview(record, index, preview, previewAlt, fields = []) {
26930
26990
  if (preview == null) return null;
26931
26991
  const candidate = typeof preview === "function" ? preview(record, index) : readRecordSource(record, index, preview, fields, false);
@@ -26994,21 +27054,49 @@ var zx = (() => {
26994
27054
  dataset: { failed: String(preview.rejected) }
26995
27055
  }, fallback, image);
26996
27056
  }
26997
- function createMetadata(record, index, fields, titleSource, subtitleSource) {
26998
- const titleId = typeof titleSource === "string" ? titleSource : null;
26999
- const subtitleId = typeof subtitleSource === "string" ? subtitleSource : null;
27000
- const visible = fields.filter((field) => field.duplicate || field.id !== titleId && field.id !== subtitleId);
27001
- if (visible.length === 0) return null;
27002
- return h("dl", { class: "zx-record-card__metadata" }, visible.map((field) => h(
27057
+ function createEyebrow(record, index, eyebrowStart, eyebrowEnd) {
27058
+ const start = createFieldList(
27059
+ record,
27060
+ index,
27061
+ eyebrowStart,
27062
+ "zx-record-card__eyebrow-group",
27063
+ "eyebrow-start"
27064
+ );
27065
+ const end = createFieldList(
27066
+ record,
27067
+ index,
27068
+ eyebrowEnd,
27069
+ "zx-record-card__eyebrow-group",
27070
+ "eyebrow-end"
27071
+ );
27072
+ return start || end ? h("div", { class: "zx-record-card__eyebrow" }, start, end) : null;
27073
+ }
27074
+ function createFieldList(record, index, fields, className, slot = null) {
27075
+ if (fields.length === 0) return null;
27076
+ return h("dl", {
27077
+ class: className,
27078
+ dataset: slot ? { cardSlot: slot } : void 0
27079
+ }, fields.map((field) => h(
27003
27080
  "div",
27004
27081
  {
27005
27082
  class: "zx-record-card__field",
27006
- dataset: { fieldId: field.id }
27083
+ dataset: { fieldId: field.id, ...slot ? { cardSlot: slot } : {} }
27007
27084
  },
27008
27085
  h("dt", { class: "zx-record-card__label" }, field.label),
27009
27086
  h("dd", { class: "zx-record-card__value" }, contentNode(renderViewField(field, record, index)))
27010
27087
  )));
27011
27088
  }
27089
+ function resolveRecordCardFieldSlot(field) {
27090
+ const view = field?.view;
27091
+ if (!view || typeof view !== "object" || Array.isArray(view)) return null;
27092
+ const card = view.card;
27093
+ if (!card || typeof card !== "object" || Array.isArray(card)) return null;
27094
+ const slot = (
27095
+ /** @type {{slot?:unknown}} */
27096
+ card.slot
27097
+ );
27098
+ return slot === "eyebrow-start" || slot === "eyebrow-end" || slot === "title-prefix" ? slot : null;
27099
+ }
27012
27100
  function createAction(action) {
27013
27101
  const label = String(action.label ?? "");
27014
27102
  const title = String(action.title ?? (label || action.id));
@@ -27078,7 +27166,7 @@ var zx = (() => {
27078
27166
  try {
27079
27167
  const base = globalThis.location?.href ?? "https://zx.invalid/";
27080
27168
  const protocol = new URL(href, base).protocol.toLowerCase();
27081
- return ["http:", "https:", "blob:", "file:"].includes(protocol) ? href : null;
27169
+ return ["http:", "https:", "blob:"].includes(protocol) ? href : null;
27082
27170
  } catch {
27083
27171
  return null;
27084
27172
  }
@@ -27122,7 +27210,7 @@ var zx = (() => {
27122
27210
  actions: [],
27123
27211
  groupBy: null,
27124
27212
  groupOrder: [],
27125
- minCardWidth: "18rem",
27213
+ minCardWidth: "15rem",
27126
27214
  maxColumns: null,
27127
27215
  variant: "outlined",
27128
27216
  loadingCount: 6,
@@ -27396,7 +27484,7 @@ var zx = (() => {
27396
27484
  for (const [id, card] of this._cardNodesById) {
27397
27485
  const selected = this._isViewSelected(id);
27398
27486
  card.dataset.selected = String(selected);
27399
- if (this.options.selectable) card.setAttribute("aria-selected", String(selected));
27487
+ if (this.options.selectable) card.setAttribute("aria-description", selected ? "Selected" : "Not selected");
27400
27488
  const checkbox = (
27401
27489
  /** @type {HTMLInputElement|null} */
27402
27490
  card.querySelector("[data-record-selection]")
@@ -27540,11 +27628,11 @@ var zx = (() => {
27540
27628
  return value == null || value === "" ? "" : String(value);
27541
27629
  }
27542
27630
  function normalizeCardWidth(value) {
27543
- if (typeof value === "number") return Number.isFinite(value) && value > 0 ? `${value}px` : "18rem";
27631
+ if (typeof value === "number") return Number.isFinite(value) && value > 0 ? `${value}px` : "15rem";
27544
27632
  const text4 = String(value ?? "").trim();
27545
- if (!text4 || text4.length > 128 || /[;{}\u0000-\u001f\u007f]/.test(text4)) return "18rem";
27546
- if (/(?:url|image|image-set|cross-fade|element|expression)\s*\(/i.test(text4)) return "18rem";
27547
- if (typeof globalThis.CSS?.supports === "function" && !globalThis.CSS.supports("width", text4)) return "18rem";
27633
+ if (!text4 || text4.length > 128 || /[;{}\u0000-\u001f\u007f]/.test(text4)) return "15rem";
27634
+ if (/(?:url|image|image-set|cross-fade|element|expression)\s*\(/i.test(text4)) return "15rem";
27635
+ if (typeof globalThis.CSS?.supports === "function" && !globalThis.CSS.supports("width", text4)) return "15rem";
27548
27636
  return text4;
27549
27637
  }
27550
27638
  function normalizeMaxColumns(value) {
@@ -27582,8 +27670,284 @@ var zx = (() => {
27582
27670
  return value.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "empty";
27583
27671
  }
27584
27672
 
27673
+ // src/components/kanban-view/kanban-policy.js
27674
+ var RULE_TONES = Object.freeze(["neutral", "accent", "success", "warning", "danger", "info"]);
27675
+ function normalizeKanbanRules(rules) {
27676
+ if (rules == null) return [];
27677
+ if (!Array.isArray(rules)) throw new TypeError("Kanban rules must be an array or null");
27678
+ const seen = /* @__PURE__ */ new Set();
27679
+ return rules.map((rule, index) => {
27680
+ if (!rule || typeof rule !== "object" || Array.isArray(rule)) {
27681
+ throw new TypeError(`Kanban rule at index ${index} must be an object`);
27682
+ }
27683
+ const source = (
27684
+ /** @type {Record<string, any>} */
27685
+ rule
27686
+ );
27687
+ const id = String(source.id ?? "").trim();
27688
+ if (!id) throw new TypeError(`Kanban rule at index ${index} requires an id`);
27689
+ if (seen.has(id)) throw new TypeError(`Duplicate Kanban rule: ${id}`);
27690
+ if (typeof source.when !== "function") {
27691
+ throw new TypeError(`Kanban rule ${id} requires a when predicate`);
27692
+ }
27693
+ seen.add(id);
27694
+ return {
27695
+ id,
27696
+ when: source.when,
27697
+ tone: RULE_TONES.includes(source.tone) ? source.tone : "accent",
27698
+ label: source.label == null ? "" : String(source.label),
27699
+ icon: source.icon == null ? null : String(source.icon),
27700
+ description: source.description == null ? "" : String(source.description)
27701
+ };
27702
+ });
27703
+ }
27704
+ function resolveKanbanRules(rules, record, context) {
27705
+ if (!Array.isArray(rules) || !rules.length) return [];
27706
+ return rules.filter((rule) => {
27707
+ try {
27708
+ return rule.when(record, context) === true;
27709
+ } catch {
27710
+ return false;
27711
+ }
27712
+ });
27713
+ }
27714
+ function evaluateKanbanMove(input) {
27715
+ const { records, column, swimlane, context, from } = input;
27716
+ const moving = Math.max(1, records.length);
27717
+ const policy = column?.wipPolicy === "block" || column?.wipPolicy === "warn" ? column.wipPolicy : input.policy === "block" ? "block" : "warn";
27718
+ const refuse = (reason, extra = {}) => ({
27719
+ allowed: false,
27720
+ reason,
27721
+ limit: null,
27722
+ count: 0,
27723
+ limitExceeded: false,
27724
+ limitReached: false,
27725
+ policy,
27726
+ ...extra
27727
+ });
27728
+ const blockedTransition = allowsKanbanTransition(column, swimlane, from);
27729
+ if (blockedTransition) return refuse(blockedTransition);
27730
+ for (const record of records) {
27731
+ if (column.accept && column.accept(record, context) === false) return refuse("accept");
27732
+ if (swimlane?.accept && swimlane.accept(record, context) === false) return refuse("lane-accept");
27733
+ }
27734
+ const capacities = [];
27735
+ if (column.limit != null) {
27736
+ capacities.push({ reason: "wip", limit: column.limit, count: input.columnCount + moving });
27737
+ }
27738
+ const cellLimit = swimlane ? readLaneLimit(column.laneLimits, swimlane.id) : null;
27739
+ if (cellLimit != null) {
27740
+ capacities.push({ reason: "wip", limit: cellLimit, count: input.cellCount + moving });
27741
+ }
27742
+ if (swimlane?.limit != null) {
27743
+ capacities.push({ reason: "lane-wip", limit: swimlane.limit, count: input.laneCount + moving });
27744
+ }
27745
+ const violated = capacities.find((capacity) => capacity.count > capacity.limit);
27746
+ const reported = violated ?? capacities.find((capacity) => capacity.count === capacity.limit) ?? capacities[0];
27747
+ if (violated && policy === "block") {
27748
+ return refuse(violated.reason, {
27749
+ limit: violated.limit,
27750
+ count: violated.count,
27751
+ limitExceeded: true
27752
+ });
27753
+ }
27754
+ return {
27755
+ allowed: true,
27756
+ reason: null,
27757
+ limit: reported ? reported.limit : null,
27758
+ count: reported ? reported.count : 0,
27759
+ limitExceeded: Boolean(violated),
27760
+ limitReached: Boolean(reported && !violated && reported.count === reported.limit),
27761
+ policy
27762
+ };
27763
+ }
27764
+ function allowsKanbanTransition(column, swimlane, from) {
27765
+ if (column.id !== from.column && !allowsOrigin(column.from, from.column)) return "transition";
27766
+ if (swimlane && swimlane.id !== from.lane && !allowsOrigin(swimlane.from, from.lane)) {
27767
+ return "lane-transition";
27768
+ }
27769
+ return null;
27770
+ }
27771
+ function kanbanSearchTerms(search) {
27772
+ const text4 = search == null ? "" : String(search).trim().toLowerCase();
27773
+ if (!text4) return [];
27774
+ return [...text4.matchAll(/"([^"]*)"|(\S+)/g)].map((match) => (match[1] ?? match[2] ?? "").trim()).filter(Boolean);
27775
+ }
27776
+ function matchesKanbanSearch(text4, terms) {
27777
+ if (!terms.length) return true;
27778
+ const haystack = text4.toLowerCase();
27779
+ return terms.every((term) => haystack.includes(term));
27780
+ }
27781
+ function createKanbanHistory(limit = 50) {
27782
+ const max = Number.isFinite(Number(limit)) && Number(limit) > 0 ? Math.floor(Number(limit)) : 50;
27783
+ let undoStack = [];
27784
+ let redoStack = [];
27785
+ return {
27786
+ push(entry) {
27787
+ undoStack.push(entry);
27788
+ if (undoStack.length > max) undoStack = undoStack.slice(-max);
27789
+ redoStack = [];
27790
+ },
27791
+ undo(current) {
27792
+ const entry = undoStack.pop();
27793
+ if (!entry) return null;
27794
+ redoStack.push(current);
27795
+ if (redoStack.length > max) redoStack = redoStack.slice(-max);
27796
+ return entry;
27797
+ },
27798
+ redo(current) {
27799
+ const entry = redoStack.pop();
27800
+ if (!entry) return null;
27801
+ undoStack.push(current);
27802
+ if (undoStack.length > max) undoStack = undoStack.slice(-max);
27803
+ return entry;
27804
+ },
27805
+ canUndo: () => undoStack.length > 0,
27806
+ canRedo: () => redoStack.length > 0,
27807
+ clear() {
27808
+ undoStack = [];
27809
+ redoStack = [];
27810
+ },
27811
+ depth: () => ({ undo: undoStack.length, redo: redoStack.length })
27812
+ };
27813
+ }
27814
+ function reorderKanbanRecords(records, entries, options) {
27815
+ if (!Array.isArray(records) || !Array.isArray(entries) || !entries.length) return null;
27816
+ const sources = entries.map((entry) => ({
27817
+ ...entry,
27818
+ index: records.findIndex((record) => Object.is(viewRecordId(record, options.recordId), entry.id))
27819
+ }));
27820
+ if (sources.some((source) => source.index < 0 || !source.record || typeof source.record !== "object")) {
27821
+ return null;
27822
+ }
27823
+ sources.sort((left, right) => left.index - right.index);
27824
+ const moves = sources.map((source) => ({
27825
+ id: source.id,
27826
+ from: locateKanbanRecord(records, source.index, options)
27827
+ }));
27828
+ const movingIndices = new Set(sources.map((source) => source.index));
27829
+ const next = records.filter((_, index) => !movingIndices.has(index));
27830
+ const destinationRecords = next.filter((record, index) => normalizeAxisId(readKanbanAccessor(record, index, options.columnBy)) === options.destination.column && (options.swimlaneBy == null || normalizeAxisId(readKanbanAccessor(record, index, options.swimlaneBy)) === options.destination.lane));
27831
+ const targetIndex = clampKanbanIndex(options.destination.index, destinationRecords.length);
27832
+ let insertion = next.length;
27833
+ if (destinationRecords[targetIndex]) insertion = next.indexOf(destinationRecords[targetIndex]);
27834
+ else if (destinationRecords.length) insertion = next.indexOf(destinationRecords.at(-1)) + 1;
27835
+ next.splice(insertion, 0, ...sources.map((source) => source.record));
27836
+ return {
27837
+ records: next,
27838
+ moves,
27839
+ to: {
27840
+ column: options.destination.column,
27841
+ lane: options.swimlaneBy == null ? null : options.destination.lane,
27842
+ index: targetIndex
27843
+ }
27844
+ };
27845
+ }
27846
+ function locateKanbanRecord(records, index, options) {
27847
+ const record = records[index];
27848
+ const column = normalizeAxisId(readKanbanAccessor(record, index, options.columnBy));
27849
+ const lane = options.swimlaneBy == null ? null : normalizeAxisId(readKanbanAccessor(record, index, options.swimlaneBy));
27850
+ const group = records.filter((candidate, candidateIndex) => normalizeAxisId(readKanbanAccessor(candidate, candidateIndex, options.columnBy)) === column && (options.swimlaneBy == null || normalizeAxisId(readKanbanAccessor(candidate, candidateIndex, options.swimlaneBy)) === lane));
27851
+ const id = viewRecordId(record, options.recordId);
27852
+ return {
27853
+ column,
27854
+ lane,
27855
+ index: group.findIndex((candidate) => Object.is(viewRecordId(candidate, options.recordId), id))
27856
+ };
27857
+ }
27858
+ function absoluteKanbanIndex(visibility, visibleIndex) {
27859
+ const target = clampKanbanIndex(visibleIndex, visibility.filter(Boolean).length);
27860
+ let seen = 0;
27861
+ for (let index = 0; index < visibility.length; index += 1) {
27862
+ if (!visibility[index]) continue;
27863
+ if (seen === target) return index;
27864
+ seen += 1;
27865
+ }
27866
+ return visibility.length;
27867
+ }
27868
+ function readKanbanAccessor(record, index, accessor) {
27869
+ return typeof accessor === "function" ? accessor(record, index) : accessor == null ? null : record?.[accessor];
27870
+ }
27871
+ function normalizeAxisId(value) {
27872
+ return value == null ? "" : String(value);
27873
+ }
27874
+ function clampKanbanIndex(value, maximum) {
27875
+ if (value == null || !Number.isFinite(Number(value))) return maximum;
27876
+ return Math.max(0, Math.min(maximum, Math.trunc(Number(value))));
27877
+ }
27878
+ function allowsOrigin(allowed, origin) {
27879
+ if (allowed == null) return true;
27880
+ if (!Array.isArray(allowed)) return true;
27881
+ if (!allowed.length) return false;
27882
+ return allowed.some((entry) => String(entry) === "*" || String(entry) === normalizeAxisId(origin));
27883
+ }
27884
+ function readLaneLimit(laneLimits, lane) {
27885
+ if (!laneLimits || typeof laneLimits !== "object") return null;
27886
+ const value = (
27887
+ /** @type {Record<string, unknown>} */
27888
+ laneLimits[lane]
27889
+ );
27890
+ if (value == null || value === "") return null;
27891
+ const number2 = Number(value);
27892
+ return Number.isFinite(number2) && number2 >= 0 ? Math.floor(number2) : null;
27893
+ }
27894
+
27585
27895
  // src/components/kanban-view/kanban-view.js
27586
27896
  var INTERACTIVE = 'a, button, input, select, textarea, summary, [contenteditable="true"]';
27897
+ var DRAG_THRESHOLD = 4;
27898
+ var TOUCH_HOLD = 350;
27899
+ var SEARCH_DEBOUNCE = 120;
27900
+ var SCROLL_EDGE = 56;
27901
+ var SCROLL_SPEED = 18;
27902
+ var DEFAULT_LABELS = Object.freeze({
27903
+ fields: "Fields",
27904
+ search: "Search board",
27905
+ searchPlaceholder: "Search cards",
27906
+ clearSearch: "Clear search",
27907
+ searchSummary: "%count% of %total% cards match.",
27908
+ undo: "Undo",
27909
+ redo: "Redo",
27910
+ add: "Add card",
27911
+ addTo: "Add card to %target%",
27912
+ move: "Move %name%",
27913
+ moveMultiple: "Move %count% selected cards",
27914
+ collapseColumn: "Collapse column %label%",
27915
+ expandColumn: "Expand column %label%",
27916
+ collapseLane: "Collapse swim lane %label%",
27917
+ expandLane: "Expand swim lane %label%",
27918
+ count: "%count% records",
27919
+ countFiltered: "%count% of %total% records",
27920
+ countLimit: "%count% records, work-in-progress limit %limit%",
27921
+ countLimitInLane: "%count% records in this lane, %total% of work-in-progress limit %limit% in the column",
27922
+ empty: "No records",
27923
+ columnEmpty: "",
27924
+ selected: "Selected",
27925
+ notSelected: "Not selected",
27926
+ noMatches: "No cards match the current search.",
27927
+ record: "record",
27928
+ grabbed: "Grabbed %name%. Use left and right for columns, up and down for position, and Alt plus up or down for swim lanes.",
27929
+ grabbedMultiple: "Grabbed %count% cards. Use left and right for columns, up and down for position, and Alt plus up or down for swim lanes.",
27930
+ target: "Target %target%, position %position% of %total%.",
27931
+ moved: "Moved %name% to %target%.%wip%",
27932
+ movedMultiple: "Moved %count% cards to %target%.%wip%",
27933
+ moveRequested: "Move requested for %name% to %target%.%wip%",
27934
+ moveCanceled: "Move canceled for %name%.",
27935
+ moveCanceledPlain: "Move canceled.",
27936
+ limitReached: " Work-in-progress limit %limit% reached.",
27937
+ limitExceeded: " Work-in-progress limit %limit% exceeded.",
27938
+ rejectAccept: "Move rejected: %target% does not accept %name%.",
27939
+ rejectTransition: "Move rejected: %target% does not accept cards from %origin%.",
27940
+ rejectWip: "Move rejected: %target% is at its work-in-progress limit of %limit%.",
27941
+ rejectDestination: "Move rejected: the destination is not available.",
27942
+ rejectGrouping: "Move rejected: callback grouping requires external move mode.",
27943
+ undone: "Move undone.",
27944
+ redone: "Move redone.",
27945
+ nothingToUndo: "Nothing to undo.",
27946
+ nothingToRedo: "Nothing to redo.",
27947
+ menuMoveTo: "Move to %target%",
27948
+ menuMoveToLane: "Move to lane %target%",
27949
+ dragPreview: "%count% cards"
27950
+ });
27587
27951
  var KanbanView = class extends RecordView {
27588
27952
  static cssName = "kanban-view";
27589
27953
  /** @type {Readonly<KanbanViewOptions & RecordViewOptions>} */
@@ -27600,6 +27964,27 @@ var zx = (() => {
27600
27964
  link: null,
27601
27965
  actions: [],
27602
27966
  moveMode: "local",
27967
+ dragFrom: "card",
27968
+ multiMove: true,
27969
+ autoScroll: true,
27970
+ wipPolicy: "warn",
27971
+ rules: [],
27972
+ search: "",
27973
+ searchFields: null,
27974
+ filter: null,
27975
+ searchControl: false,
27976
+ history: true,
27977
+ historyLimit: 50,
27978
+ historyControls: false,
27979
+ columnHeight: null,
27980
+ allowAdd: false,
27981
+ contextMenu: false,
27982
+ renderCard: null,
27983
+ renderColumnHeader: null,
27984
+ renderSwimlaneHeader: null,
27985
+ renderColumnEmpty: null,
27986
+ renderDragPreview: null,
27987
+ labels: {},
27603
27988
  columnOrder: [],
27604
27989
  swimlaneOrder: [],
27605
27990
  collapsedColumns: [],
@@ -27635,17 +28020,45 @@ var zx = (() => {
27635
28020
  this._kanbanSwimlaneOrder = normalizeOrder(options.swimlaneOrder);
27636
28021
  this._collapsedColumns = new Set(normalizeOrder(options.collapsedColumns));
27637
28022
  this._collapsedSwimlanes = new Set(normalizeOrder(options.collapsedSwimlanes));
28023
+ this._kanbanRules = normalizeKanbanRules(options.rules);
28024
+ this._kanbanSearch = options.search == null ? "" : String(options.search);
28025
+ this._kanbanFilter = typeof options.filter === "function" ? options.filter : null;
28026
+ this._kanbanHistory = createKanbanHistory(options.historyLimit);
27638
28027
  this._keyboardMove = /** @type {KanbanKeyboardMove|null} */
27639
28028
  null;
27640
- this._dragId = null;
28029
+ this._dragIds = /** @type {unknown[]} */
28030
+ [];
27641
28031
  this._dragDestination = /** @type {KanbanMovePoint|null} */
27642
28032
  null;
28033
+ this._pointerStart = /** @type {{x:number,y:number,id:number,ids:unknown[],touch:boolean}|null} */
28034
+ null;
28035
+ this._pointerHold = 0;
28036
+ this._dragFrame = 0;
28037
+ this._dragPreview = /** @type {HTMLElement|null} */
28038
+ null;
28039
+ this._dragPointer = /** @type {number|null} */
28040
+ null;
28041
+ this._dragCaptured = false;
28042
+ this._searchTimer = 0;
28043
+ this._dragPoint = { x: 0, y: 0 };
28044
+ this._suppressClick = false;
27643
28045
  this._announcementToken = 0;
27644
28046
  this._cardMeta = /* @__PURE__ */ new WeakMap();
27645
28047
  this._sectionMeta = /* @__PURE__ */ new WeakMap();
27646
28048
  this._cardsById = /* @__PURE__ */ new Map();
28049
+ this._visibleIds = /* @__PURE__ */ new Set();
28050
+ this._kanbanMenu = /** @type {ContextMenu|null} */
28051
+ null;
28052
+ this._searchInput = /** @type {HTMLInputElement|null} */
28053
+ null;
28054
+ this._undoButton = /** @type {HTMLButtonElement|null} */
28055
+ null;
28056
+ this._redoButton = /** @type {HTMLButtonElement|null} */
28057
+ null;
27647
28058
  this._toolbar = h("div", { class: "zx-record-view__toolbar zx-kanban-view__toolbar" });
27648
- if (options.fieldControls) this._toolbar.append(this._createViewFieldControls());
28059
+ if (options.fieldControls) this._toolbar.append(this._createViewFieldControls(this._text("fields")));
28060
+ if (options.searchControl) this._toolbar.append(this._createSearchControl());
28061
+ if (options.historyControls) this._toolbar.append(this._createHistoryControls());
27649
28062
  this._board = h("div", {
27650
28063
  class: "zx-kanban-view__board",
27651
28064
  role: "region",
@@ -27658,6 +28071,10 @@ var zx = (() => {
27658
28071
  ariaAtomic: "true"
27659
28072
  });
27660
28073
  root.replaceChildren(this._toolbar, this._board, this._live);
28074
+ if (options.columnHeight) {
28075
+ root.style.setProperty("--zx-kanban-column-height", String(options.columnHeight));
28076
+ root.dataset.columnScroll = "true";
28077
+ }
27661
28078
  this.listen(this._board, "click", (event) => this._handleBoardClick(
27662
28079
  /** @type {MouseEvent} */
27663
28080
  event
@@ -27671,20 +28088,22 @@ var zx = (() => {
27671
28088
  event
27672
28089
  ));
27673
28090
  this.listen(this._board, "error", (event) => this._handlePreviewError(event), { capture: true });
27674
- this.listen(this._board, "dragstart", (event) => this._handleDragStart(
27675
- /** @type {DragEvent} */
28091
+ this.listen(this._board, "pointerdown", (event) => this._handlePointerDown(
28092
+ /** @type {PointerEvent} */
27676
28093
  event
27677
28094
  ));
27678
- this.listen(this._board, "dragover", (event) => this._handleDragOver(
27679
- /** @type {DragEvent} */
28095
+ this.listen(this._board, "pointermove", (event) => this._handlePointerMove(
28096
+ /** @type {PointerEvent} */
27680
28097
  event
27681
28098
  ));
27682
- this.listen(this._board, "drop", (event) => this._handleDrop(
27683
- /** @type {DragEvent} */
28099
+ this.listen(this._board, "pointerup", (event) => this._handlePointerUp(
28100
+ /** @type {PointerEvent} */
27684
28101
  event
27685
28102
  ));
27686
- this.listen(this._board, "dragend", () => this._clearDrag());
28103
+ this.listen(this._board, "pointercancel", () => this._clearDrag());
28104
+ this.listen(this._board, "dragstart", (event) => event.preventDefault());
27687
28105
  this._refreshView("init");
28106
+ if (options.contextMenu) this._createContextMenu();
27688
28107
  return root;
27689
28108
  } catch (error2) {
27690
28109
  if (this._kanbanSnapshot) restoreTarget(root, this._kanbanSnapshot);
@@ -27702,7 +28121,16 @@ var zx = (() => {
27702
28121
  */
27703
28122
  setColumns(columns) {
27704
28123
  this._kanbanColumns = columns === null ? null : normalizeKanbanDescriptors(columns, "column");
27705
- this._kanbanColumnOrder = reconcileKanbanOrder(this._rawColumns(), this._kanbanColumnOrder);
28124
+ const descriptors = this._rawColumns();
28125
+ this._kanbanColumnOrder = reconcileKanbanPreference(
28126
+ descriptors,
28127
+ this._kanbanColumnOrder,
28128
+ this._kanbanColumns === null
28129
+ );
28130
+ if (this._kanbanColumns !== null) {
28131
+ const ids = new Set(descriptors.map((column) => column.id));
28132
+ this._collapsedColumns = new Set([...this._collapsedColumns].filter((id) => ids.has(id)));
28133
+ }
27706
28134
  this._refreshView("columns");
27707
28135
  this._emitKanbanState("columns");
27708
28136
  return this;
@@ -27718,7 +28146,17 @@ var zx = (() => {
27718
28146
  */
27719
28147
  setSwimlanes(lanes) {
27720
28148
  this._kanbanSwimlanes = lanes === null ? null : normalizeKanbanDescriptors(lanes, "swimlane");
27721
- this._kanbanSwimlaneOrder = reconcileKanbanOrder(this._rawSwimlanes(), this._kanbanSwimlaneOrder);
28149
+ const descriptors = this._rawSwimlanes();
28150
+ const preserveUnknown = this._kanbanSwimlanes === null && this._kanbanOptions().swimlaneBy != null;
28151
+ this._kanbanSwimlaneOrder = reconcileKanbanPreference(
28152
+ descriptors,
28153
+ this._kanbanSwimlaneOrder,
28154
+ preserveUnknown
28155
+ );
28156
+ if (!preserveUnknown) {
28157
+ const ids = new Set(descriptors.map((lane) => lane.id));
28158
+ this._collapsedSwimlanes = new Set([...this._collapsedSwimlanes].filter((id) => ids.has(id)));
28159
+ }
27722
28160
  this._refreshView("swimlanes");
27723
28161
  this._emitKanbanState("swimlanes");
27724
28162
  return this;
@@ -27734,9 +28172,16 @@ var zx = (() => {
27734
28172
  * @returns {this}
27735
28173
  */
27736
28174
  setColumnOrder(order, options = {}) {
27737
- const next = reconcileKanbanOrder(this._rawColumns(), order);
27738
- if (sameArray3(next, this.getColumnOrder())) return this;
27739
- this._kanbanColumnOrder = next;
28175
+ const descriptors = this._rawColumns();
28176
+ const current = this.getColumnOrder();
28177
+ const next = reconcileKanbanOrder(descriptors, order);
28178
+ if (sameArray3(next, current)) return this;
28179
+ this._kanbanColumnOrder = mergeKanbanResolvedOrder(
28180
+ this._kanbanColumnOrder,
28181
+ descriptors,
28182
+ next,
28183
+ this._kanbanColumns === null
28184
+ );
27740
28185
  this._refreshView("columnorder");
27741
28186
  if (!options.silent) this._emitKanbanState("columnorder");
27742
28187
  return this;
@@ -27762,9 +28207,17 @@ var zx = (() => {
27762
28207
  * @returns {this}
27763
28208
  */
27764
28209
  setSwimlaneOrder(order, options = {}) {
27765
- const next = reconcileKanbanOrder(this._rawSwimlanes(), order);
27766
- if (sameArray3(next, this.getSwimlaneOrder())) return this;
27767
- this._kanbanSwimlaneOrder = next;
28210
+ const descriptors = this._rawSwimlanes();
28211
+ const current = this.getSwimlaneOrder();
28212
+ const next = reconcileKanbanOrder(descriptors, order);
28213
+ if (sameArray3(next, current)) return this;
28214
+ const preserveUnknown = this._kanbanSwimlanes === null && this._kanbanOptions().swimlaneBy != null;
28215
+ this._kanbanSwimlaneOrder = mergeKanbanResolvedOrder(
28216
+ this._kanbanSwimlaneOrder,
28217
+ descriptors,
28218
+ next,
28219
+ preserveUnknown
28220
+ );
27768
28221
  this._refreshView("swimlaneorder");
27769
28222
  if (!options.silent) this._emitKanbanState("swimlaneorder");
27770
28223
  return this;
@@ -27832,91 +28285,244 @@ var zx = (() => {
27832
28285
  return this.setSwimlaneCollapsed(id, !this._collapsedSwimlanes.has(String(id)));
27833
28286
  }
27834
28287
  /**
27835
- * Proposes and, in local mode, commits a record move. Destination acceptance is checked before
27836
- * a cancelable `recordmove` event. WIP limits are reported but remain advisory.
28288
+ * Proposes and, in local mode, commits a record move. Equivalent to `moveRecords([id], …)`.
27837
28289
  * @param {unknown} id Record id.
27838
28290
  * @param {KanbanDestination} destination Destination.
27839
28291
  * @returns {this}
27840
28292
  * @fires KanbanView#recordmove
28293
+ * @fires KanbanView#movereject
27841
28294
  * @fires RecordView#datachange
27842
28295
  */
27843
28296
  moveRecord(id, destination) {
28297
+ return this.moveRecords([id], destination);
28298
+ }
28299
+ /**
28300
+ * Proposes and, in local mode, commits an atomic move of one or more records. Destination
28301
+ * eligibility, transition rules, and work-in-progress policy are all checked before a single
28302
+ * cancelable `recordmove` event; the move commits completely or not at all, and the moving
28303
+ * records keep their relative order at the destination.
28304
+ * @param {unknown[]} ids Record ids, in any order.
28305
+ * @param {KanbanDestination} destination Destination.
28306
+ * @returns {this}
28307
+ * @fires KanbanView#recordmove
28308
+ * @fires KanbanView#movereject
28309
+ * @fires RecordView#datachange
28310
+ */
28311
+ moveRecords(ids, destination) {
27844
28312
  const options = this._kanbanOptions();
27845
- const record = this.getRecord(id);
27846
- const from = this._recordLocation(id);
27847
- if (!record || !from || !destination || typeof destination !== "object") return this;
28313
+ if (!Array.isArray(ids) || !destination || typeof destination !== "object") return this;
28314
+ const moving = this._orderedIds(ids);
28315
+ if (!moving.length) return this;
28316
+ const records = moving.map((id) => (
28317
+ /** @type {KanbanRecord} */
28318
+ this.getRecord(id)
28319
+ ));
28320
+ const origins = moving.map((id) => (
28321
+ /** @type {KanbanMovePoint} */
28322
+ this._recordLocation(id)
28323
+ ));
28324
+ const from = origins[0];
27848
28325
  const columns = this._resolvedColumns();
27849
28326
  const lanes = this._resolvedSwimlanes();
27850
28327
  const columnId = destination.column == null ? from.column : String(destination.column);
27851
28328
  const laneId = options.swimlaneBy == null ? null : destination.lane === void 0 ? from.lane : normalizeAxisId(destination.lane);
27852
28329
  const column = columns.find((candidate) => candidate.id === columnId);
27853
- const lane = options.swimlaneBy == null ? null : lanes.find((candidate) => candidate.id === laneId);
28330
+ const lane = options.swimlaneBy == null ? null : lanes.find((candidate) => candidate.id === laneId) ?? null;
27854
28331
  if (!column || options.swimlaneBy != null && !lane) {
27855
- this._announce("Move rejected: the destination is not available.");
28332
+ this._rejectMove("destination", { ids: moving, records, from, to: null, column: null, swimlane: null });
27856
28333
  return this;
27857
28334
  }
27858
- const destinationCount = this._recordsIn(columnId, laneId).filter((candidate) => !Object.is(this._viewRecordId(candidate.record), id)).length;
27859
- const destinationColumnCount = this._viewData.filter((candidate, candidateIndex) => normalizeAxisId(this._readAxis(options.columnBy, candidate, candidateIndex)) === columnId && !Object.is(this._viewRecordId(candidate), id)).length;
27860
- const index = normalizeMoveIndex(destination.index, destinationCount);
28335
+ const excluded = new Set(moving);
28336
+ const cellCount = this._recordsIn(columnId, laneId).filter((entry) => !excluded.has(this._viewRecordId(entry.record))).length;
28337
+ const index = clampKanbanIndex(destination.index, cellCount);
27861
28338
  const to = { column: columnId, lane: laneId, index };
27862
- if (sameMovePoint(from, to)) return this;
27863
- const context = { id, from: { ...from }, to: { ...to }, column: { ...column }, swimlane: lane ? { ...lane } : null };
27864
- if (column.accept && column.accept(record, context) === false || lane?.accept && lane.accept(record, context) === false) {
27865
- this._announce(`Move rejected for ${this._recordName(record, from.index)}.`);
28339
+ if (moving.length === 1 && sameMovePoint(from, to)) return this;
28340
+ const context = {
28341
+ id: moving[0],
28342
+ from: { ...from },
28343
+ to: { ...to },
28344
+ column: { ...column },
28345
+ swimlane: lane ? { ...lane } : null
28346
+ };
28347
+ const evaluation = this._evaluateMove(records, origins, column, lane, to, context, excluded);
28348
+ if (!evaluation.allowed) {
28349
+ this._rejectMove(
28350
+ /** @type {KanbanRejectReason} */
28351
+ evaluation.reason,
28352
+ {
28353
+ ids: moving,
28354
+ records,
28355
+ from,
28356
+ to,
28357
+ column,
28358
+ swimlane: lane,
28359
+ evaluation
28360
+ }
28361
+ );
27866
28362
  return this;
27867
28363
  }
27868
- let movedRecord = record;
27869
- if (options.moveMode === "local") {
27870
- movedRecord = this._localMovedRecord(record, from, to, column, lane);
27871
- if (!movedRecord) {
27872
- this._announce("Move rejected: callback grouping requires external move mode.");
28364
+ const entries = [];
28365
+ for (let position2 = 0; position2 < moving.length; position2 += 1) {
28366
+ const record = records[position2];
28367
+ const moved = options.moveMode === "local" ? this._localMovedRecord(record, origins[position2], to, column, lane) : record;
28368
+ if (!moved) {
28369
+ this._rejectMove("grouping", { ids: moving, records, from, to, column, swimlane: lane });
27873
28370
  return this;
27874
28371
  }
28372
+ entries.push({ id: moving[position2], record: moved });
27875
28373
  }
27876
- const next = reorderKanbanData(this._viewData, id, movedRecord, {
28374
+ const next = reorderKanbanRecords(this._viewData, entries, {
27877
28375
  recordId: options.recordId,
27878
28376
  columnBy: (candidate, candidateIndex) => this._readAxis(options.columnBy, candidate, candidateIndex),
27879
28377
  swimlaneBy: options.swimlaneBy == null ? null : (candidate, candidateIndex) => this._readAxis(options.swimlaneBy, candidate, candidateIndex),
27880
28378
  destination: to
27881
28379
  });
27882
28380
  if (!next) {
27883
- this._announce("Move rejected: the destination could not be resolved.");
28381
+ this._rejectMove("destination", { ids: moving, records, from, to, column, swimlane: lane });
27884
28382
  return this;
27885
28383
  }
27886
- const finalCount = destinationColumnCount + 1;
27887
- const limitExceeded = column.limit != null && finalCount > column.limit;
28384
+ const wip = this._wipAnnouncement(evaluation);
27888
28385
  const moveEvent = this.emit("recordmove", {
27889
- record,
27890
- id,
28386
+ record: records[0],
28387
+ id: moving[0],
28388
+ records: [...records],
28389
+ ids: [...moving],
28390
+ moves: next.moves.map((move) => ({ id: move.id, from: { ...move.from } })),
27891
28391
  from: { ...from },
27892
28392
  to: { ...next.to },
27893
28393
  column: { ...column },
27894
28394
  swimlane: lane ? { ...lane } : null,
27895
- limitExceeded
27896
- });
28395
+ limitExceeded: evaluation.limitExceeded
28396
+ }, { honorDomCancellation: true });
27897
28397
  if (moveEvent.defaultPrevented) {
27898
- this._announce(`Move canceled for ${this._recordName(record, from.index)}.`);
28398
+ this._announce(this._text("moveCanceled", { name: this._movingName(records) }));
27899
28399
  return this;
27900
28400
  }
27901
28401
  if (options.moveMode === "external") {
27902
- this._announce(`Move requested for ${this._recordName(record, from.index)} to ${this._targetName(next.to)}.`);
28402
+ this._announce(this._text("moveRequested", {
28403
+ name: this._movingName(records),
28404
+ target: this._targetName(next.to),
28405
+ wip
28406
+ }));
27903
28407
  return this;
27904
28408
  }
27905
- this._viewData = next.records;
27906
- this._refreshView("move");
27907
- this.emit("datachange", { records: this.getData() });
27908
- this._announce(`Moved ${this._recordName(movedRecord, next.to.index)} to ${this._targetName(next.to)}.`);
27909
- queueMicrotask(() => this._moveHandle(id)?.focus());
28409
+ this._commitLocalRecords(next.records);
28410
+ this._announce(moving.length > 1 ? this._text("movedMultiple", { count: moving.length, target: this._targetName(next.to), wip }) : this._text("moved", {
28411
+ name: this._recordName(entries[0].record, next.to.index),
28412
+ target: this._targetName(next.to),
28413
+ wip
28414
+ }));
28415
+ queueMicrotask(() => this._moveHandle(moving[0])?.focus());
28416
+ return this;
28417
+ }
28418
+ /** Whether a committed local move can be reverted. @returns {boolean} */
28419
+ canUndo() {
28420
+ return this._kanbanOptions().history !== false && this._kanbanHistory.canUndo();
28421
+ }
28422
+ /** Whether a reverted local move can be reapplied. @returns {boolean} */
28423
+ canRedo() {
28424
+ return this._kanbanOptions().history !== false && this._kanbanHistory.canRedo();
28425
+ }
28426
+ /**
28427
+ * Reverts the last committed local move, restoring the exact record order and selection.
28428
+ * @returns {this}
28429
+ * @fires RecordView#datachange
28430
+ * @fires KanbanView#historychange
28431
+ */
28432
+ undo() {
28433
+ return this._travelHistory("undo");
28434
+ }
28435
+ /**
28436
+ * Reapplies the last reverted local move.
28437
+ * @returns {this}
28438
+ * @fires RecordView#datachange
28439
+ * @fires KanbanView#historychange
28440
+ */
28441
+ redo() {
28442
+ return this._travelHistory("redo");
28443
+ }
28444
+ /** Empties both history stacks. @returns {this} @fires KanbanView#historychange */
28445
+ clearHistory() {
28446
+ this._kanbanHistory.clear();
28447
+ this._syncHistoryControls();
28448
+ this._emitHistory();
28449
+ return this;
28450
+ }
28451
+ /** Returns the current board search text. @returns {string} */
28452
+ getSearch() {
28453
+ return this._kanbanSearch;
28454
+ }
28455
+ /**
28456
+ * Narrows visible cards to those matching a search. Hidden records keep their position in the
28457
+ * data, so a card dropped between two visible cards still lands between them.
28458
+ * @param {string} search Search text.
28459
+ * @param {{silent?:boolean}} [options={}] Event behavior.
28460
+ * @returns {this}
28461
+ * @fires KanbanView#searchchange
28462
+ */
28463
+ setSearch(search, options = {}) {
28464
+ const next = search == null ? "" : String(search);
28465
+ if (next === this._kanbanSearch) return this;
28466
+ this._kanbanSearch = next;
28467
+ if (this._searchInput && this._searchInput.value !== next) this._searchInput.value = next;
28468
+ this._refreshView("search");
28469
+ if (!options.silent) this.emit("searchchange", { search: next });
28470
+ return this;
28471
+ }
28472
+ /**
28473
+ * Replaces the extra card predicate. Pass null to clear it.
28474
+ * @param {((record:KanbanRecord,index:number)=>boolean)|null} filter Predicate.
28475
+ * @returns {this}
28476
+ */
28477
+ setFilter(filter) {
28478
+ if (filter != null && typeof filter !== "function") {
28479
+ throw new TypeError("Kanban filter must be a function or null");
28480
+ }
28481
+ this._kanbanFilter = filter ?? null;
28482
+ this._refreshView("filter");
27910
28483
  return this;
27911
28484
  }
28485
+ /** Returns the records currently rendered, in board order. @returns {KanbanRecord[]} */
28486
+ getVisibleRecords() {
28487
+ return this._viewData.filter((record) => this._visibleIds.has(this._viewRecordId(record)));
28488
+ }
28489
+ /**
28490
+ * Replaces every record. History is dropped because its snapshots describe records the host has
28491
+ * just replaced, and reinstating them would resurrect data the application no longer owns.
28492
+ * @param {KanbanRecord[]} records New records.
28493
+ * @returns {this}
28494
+ */
28495
+ setData(records) {
28496
+ this._resetHistory();
28497
+ return super.setData(records);
28498
+ }
28499
+ /** @param {KanbanRecord[]} records Records to append. @returns {this} */
28500
+ addData(records) {
28501
+ this._resetHistory();
28502
+ return super.addData(records);
28503
+ }
28504
+ /** @param {unknown} id Existing record id. @param {KanbanRecord} record Replacement. @returns {this} */
28505
+ updateRecord(id, record) {
28506
+ this._resetHistory();
28507
+ return super.updateRecord(id, record);
28508
+ }
28509
+ /** @param {unknown} id Record id. @returns {this} */
28510
+ removeRecord(id) {
28511
+ this._resetHistory();
28512
+ return super.removeRecord(id);
28513
+ }
27912
28514
  /** Returns common plus board-specific serializable configuration. @returns {KanbanViewState} */
27913
28515
  getViewState() {
28516
+ this.getColumnOrder();
28517
+ this.getSwimlaneOrder();
28518
+ const derivedColumns = this._kanbanColumns === null;
28519
+ const derivedSwimlanes = this._kanbanSwimlanes === null && this._kanbanOptions().swimlaneBy != null;
27914
28520
  return {
27915
28521
  ...super.getViewState(),
27916
- columnOrder: this.getColumnOrder(),
27917
- swimlaneOrder: this.getSwimlaneOrder(),
27918
- collapsedColumns: this.getCollapsedColumns(),
27919
- collapsedSwimlanes: this.getCollapsedSwimlanes()
28522
+ columnOrder: derivedColumns ? [...this._kanbanColumnOrder] : this.getColumnOrder(),
28523
+ swimlaneOrder: derivedSwimlanes ? [...this._kanbanSwimlaneOrder] : this.getSwimlaneOrder(),
28524
+ collapsedColumns: derivedColumns ? [...this._collapsedColumns] : this.getCollapsedColumns(),
28525
+ collapsedSwimlanes: derivedSwimlanes ? [...this._collapsedSwimlanes] : this.getCollapsedSwimlanes()
27920
28526
  };
27921
28527
  }
27922
28528
  /**
@@ -27928,21 +28534,29 @@ var zx = (() => {
27928
28534
  setViewState(state, options = {}) {
27929
28535
  const source = state && typeof state === "object" ? state : {};
27930
28536
  super.setViewState(source, { silent: true });
27931
- this._kanbanColumnOrder = reconcileKanbanOrder(
27932
- this._rawColumns(),
27933
- Array.isArray(source.columnOrder) ? source.columnOrder : this._kanbanColumnOrder
27934
- );
27935
- this._kanbanSwimlaneOrder = reconcileKanbanOrder(
27936
- this._rawSwimlanes(),
27937
- Array.isArray(source.swimlaneOrder) ? source.swimlaneOrder : this._kanbanSwimlaneOrder
27938
- );
27939
- const columnIds = new Set(this._rawColumns().map((column) => column.id));
27940
- const laneIds = new Set(this._rawSwimlanes().map((lane) => lane.id));
28537
+ const columns = this._rawColumns();
28538
+ const lanes = this._rawSwimlanes();
28539
+ const derivedColumns = this._kanbanColumns === null;
28540
+ const derivedSwimlanes = this._kanbanSwimlanes === null && this._kanbanOptions().swimlaneBy != null;
28541
+ this._kanbanColumnOrder = reconcileKanbanPreference(
28542
+ columns,
28543
+ Array.isArray(source.columnOrder) ? source.columnOrder : this._kanbanColumnOrder,
28544
+ derivedColumns
28545
+ );
28546
+ this._kanbanSwimlaneOrder = reconcileKanbanPreference(
28547
+ lanes,
28548
+ Array.isArray(source.swimlaneOrder) ? source.swimlaneOrder : this._kanbanSwimlaneOrder,
28549
+ derivedSwimlanes
28550
+ );
28551
+ const columnIds = new Set(columns.map((column) => column.id));
28552
+ const laneIds = new Set(lanes.map((lane) => lane.id));
27941
28553
  if (Array.isArray(source.collapsedColumns)) {
27942
- this._collapsedColumns = new Set(source.collapsedColumns.map(String).filter((id) => columnIds.has(id)));
28554
+ const collapsed = normalizeOrder(source.collapsedColumns);
28555
+ this._collapsedColumns = new Set(derivedColumns ? collapsed : collapsed.filter((id) => columnIds.has(id)));
27943
28556
  }
27944
28557
  if (Array.isArray(source.collapsedSwimlanes)) {
27945
- this._collapsedSwimlanes = new Set(source.collapsedSwimlanes.map(String).filter((id) => laneIds.has(id)));
28558
+ const collapsed = normalizeOrder(source.collapsedSwimlanes);
28559
+ this._collapsedSwimlanes = new Set(derivedSwimlanes ? collapsed : collapsed.filter((id) => laneIds.has(id)));
27946
28560
  }
27947
28561
  this._refreshView("state");
27948
28562
  if (!options.silent) this._emitKanbanState("restore");
@@ -27952,6 +28566,11 @@ var zx = (() => {
27952
28566
  destroy() {
27953
28567
  if (this._kanbanDestroyed) return;
27954
28568
  this._kanbanDestroyed = true;
28569
+ this._clearDrag();
28570
+ clearTimeout(this._pointerHold);
28571
+ clearTimeout(this._searchTimer);
28572
+ this._kanbanMenu?.destroy();
28573
+ this._kanbanMenu = null;
27955
28574
  const root = this.el;
27956
28575
  const snapshot10 = this._kanbanSnapshot;
27957
28576
  super.destroy();
@@ -27966,25 +28585,61 @@ var zx = (() => {
27966
28585
  }
27967
28586
  if (_reason === "loading") return;
27968
28587
  const options = this._kanbanOptions();
28588
+ const activeElement = document.activeElement;
27969
28589
  const activeMeta = this._metaForTarget(document.activeElement);
27970
28590
  const restoreHandle = Boolean(document.activeElement?.classList?.contains("zx-kanban-view__move-handle"));
28591
+ const activeCollapse = activeElement instanceof Element ? activeElement.closest("[data-kanban-collapse]") : null;
28592
+ const activeSection = activeCollapse?.closest(".zx-kanban-view__column");
28593
+ const activeCollapseFocus = activeCollapse && this._board.contains(activeCollapse) ? {
28594
+ axis: (
28595
+ /** @type {'column'|'swimlane'} */
28596
+ /** @type {HTMLElement} */
28597
+ activeCollapse.dataset.kanbanCollapse
28598
+ ),
28599
+ id: String(
28600
+ /** @type {HTMLElement} */
28601
+ activeCollapse.dataset.kanbanId
28602
+ ),
28603
+ lane: activeSection ? this._sectionMeta.get(activeSection)?.lane ?? null : null
28604
+ } : null;
27971
28605
  const columns = this._resolvedColumns();
27972
28606
  const lanes = this._resolvedSwimlanes();
27973
28607
  this._cardMeta = /* @__PURE__ */ new WeakMap();
27974
28608
  this._sectionMeta = /* @__PURE__ */ new WeakMap();
27975
28609
  this._cardsById = /* @__PURE__ */ new Map();
28610
+ this._resolveVisibility();
27976
28611
  const content = [];
27977
28612
  if (options.swimlaneBy == null) {
27978
28613
  content.push(this._renderColumns(columns, null, false));
27979
28614
  } else {
27980
28615
  lanes.forEach((lane) => content.push(this._renderLane(lane, columns)));
27981
28616
  }
27982
- const shownCards = this._cardsById.size;
27983
- if (shownCards === 0 && !this._viewData.length) content.push(this._emptyContent());
28617
+ if (!this._viewData.length) content.push(this._emptyContent(this._text("empty")));
28618
+ else if (!this._visibleIds.size) content.push(this._emptyContent(this._text("noMatches")));
27984
28619
  this._board.replaceChildren(...content);
27985
- if (this._keyboardMove && !this.getRecord(this._keyboardMove.id)) this._keyboardMove = null;
28620
+ if (this._keyboardMove) {
28621
+ this._keyboardMove.ids = this._keyboardMove.ids.filter((id) => this.getRecord(id));
28622
+ if (!this._keyboardMove.ids.length) this._keyboardMove = null;
28623
+ }
27986
28624
  this._syncKeyboardTarget();
27987
- if (activeMeta) queueMicrotask(() => {
28625
+ if (activeCollapseFocus || activeMeta) queueMicrotask(() => {
28626
+ if (activeCollapseFocus) {
28627
+ this._collapseControl(
28628
+ activeCollapseFocus.axis,
28629
+ activeCollapseFocus.id,
28630
+ activeCollapseFocus.lane
28631
+ )?.focus();
28632
+ return;
28633
+ }
28634
+ if (!activeMeta) return;
28635
+ if (activeMeta.lane != null && this._collapsedSwimlanes.has(activeMeta.lane)) {
28636
+ this._collapseControl("swimlane", activeMeta.lane)?.focus();
28637
+ return;
28638
+ }
28639
+ if (this._collapsedColumns.has(activeMeta.column)) {
28640
+ this._collapseControl("column", activeMeta.column, activeMeta.lane)?.focus();
28641
+ return;
28642
+ }
27988
28643
  const card = this._cardsById.get(activeMeta.id);
27989
28644
  const target = restoreHandle ? card?.querySelector(".zx-kanban-view__move-handle") : card;
27990
28645
  if (target?.isConnected) target.focus();
@@ -27996,21 +28651,36 @@ var zx = (() => {
27996
28651
  const collapsed = this._collapsedSwimlanes.has(lane.id);
27997
28652
  const headingId = uid("zx-kanban-lane");
27998
28653
  const bodyId = uid("zx-kanban-lane-body");
27999
- const count2 = this._viewData.filter((record, index) => normalizeAxisId(this._readAxis(options.swimlaneBy, record, index)) === lane.id).length;
28654
+ const entries = this._viewData.filter((record, index) => normalizeAxisId(this._readAxis(options.swimlaneBy, record, index)) === lane.id);
28655
+ const total = entries.length;
28656
+ const count2 = entries.filter((record) => this._visibleIds.has(this._viewRecordId(record))).length;
28000
28657
  const toggle = h("button", {
28001
28658
  class: "zx-kanban-view__collapse",
28002
28659
  type: "button",
28003
28660
  ariaExpanded: String(!collapsed),
28004
28661
  ariaControls: bodyId,
28005
- ariaLabel: `${collapsed ? "Expand" : "Collapse"} swim lane ${lane.label}`,
28662
+ ariaLabel: this._text(collapsed ? "expandLane" : "collapseLane", { label: lane.label }),
28006
28663
  dataset: { kanbanCollapse: "swimlane", kanbanId: lane.id }
28007
28664
  }, icon(collapsed ? "chevron-right" : "chevron-down", { size: 13 }));
28665
+ const limit = lane.limit ?? null;
28666
+ const wip = wipState(total, limit);
28667
+ const custom = this._renderHook("renderSwimlaneHeader", {
28668
+ swimlane: { ...lane },
28669
+ count: count2,
28670
+ total,
28671
+ columnTotal: total,
28672
+ limit,
28673
+ wip,
28674
+ collapsed
28675
+ });
28008
28676
  const heading = h(
28009
28677
  "h2",
28010
28678
  { class: "zx-kanban-view__lane-heading", id: headingId },
28011
28679
  toggle,
28012
- h("span", {}, lane.label),
28013
- options.showCounts ? h("span", { class: "zx-kanban-view__count", ariaLabel: `${count2} records` }, String(count2)) : null
28680
+ custom ?? [
28681
+ h("span", {}, lane.label),
28682
+ options.showCounts || limit != null ? this._countIndicator(count2, total, limit) : null
28683
+ ]
28014
28684
  );
28015
28685
  const body = this._renderColumns(columns, lane, true);
28016
28686
  body.id = bodyId;
@@ -28018,7 +28688,7 @@ var zx = (() => {
28018
28688
  return h("section", {
28019
28689
  class: "zx-kanban-view__lane",
28020
28690
  ariaLabelledby: headingId,
28021
- dataset: { collapsed: String(collapsed) }
28691
+ dataset: { collapsed: String(collapsed), wip }
28022
28692
  }, heading, body);
28023
28693
  }
28024
28694
  /** @param {KanbanColumn[]} columns @param {KanbanSwimlane|null} lane @param {boolean} hasLanes @returns {HTMLElement} */
@@ -28044,36 +28714,67 @@ var zx = (() => {
28044
28714
  const collapsed = this._collapsedColumns.has(column.id);
28045
28715
  const headingId = uid("zx-kanban-column");
28046
28716
  const listId = uid("zx-kanban-column-list");
28047
- const columnCount = this._viewData.filter((record, index) => normalizeAxisId(this._readAxis(options.columnBy, record, index)) === column.id).length;
28048
- const wip = column.limit == null ? "none" : columnCount > column.limit ? "exceeded" : columnCount === column.limit ? "limit" : "within";
28717
+ const columnEntries = this._viewData.filter((record, index) => normalizeAxisId(this._readAxis(options.columnBy, record, index)) === column.id);
28718
+ const columnTotal = columnEntries.length;
28719
+ const visible = entries.filter((entry) => this._visibleIds.has(this._viewRecordId(entry.record)));
28720
+ const laneLimit = lane ? readColumnLaneLimit(column, lane.id) : null;
28721
+ const limit = laneLimit ?? column.limit ?? null;
28722
+ const wip = wipState(laneLimit == null ? columnTotal : entries.length, limit);
28049
28723
  const toggle = h("button", {
28050
28724
  class: "zx-kanban-view__collapse",
28051
28725
  type: "button",
28052
28726
  ariaExpanded: String(!collapsed),
28053
28727
  ariaControls: listId,
28054
- ariaLabel: `${collapsed ? "Expand" : "Collapse"} column ${column.label}`,
28728
+ ariaLabel: this._text(collapsed ? "expandColumn" : "collapseColumn", { label: column.label }),
28055
28729
  dataset: { kanbanCollapse: "column", kanbanId: column.id }
28056
28730
  }, icon(collapsed ? "chevron-right" : "chevron-down", { size: 13 }));
28057
- const indicator = options.showCounts || column.limit != null ? h("span", {
28058
- class: "zx-kanban-view__count",
28059
- 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}`
28060
- }, column.limit == null ? String(entries.length) : hasLanes ? `${entries.length} \xB7 ${columnCount} / ${column.limit}` : `${columnCount} / ${column.limit}`) : null;
28731
+ const custom = this._renderHook("renderColumnHeader", {
28732
+ column: { ...column },
28733
+ swimlane: lane ? { ...lane } : null,
28734
+ count: visible.length,
28735
+ total: entries.length,
28736
+ columnTotal,
28737
+ limit,
28738
+ wip,
28739
+ collapsed
28740
+ });
28741
+ const indicator = options.showCounts || limit != null ? this._columnIndicator(visible.length, entries.length, columnTotal, limit, hasLanes && laneLimit == null) : null;
28061
28742
  const heading = h(hasLanes ? "h3" : "h2", {
28062
28743
  class: "zx-kanban-view__column-heading",
28063
28744
  id: headingId
28064
- }, toggle, h("span", { class: "zx-kanban-view__column-label" }, column.label), indicator);
28745
+ }, toggle, custom ?? [h("span", { class: "zx-kanban-view__column-label" }, column.label), indicator]);
28065
28746
  const list = h("ul", {
28066
28747
  class: "zx-kanban-view__cards",
28067
28748
  id: listId,
28068
28749
  ariaLabel: `${column.label}${lane ? `, ${lane.label}` : ""}`
28069
28750
  });
28070
- entries.forEach((entry) => list.append(this._renderCard(entry.record, entry.index, column.id, lane?.id ?? null, hasLanes)));
28751
+ visible.forEach((entry) => list.append(this._renderCard(entry.record, entry.index, column.id, lane?.id ?? null, hasLanes)));
28752
+ if (!visible.length) {
28753
+ const placeholder = this._renderHook("renderColumnEmpty", {
28754
+ column: { ...column },
28755
+ swimlane: lane ? { ...lane } : null,
28756
+ count: 0,
28757
+ total: entries.length,
28758
+ columnTotal,
28759
+ limit,
28760
+ wip,
28761
+ collapsed
28762
+ }) ?? this._placeholderText();
28763
+ if (placeholder) list.append(h("li", { class: "zx-kanban-view__column-empty", role: "presentation" }, placeholder));
28764
+ }
28071
28765
  list.hidden = collapsed;
28766
+ const add = this._allowsAdd(column, lane) ? h("button", {
28767
+ class: "zx-kanban-view__add",
28768
+ type: "button",
28769
+ ariaLabel: this._text("addTo", { target: `${column.label}${lane ? `, ${lane.label}` : ""}` }),
28770
+ dataset: { kanbanAdd: column.id, kanbanLane: lane?.id ?? "" }
28771
+ }, icon("plus", { size: 13 }), h("span", {}, this._text("add"))) : null;
28772
+ if (add) add.hidden = collapsed;
28072
28773
  const section = h("section", {
28073
28774
  class: "zx-kanban-view__column",
28074
28775
  ariaLabelledby: headingId,
28075
28776
  dataset: { column: column.id, collapsed: String(collapsed), wip }
28076
- }, heading, list);
28777
+ }, heading, list, add);
28077
28778
  this._sectionMeta.set(section, { column: column.id, lane: lane?.id ?? null, list });
28078
28779
  return section;
28079
28780
  }
@@ -28081,13 +28782,14 @@ var zx = (() => {
28081
28782
  _renderCard(record, index, column, lane, hasLanes) {
28082
28783
  const options = this._kanbanOptions();
28083
28784
  const id = this._viewRecordId(record);
28084
- const grabbed = Boolean(this._keyboardMove && Object.is(this._keyboardMove.id, id));
28785
+ const grabbed = Boolean(this._keyboardMove?.ids.some((candidate) => Object.is(candidate, id)));
28786
+ const rules = resolveKanbanRules(this._kanbanRules, record, { index, column, lane });
28787
+ const multiple = grabbed && this._keyboardMove ? this._keyboardMove.ids.length : 1;
28085
28788
  const handle = h("button", {
28086
28789
  class: "zx-kanban-view__move-handle",
28087
28790
  type: "button",
28088
- draggable: true,
28089
28791
  ariaPressed: String(grabbed),
28090
- ariaLabel: `Move ${this._recordName(record, index)}`
28792
+ ariaLabel: multiple > 1 ? this._text("moveMultiple", { count: multiple }) : this._text("move", { name: this._recordName(record, index) })
28091
28793
  }, icon("drag", { size: 13 }));
28092
28794
  const card = createRecordCard(record, index, {
28093
28795
  fields: this.getFields(),
@@ -28106,15 +28808,81 @@ var zx = (() => {
28106
28808
  });
28107
28809
  card.tabIndex = 0;
28108
28810
  if (grabbed) card.dataset.grabbed = "true";
28811
+ this._applyCardContent(card, { record, id, index, column, lane, rules, selected: this._isViewSelected(id) });
28812
+ this._applyCardRules(card, rules);
28813
+ this._applyCardDescription(card, id);
28109
28814
  const meta = { record, id, index, column, lane };
28110
28815
  this._cardMeta.set(card, meta);
28111
28816
  this._cardsById.set(id, card);
28112
28817
  return card;
28113
28818
  }
28114
- /** @returns {HTMLElement} */
28115
- _emptyContent() {
28819
+ /**
28820
+ * Hands card content to `renderCard` while keeping the managed shell: the list item, its
28821
+ * selection control, the action group, and the move handle are never given away, because they
28822
+ * carry the drop, focus, and movement semantics the board is responsible for.
28823
+ * @param {HTMLElement} card Card built from the shared anatomy.
28824
+ * @param {KanbanCardContext} context Card context.
28825
+ * @returns {void}
28826
+ */
28827
+ _applyCardContent(card, context) {
28828
+ const custom = this._renderHook("renderCard", context);
28829
+ if (!custom) return;
28830
+ const body = card.querySelector(".zx-record-card__body");
28831
+ const titles = card.querySelector(".zx-record-card__titles");
28832
+ if (!body || !titles) return;
28833
+ titles.replaceChildren(custom);
28834
+ body.querySelector(".zx-record-card__eyebrow")?.remove();
28835
+ body.querySelector(".zx-record-card__metadata")?.remove();
28836
+ card.removeAttribute("aria-labelledby");
28837
+ card.setAttribute("aria-label", this._recordName(context.record, context.index));
28838
+ card.dataset.customCard = "true";
28839
+ }
28840
+ /**
28841
+ * Applies ordered card rules. The first match owns the marker tone; every match with a label adds
28842
+ * a badge, and rule descriptions join the accessible description so the marker is never the only
28843
+ * carrier of the meaning.
28844
+ * @param {HTMLElement} card Card element.
28845
+ * @param {KanbanRule[]} rules Matching rules.
28846
+ * @returns {void}
28847
+ */
28848
+ _applyCardRules(card, rules) {
28849
+ if (!rules.length) return;
28850
+ card.dataset.rule = rules.map((rule) => rule.id).join(" ");
28851
+ card.dataset.ruleTone = rules[0].tone;
28852
+ const badges = rules.filter((rule) => rule.label);
28853
+ if (badges.length) {
28854
+ const list = h("p", { class: "zx-kanban-view__rules" }, badges.map((rule) => badge({
28855
+ label: rule.label,
28856
+ icon: rule.icon ?? void 0,
28857
+ kind: rule.tone,
28858
+ size: "sm",
28859
+ variant: "soft"
28860
+ })));
28861
+ card.querySelector(".zx-record-card__body")?.append(list);
28862
+ }
28863
+ const descriptions = rules.map((rule) => rule.description).filter(Boolean).join(". ");
28864
+ if (descriptions) card.dataset.ruleDescription = descriptions;
28865
+ }
28866
+ /**
28867
+ * Composes one card's accessible description from its selection state and its rules, so that
28868
+ * refreshing selection cannot quietly drop what a rule contributed.
28869
+ * @param {HTMLElement} card Card element.
28870
+ * @param {unknown} id Record id.
28871
+ * @returns {void}
28872
+ */
28873
+ _applyCardDescription(card, id) {
28874
+ const parts = [];
28875
+ if (this.options.selectable) {
28876
+ parts.push(this._text(this._isViewSelected(id) ? "selected" : "notSelected"));
28877
+ }
28878
+ if (card.dataset.ruleDescription) parts.push(card.dataset.ruleDescription);
28879
+ if (parts.length) card.setAttribute("aria-description", parts.join(". "));
28880
+ else card.removeAttribute("aria-description");
28881
+ }
28882
+ /** @param {string} text Board-level empty message. @returns {HTMLElement} */
28883
+ _emptyContent(text4) {
28116
28884
  const supplied = typeof this.options.emptyText === "function" ? this.options.emptyText() : this.options.emptyText;
28117
- const content = supplied ?? "No records";
28885
+ const content = supplied ?? text4;
28118
28886
  return h(
28119
28887
  "div",
28120
28888
  { class: "zx-kanban-view__empty", role: "status" },
@@ -28124,6 +28892,37 @@ var zx = (() => {
28124
28892
  ) : String(content)
28125
28893
  );
28126
28894
  }
28895
+ /** @returns {string} Per-column placeholder text, or an empty string for none. */
28896
+ _placeholderText() {
28897
+ return this._text("columnEmpty");
28898
+ }
28899
+ /**
28900
+ * @param {number} count Visible records.
28901
+ * @param {number} total Records before search and filters.
28902
+ * @param {number|null} limit Applicable limit.
28903
+ * @returns {HTMLElement}
28904
+ */
28905
+ _countIndicator(count2, total, limit) {
28906
+ const filtered = count2 !== total;
28907
+ return h("span", {
28908
+ class: "zx-kanban-view__count",
28909
+ ariaLabel: limit != null ? this._text("countLimit", { count: total, limit }) : filtered ? this._text("countFiltered", { count: count2, total }) : this._text("count", { count: count2 })
28910
+ }, limit != null ? `${total} / ${limit}` : filtered ? `${count2} / ${total}` : String(count2));
28911
+ }
28912
+ /**
28913
+ * @param {number} count Visible records in this column and lane.
28914
+ * @param {number} total Records in this column and lane before search and filters.
28915
+ * @param {number} columnTotal Records across the whole column.
28916
+ * @param {number|null} limit Applicable limit.
28917
+ * @param {boolean} columnScoped Whether the limit counts the column rather than this lane.
28918
+ * @returns {HTMLElement}
28919
+ */
28920
+ _columnIndicator(count2, total, columnTotal, limit, columnScoped) {
28921
+ const filtered = count2 !== total;
28922
+ const label = limit == null ? filtered ? this._text("countFiltered", { count: count2, total }) : this._text("count", { count: count2 }) : columnScoped ? this._text("countLimitInLane", { count: total, total: columnTotal, limit }) : this._text("countLimit", { count: total, limit });
28923
+ const text4 = limit == null ? filtered ? `${count2} / ${total}` : String(count2) : columnScoped ? `${total} \xB7 ${columnTotal} / ${limit}` : `${total} / ${limit}`;
28924
+ return h("span", { class: "zx-kanban-view__count", ariaLabel: label }, text4);
28925
+ }
28127
28926
  /** @returns {KanbanColumn[]} */
28128
28927
  _rawColumns() {
28129
28928
  const options = this._kanbanOptions();
@@ -28132,7 +28931,11 @@ var zx = (() => {
28132
28931
  /** @returns {KanbanColumn[]} */
28133
28932
  _resolvedColumns() {
28134
28933
  const columns = this._rawColumns();
28135
- this._kanbanColumnOrder = reconcileKanbanOrder(columns, this._kanbanColumnOrder);
28934
+ this._kanbanColumnOrder = reconcileKanbanPreference(
28935
+ columns,
28936
+ this._kanbanColumnOrder,
28937
+ this._kanbanColumns === null
28938
+ );
28136
28939
  return orderDescriptors(columns, this._kanbanColumnOrder);
28137
28940
  }
28138
28941
  /** @returns {KanbanSwimlane[]} */
@@ -28144,7 +28947,12 @@ var zx = (() => {
28144
28947
  /** @returns {KanbanSwimlane[]} */
28145
28948
  _resolvedSwimlanes() {
28146
28949
  const lanes = this._rawSwimlanes();
28147
- this._kanbanSwimlaneOrder = reconcileKanbanOrder(lanes, this._kanbanSwimlaneOrder);
28950
+ const preserveUnknown = this._kanbanSwimlanes === null && this._kanbanOptions().swimlaneBy != null;
28951
+ this._kanbanSwimlaneOrder = reconcileKanbanPreference(
28952
+ lanes,
28953
+ this._kanbanSwimlaneOrder,
28954
+ preserveUnknown
28955
+ );
28148
28956
  return orderDescriptors(lanes, this._kanbanSwimlaneOrder);
28149
28957
  }
28150
28958
  /** @param {KanbanAccessor} accessor @param {KanbanRecord} record @param {number} index @returns {unknown} */
@@ -28168,13 +28976,84 @@ var zx = (() => {
28168
28976
  /** @param {unknown} id @returns {KanbanMovePoint|null} */
28169
28977
  _recordLocation(id) {
28170
28978
  const options = this._kanbanOptions();
28171
- const index = this._viewData.findIndex((record2) => Object.is(this._viewRecordId(record2), id));
28979
+ const index = this._viewData.findIndex((record) => Object.is(this._viewRecordId(record), id));
28172
28980
  if (index < 0) return null;
28173
- const record = this._viewData[index];
28174
- const column = normalizeAxisId(this._readAxis(options.columnBy, record, index));
28175
- const lane = options.swimlaneBy == null ? null : normalizeAxisId(this._readAxis(options.swimlaneBy, record, index));
28176
- const within = this._recordsIn(column, lane).findIndex((entry) => Object.is(this._viewRecordId(entry.record), id));
28177
- return { column, lane, index: within };
28981
+ return locateKanbanRecord(this._viewData, index, {
28982
+ recordId: options.recordId,
28983
+ columnBy: (record, recordIndex) => this._readAxis(options.columnBy, record, recordIndex),
28984
+ swimlaneBy: options.swimlaneBy == null ? null : (record, recordIndex) => this._readAxis(options.swimlaneBy, record, recordIndex)
28985
+ });
28986
+ }
28987
+ /**
28988
+ * Recomputes which records survive the search and the filter predicate.
28989
+ * @returns {void}
28990
+ */
28991
+ _resolveVisibility() {
28992
+ const terms = kanbanSearchTerms(this._kanbanSearch);
28993
+ const filter = this._kanbanFilter;
28994
+ const sources = terms.length ? this._searchSources() : null;
28995
+ this._visibleIds = /* @__PURE__ */ new Set();
28996
+ this._viewData.forEach((record, index) => {
28997
+ if (filter && filter(record, index) !== true) return;
28998
+ if (sources && !matchesKanbanSearch(this._searchableText(record, index, sources), terms)) return;
28999
+ this._visibleIds.add(this._viewRecordId(record));
29000
+ });
29001
+ }
29002
+ /**
29003
+ * Resolves what a search looks at, once. `searchFields` names them explicitly; otherwise the
29004
+ * visible fields are searched, plus the title and subtitle sources — a card is findable by the
29005
+ * name it shows even when that field is hidden from the metadata list.
29006
+ * @returns {{fields:import('../view/record-view.js').ViewField[],readers:((record:KanbanRecord,index:number)=>unknown)[]}}
29007
+ */
29008
+ _searchSources() {
29009
+ const options = this._kanbanOptions();
29010
+ const configured = Array.isArray(options.searchFields) ? options.searchFields.map(String) : null;
29011
+ const all = this.getFields();
29012
+ if (configured) return { fields: all.filter((field) => configured.includes(field.id)), readers: [] };
29013
+ const fields = this.getVisibleFields();
29014
+ const known = new Set(fields.map((field) => field.id));
29015
+ const readers = [];
29016
+ for (const source of [options.titleField, options.subtitleField]) {
29017
+ if (source == null) continue;
29018
+ if (typeof source === "function") {
29019
+ readers.push(source);
29020
+ continue;
29021
+ }
29022
+ if (known.has(source)) continue;
29023
+ const field = all.find((candidate) => candidate.id === source);
29024
+ if (field) fields.push(field);
29025
+ else readers.push((record) => record?.[source]);
29026
+ }
29027
+ return { fields, readers };
29028
+ }
29029
+ /**
29030
+ * Builds the text one record is searched through. Field `render` output is deliberately ignored:
29031
+ * it may be a DOM node, and matching what a formatter produced rather than what the record holds
29032
+ * would make the same query behave differently in two views over the same data.
29033
+ * @param {KanbanRecord} record Record.
29034
+ * @param {number} index Display index.
29035
+ * @param {{fields:import('../view/record-view.js').ViewField[],readers:((record:KanbanRecord,index:number)=>unknown)[]}} sources Resolved sources.
29036
+ * @returns {string} Searchable text.
29037
+ */
29038
+ _searchableText(record, index, sources) {
29039
+ const parts = sources.fields.map((field) => stringifyValue(readViewField(field, record, index)));
29040
+ for (const reader of sources.readers) parts.push(stringifyValue(reader(record, index)));
29041
+ return parts.filter(Boolean).join(" ");
29042
+ }
29043
+ /**
29044
+ * Translates a position among rendered cards into a position among all records in one bucket.
29045
+ * @param {string} column Column id.
29046
+ * @param {string|null} lane Lane id.
29047
+ * @param {number} visibleIndex Position among visible cards.
29048
+ * @param {Set<unknown>} excluded Ids being moved, which the visible list already omits.
29049
+ * @returns {number} Absolute position.
29050
+ */
29051
+ _absoluteIndex(column, lane, visibleIndex, excluded) {
29052
+ const entries = this._recordsIn(column, lane).filter((entry) => !excluded.has(this._viewRecordId(entry.record)));
29053
+ return absoluteKanbanIndex(
29054
+ entries.map((entry) => this._visibleIds.has(this._viewRecordId(entry.record))),
29055
+ visibleIndex
29056
+ );
28178
29057
  }
28179
29058
  /**
28180
29059
  * @param {KanbanRecord} record @param {KanbanMovePoint} from @param {KanbanMovePoint} to
@@ -28196,8 +29075,327 @@ var zx = (() => {
28196
29075
  if (options.swimlaneBy != null && normalizeAxisId(this._readAxis(options.swimlaneBy, clone, currentIndex)) !== to.lane) return null;
28197
29076
  return clone;
28198
29077
  }
29078
+ /**
29079
+ * Runs transition, eligibility, and capacity policy for a whole move. Every distinct origin is
29080
+ * checked, so a multi-card move drawn from two columns cannot slip past a transition rule that
29081
+ * only the second origin violates.
29082
+ * @param {KanbanRecord[]} records Moving records.
29083
+ * @param {KanbanMovePoint[]} origins Their origins.
29084
+ * @param {KanbanColumn} column Destination column.
29085
+ * @param {KanbanSwimlane|null} lane Destination lane.
29086
+ * @param {KanbanMovePoint} to Destination point.
29087
+ * @param {KanbanMoveContext} context Predicate context.
29088
+ * @param {Set<unknown>} excluded Moving ids, excluded from destination counts.
29089
+ * @returns {KanbanMoveEvaluation} Decision.
29090
+ */
29091
+ _evaluateMove(records, origins, column, lane, to, context, excluded) {
29092
+ const options = this._kanbanOptions();
29093
+ for (const origin of origins) {
29094
+ const blocked = allowsKanbanTransition(column, lane, origin);
29095
+ if (blocked) {
29096
+ return {
29097
+ allowed: false,
29098
+ reason: blocked,
29099
+ limit: null,
29100
+ count: 0,
29101
+ limitExceeded: false,
29102
+ limitReached: false,
29103
+ policy: column.wipPolicy ?? (options.wipPolicy === "block" ? "block" : "warn")
29104
+ };
29105
+ }
29106
+ }
29107
+ const excludes = (record) => !excluded.has(this._viewRecordId(record));
29108
+ const columnCount = this._viewData.filter((record, index) => normalizeAxisId(this._readAxis(options.columnBy, record, index)) === to.column && excludes(record)).length;
29109
+ const laneCount = options.swimlaneBy == null ? 0 : this._viewData.filter((record, index) => normalizeAxisId(this._readAxis(options.swimlaneBy, record, index)) === to.lane && excludes(record)).length;
29110
+ const cellCount = this._recordsIn(to.column, to.lane).filter((entry) => excludes(entry.record)).length;
29111
+ return evaluateKanbanMove({
29112
+ records,
29113
+ column,
29114
+ swimlane: lane,
29115
+ context,
29116
+ from: origins[0],
29117
+ columnCount,
29118
+ laneCount,
29119
+ cellCount,
29120
+ policy: options.wipPolicy === "block" ? "block" : "warn"
29121
+ });
29122
+ }
29123
+ /**
29124
+ * Announces and reports a refused move. Nothing is written and no `recordmove` is emitted, so a
29125
+ * listener can neither observe nor undo a move that never happened.
29126
+ * @param {KanbanRejectReason} reason Refusal reason.
29127
+ * @param {{ids:unknown[],records:KanbanRecord[],from:KanbanMovePoint|null,to:KanbanMovePoint|null,column:KanbanColumn|null,swimlane:KanbanSwimlane|null,evaluation?:KanbanMoveEvaluation}} detail Context.
29128
+ * @returns {void}
29129
+ * @fires KanbanView#movereject
29130
+ */
29131
+ _rejectMove(reason, detail) {
29132
+ const target = detail.to ? this._targetName(detail.to) : "";
29133
+ const name = this._movingName(detail.records);
29134
+ const message = reason === "destination" ? this._text("rejectDestination") : reason === "grouping" ? this._text("rejectGrouping") : reason === "transition" || reason === "lane-transition" ? this._text("rejectTransition", { target, origin: detail.from ? this._targetName(detail.from) : "" }) : reason === "wip" || reason === "lane-wip" ? this._text("rejectWip", { target, limit: detail.evaluation?.limit ?? "" }) : this._text("rejectAccept", { target, name });
29135
+ this._announce(message);
29136
+ this.emit("movereject", {
29137
+ reason,
29138
+ ids: [...detail.ids],
29139
+ records: [...detail.records],
29140
+ record: detail.records[0] ?? null,
29141
+ id: detail.ids[0],
29142
+ from: detail.from ? { ...detail.from } : null,
29143
+ to: detail.to ? { ...detail.to } : null,
29144
+ column: detail.column ? { ...detail.column } : null,
29145
+ swimlane: detail.swimlane ? { ...detail.swimlane } : null,
29146
+ limit: detail.evaluation?.limit ?? null,
29147
+ count: detail.evaluation?.count ?? 0,
29148
+ message
29149
+ });
29150
+ }
29151
+ /**
29152
+ * Installs a committed local record order, remembering the previous one for undo.
29153
+ * @param {KanbanRecord[]} records Next record order.
29154
+ * @returns {void}
29155
+ */
29156
+ _commitLocalRecords(records) {
29157
+ const options = this._kanbanOptions();
29158
+ if (options.history !== false) {
29159
+ this._kanbanHistory.push({ records: [...this._viewData], selection: this.getSelectionIds() });
29160
+ this._syncHistoryControls();
29161
+ }
29162
+ this._viewData = records;
29163
+ if (options.sortMode === "local" && this.getSort()) this.setSort(null);
29164
+ else this._refreshView("move");
29165
+ this.emit("datachange", { records: this.getData() });
29166
+ if (options.history !== false) this._emitHistory();
29167
+ }
29168
+ /**
29169
+ * @param {'undo'|'redo'} direction Travel direction.
29170
+ * @returns {this}
29171
+ */
29172
+ _travelHistory(direction) {
29173
+ if (this._kanbanOptions().history === false) return this;
29174
+ const current = { records: [...this._viewData], selection: this.getSelectionIds() };
29175
+ const entry = direction === "undo" ? this._kanbanHistory.undo(current) : this._kanbanHistory.redo(current);
29176
+ if (!entry) {
29177
+ this._announce(this._text(direction === "undo" ? "nothingToUndo" : "nothingToRedo"));
29178
+ return this;
29179
+ }
29180
+ this._viewData = [...entry.records];
29181
+ this._refreshView(direction);
29182
+ this.setSelection(entry.selection);
29183
+ this._syncHistoryControls();
29184
+ this.emit("datachange", { records: this.getData() });
29185
+ this._emitHistory();
29186
+ this._announce(this._text(direction === "undo" ? "undone" : "redone"));
29187
+ return this;
29188
+ }
29189
+ /** Drops both stacks without announcing, for host-driven data replacement. @returns {void} */
29190
+ _resetHistory() {
29191
+ if (!this._kanbanHistory) return;
29192
+ const had = this._kanbanHistory.canUndo() || this._kanbanHistory.canRedo();
29193
+ this._kanbanHistory.clear();
29194
+ this._syncHistoryControls();
29195
+ if (had) this._emitHistory();
29196
+ }
29197
+ /** @returns {void} */
29198
+ _emitHistory() {
29199
+ this.emit("historychange", {
29200
+ canUndo: this.canUndo(),
29201
+ canRedo: this.canRedo(),
29202
+ depth: this._kanbanHistory.depth()
29203
+ });
29204
+ }
29205
+ /** @returns {void} */
29206
+ _syncHistoryControls() {
29207
+ if (this._undoButton) this._undoButton.disabled = !this.canUndo();
29208
+ if (this._redoButton) this._redoButton.disabled = !this.canRedo();
29209
+ }
29210
+ /** @returns {HTMLElement} Built-in search control. */
29211
+ _createSearchControl() {
29212
+ const input = (
29213
+ /** @type {HTMLInputElement} */
29214
+ h("input", {
29215
+ class: "zx-kanban-view__search-input",
29216
+ type: "search",
29217
+ value: this._kanbanSearch,
29218
+ ariaLabel: this._text("search"),
29219
+ placeholder: this._text("searchPlaceholder")
29220
+ })
29221
+ );
29222
+ this._searchInput = input;
29223
+ this.listen(input, "input", () => {
29224
+ clearTimeout(this._searchTimer);
29225
+ this._searchTimer = setTimeout(() => {
29226
+ this._searchTimer = 0;
29227
+ if (!this._kanbanDestroyed) this.setSearch(input.value);
29228
+ }, SEARCH_DEBOUNCE);
29229
+ });
29230
+ this.listen(input, "keydown", (event) => {
29231
+ if (
29232
+ /** @type {KeyboardEvent} */
29233
+ event.key !== "Escape" || !input.value
29234
+ ) return;
29235
+ event.stopPropagation();
29236
+ clearTimeout(this._searchTimer);
29237
+ this._searchTimer = 0;
29238
+ this.setSearch("");
29239
+ });
29240
+ return h("div", { class: "zx-kanban-view__search" }, icon("search", { size: 13 }), input);
29241
+ }
29242
+ /** @returns {HTMLElement} Built-in history controls. */
29243
+ _createHistoryControls() {
29244
+ this._undoButton = /** @type {HTMLButtonElement} */
29245
+ h("button", {
29246
+ class: "zx-button zx-button--sm",
29247
+ type: "button",
29248
+ disabled: true,
29249
+ dataset: { kanbanHistory: "undo" }
29250
+ }, this._text("undo"));
29251
+ this._redoButton = /** @type {HTMLButtonElement} */
29252
+ h("button", {
29253
+ class: "zx-button zx-button--sm",
29254
+ type: "button",
29255
+ disabled: true,
29256
+ dataset: { kanbanHistory: "redo" }
29257
+ }, this._text("redo"));
29258
+ const group = h(
29259
+ "div",
29260
+ { class: "zx-kanban-view__history", role: "group" },
29261
+ this._undoButton,
29262
+ this._redoButton
29263
+ );
29264
+ this.listen(group, "click", (event) => {
29265
+ const control = (
29266
+ /** @type {Element|null} */
29267
+ event.target?.closest?.("[data-kanban-history]")
29268
+ );
29269
+ if (!control) return;
29270
+ if (
29271
+ /** @type {HTMLElement} */
29272
+ control.dataset.kanbanHistory === "undo"
29273
+ ) this.undo();
29274
+ else this.redo();
29275
+ });
29276
+ return group;
29277
+ }
29278
+ /** Attaches the card context menu. @returns {void} */
29279
+ _createContextMenu() {
29280
+ this._kanbanMenu = new ContextMenu(this._board, {
29281
+ selector: ".zx-record-card",
29282
+ items: (context) => this._menuItems(context),
29283
+ onselect: ({ detail }) => this._handleMenuSelect(detail)
29284
+ });
29285
+ }
29286
+ /**
29287
+ * Builds the menu for one card: every destination the same policy path would accept, plus any
29288
+ * application items. Destinations the board would refuse are shown disabled rather than hidden,
29289
+ * so a rule is discoverable instead of silently removing a command.
29290
+ * @param {Element|null} context Card element the menu opened on.
29291
+ * @returns {(MenuItem|'-')[]} Menu entries.
29292
+ */
29293
+ _menuItems(context) {
29294
+ const meta = context ? this._cardMeta.get(context) : null;
29295
+ if (!meta) return [];
29296
+ const options = this._kanbanOptions();
29297
+ const ids = this._movingIds(meta.id);
29298
+ const items = [];
29299
+ for (const column of this._resolvedColumns()) {
29300
+ if (column.id === meta.column) continue;
29301
+ items.push({
29302
+ label: this._text("menuMoveTo", { target: column.label }),
29303
+ value: { kind: "column", id: column.id, ids },
29304
+ disabled: !this._canMove(ids, { column: column.id })
29305
+ });
29306
+ }
29307
+ if (options.swimlaneBy != null) {
29308
+ for (const lane of this._resolvedSwimlanes()) {
29309
+ if (lane.id === meta.lane) continue;
29310
+ items.push({
29311
+ label: this._text("menuMoveToLane", { target: lane.label }),
29312
+ value: { kind: "lane", id: lane.id, ids },
29313
+ disabled: !this._canMove(ids, { lane: lane.id })
29314
+ });
29315
+ }
29316
+ }
29317
+ const extra = typeof options.contextMenu === "function" ? options.contextMenu({
29318
+ record: meta.record,
29319
+ id: meta.id,
29320
+ index: meta.index,
29321
+ column: meta.column,
29322
+ lane: meta.lane,
29323
+ rules: resolveKanbanRules(this._kanbanRules, meta.record, meta),
29324
+ selected: this._isViewSelected(meta.id)
29325
+ }) : Array.isArray(options.contextMenu) ? options.contextMenu : [];
29326
+ if (Array.isArray(extra) && extra.length) items.push("-", ...extra);
29327
+ return items;
29328
+ }
29329
+ /**
29330
+ * @param {{value:unknown,item:MenuItem,context:Element|null}} detail Selection detail.
29331
+ * @returns {void}
29332
+ */
29333
+ _handleMenuSelect(detail) {
29334
+ const value = (
29335
+ /** @type {{kind?:string,id?:string,ids?:unknown[]}|null} */
29336
+ detail.value
29337
+ );
29338
+ if (!value || typeof value !== "object" || !value.kind || !Array.isArray(value.ids)) return;
29339
+ this.moveRecords(value.ids, value.kind === "lane" ? { lane: value.id } : { column: value.id });
29340
+ }
29341
+ /**
29342
+ * Dry-runs the policy path for a destination without emitting or committing anything.
29343
+ * @param {unknown[]} ids Moving ids.
29344
+ * @param {KanbanDestination} destination Destination.
29345
+ * @returns {boolean} Whether the move would be allowed.
29346
+ */
29347
+ _canMove(ids, destination) {
29348
+ const options = this._kanbanOptions();
29349
+ const moving = this._orderedIds(ids);
29350
+ if (!moving.length) return false;
29351
+ const records = moving.map((id) => (
29352
+ /** @type {KanbanRecord} */
29353
+ this.getRecord(id)
29354
+ ));
29355
+ const origins = moving.map((id) => (
29356
+ /** @type {KanbanMovePoint} */
29357
+ this._recordLocation(id)
29358
+ ));
29359
+ const from = origins[0];
29360
+ const columnId = destination.column == null ? from.column : String(destination.column);
29361
+ const laneId = options.swimlaneBy == null ? null : destination.lane === void 0 ? from.lane : normalizeAxisId(destination.lane);
29362
+ const column = this._resolvedColumns().find((candidate) => candidate.id === columnId);
29363
+ const lane = options.swimlaneBy == null ? null : this._resolvedSwimlanes().find((candidate) => candidate.id === laneId) ?? null;
29364
+ if (!column || options.swimlaneBy != null && !lane) return false;
29365
+ const excluded = new Set(moving);
29366
+ const to = { column: columnId, lane: laneId, index: 0 };
29367
+ const context = {
29368
+ id: moving[0],
29369
+ from: { ...from },
29370
+ to: { ...to },
29371
+ column: { ...column },
29372
+ swimlane: lane ? { ...lane } : null
29373
+ };
29374
+ return this._evaluateMove(records, origins, column, lane, to, context, excluded).allowed;
29375
+ }
29376
+ /**
29377
+ * Resolves the ids one gesture moves: the whole selection when the grabbed card belongs to it,
29378
+ * otherwise just that card. Picking up an unselected card never drags the selection along.
29379
+ * @param {unknown} id Grabbed record id.
29380
+ * @returns {unknown[]} Ids to move, in board order.
29381
+ */
29382
+ _movingIds(id) {
29383
+ const options = this._kanbanOptions();
29384
+ if (options.multiMove === false || options.selectable !== "multi" || !this._isViewSelected(id)) return [id];
29385
+ const selection = this.getSelectionIds();
29386
+ return selection.length > 1 ? selection : [id];
29387
+ }
29388
+ /** @param {unknown[]} ids Requested ids. @returns {unknown[]} Existing ids in board order. */
29389
+ _orderedIds(ids) {
29390
+ const requested = new Set(ids);
29391
+ return this._viewData.map((record) => this._viewRecordId(record)).filter((id) => requested.has(id));
29392
+ }
28199
29393
  /** @param {MouseEvent} event @returns {void} */
28200
29394
  _handleBoardClick(event) {
29395
+ if (this._suppressClick) {
29396
+ this._suppressClick = false;
29397
+ return;
29398
+ }
28201
29399
  const target = elementTarget(event);
28202
29400
  const collapse = target?.closest("[data-kanban-collapse]");
28203
29401
  if (collapse) {
@@ -28209,6 +29407,23 @@ var zx = (() => {
28209
29407
  else this.toggleSwimlane(String(element.dataset.kanbanId));
28210
29408
  return;
28211
29409
  }
29410
+ const add = target?.closest("[data-kanban-add]");
29411
+ if (add) {
29412
+ const element = (
29413
+ /** @type {HTMLElement} */
29414
+ add
29415
+ );
29416
+ const columnId = String(element.dataset.kanbanAdd);
29417
+ const laneId = element.dataset.kanbanLane || null;
29418
+ this.emit("recordadd", {
29419
+ column: this._resolvedColumns().find((candidate) => candidate.id === columnId) ?? null,
29420
+ swimlane: laneId == null ? null : this._resolvedSwimlanes().find((candidate) => candidate.id === laneId) ?? null,
29421
+ columnId,
29422
+ laneId,
29423
+ event
29424
+ });
29425
+ return;
29426
+ }
28212
29427
  const meta = this._metaForTarget(target);
28213
29428
  if (!meta) return;
28214
29429
  const selection = (
@@ -28257,7 +29472,14 @@ var zx = (() => {
28257
29472
  }
28258
29473
  /** @param {KeyboardEvent} event @returns {void} */
28259
29474
  _handleBoardKeydown(event) {
29475
+ if (this._dragIds.length && event.key === "Escape") {
29476
+ event.preventDefault();
29477
+ this._clearDrag();
29478
+ this._announce(this._text("moveCanceledPlain"));
29479
+ return;
29480
+ }
28260
29481
  const target = elementTarget(event);
29482
+ if (this._handleHistoryKeydown(event, target)) return;
28261
29483
  const handle = target?.closest(".zx-kanban-view__move-handle");
28262
29484
  const meta = this._metaForTarget(handle);
28263
29485
  if (handle && meta) {
@@ -28281,12 +29503,33 @@ var zx = (() => {
28281
29503
  this.toggleSelection(cardMeta.id);
28282
29504
  }
28283
29505
  }
29506
+ /**
29507
+ * @param {KeyboardEvent} event Keyboard event.
29508
+ * @param {Element|null} target Event target.
29509
+ * @returns {boolean} Whether the event was handled as a history command.
29510
+ */
29511
+ _handleHistoryKeydown(event, target) {
29512
+ if (this._kanbanOptions().history === false || !(event.ctrlKey || event.metaKey)) return false;
29513
+ if (target?.closest('input, textarea, [contenteditable="true"]')) return false;
29514
+ const key = event.key.toLowerCase();
29515
+ if (key === "z" && !event.shiftKey && this.canUndo()) {
29516
+ event.preventDefault();
29517
+ this.undo();
29518
+ return true;
29519
+ }
29520
+ if ((key === "y" || key === "z" && event.shiftKey) && this.canRedo()) {
29521
+ event.preventDefault();
29522
+ this.redo();
29523
+ return true;
29524
+ }
29525
+ return false;
29526
+ }
28284
29527
  /** @param {KeyboardEvent} event @param {HTMLElement} handle @param {KanbanCardMeta} meta @returns {void} */
28285
29528
  _handleMoveKeydown(event, handle, meta) {
28286
29529
  if (event.key === " " || event.key === "Enter") {
28287
29530
  event.preventDefault();
28288
- if (this._keyboardMove && Object.is(this._keyboardMove.id, meta.id)) {
28289
- const destination = { ...this._keyboardMove.to };
29531
+ if (this._keyboardMove?.ids.some((candidate) => Object.is(candidate, meta.id))) {
29532
+ const move2 = this._keyboardMove;
28290
29533
  this._keyboardMove = null;
28291
29534
  handle.setAttribute("aria-pressed", "false");
28292
29535
  const card = (
@@ -28295,22 +29538,24 @@ var zx = (() => {
28295
29538
  );
28296
29539
  if (card) delete card.dataset.grabbed;
28297
29540
  this._syncKeyboardTarget();
28298
- this.moveRecord(meta.id, destination);
29541
+ this._commitInteractionMove(move2.ids, move2.to);
28299
29542
  } else {
28300
29543
  const from = this._recordLocation(meta.id);
28301
29544
  if (!from) return;
28302
- this._keyboardMove = { id: meta.id, from: { ...from }, to: { ...from } };
29545
+ const ids = this._movingIds(meta.id);
29546
+ const start = { ...from, index: this._visibleIndex(meta.id) };
29547
+ this._keyboardMove = { ids, from: { ...from }, to: start };
28303
29548
  handle.setAttribute("aria-pressed", "true");
28304
29549
  const card = handle.closest(".zx-record-card");
28305
29550
  if (card) card.dataset.grabbed = "true";
28306
29551
  this._syncKeyboardTarget();
28307
- this._announce(`Grabbed ${this._recordName(meta.record, meta.index)}. Use left and right for columns, up and down for position, and Alt plus up or down for swim lanes.`);
29552
+ this._announce(ids.length > 1 ? this._text("grabbedMultiple", { count: ids.length }) : this._text("grabbed", { name: this._recordName(meta.record, meta.index) }));
28308
29553
  }
28309
29554
  return;
28310
29555
  }
28311
29556
  if (event.key === "Escape" && this._keyboardMove) {
28312
29557
  event.preventDefault();
28313
- const record = this.getRecord(this._keyboardMove.id);
29558
+ const records = this._keyboardMove.ids.map((id) => this.getRecord(id)).filter(Boolean);
28314
29559
  this._keyboardMove = null;
28315
29560
  handle.setAttribute("aria-pressed", "false");
28316
29561
  const card = (
@@ -28319,10 +29564,13 @@ var zx = (() => {
28319
29564
  );
28320
29565
  if (card) delete card.dataset.grabbed;
28321
29566
  this._syncKeyboardTarget();
28322
- this._announce(`Move canceled${record ? ` for ${this._recordName(record, meta.index)}` : ""}.`);
29567
+ this._announce(records.length ? this._text("moveCanceled", { name: this._movingName(
29568
+ /** @type {KanbanRecord[]} */
29569
+ records
29570
+ ) }) : this._text("moveCanceledPlain"));
28323
29571
  return;
28324
29572
  }
28325
- if (!this._keyboardMove || !Object.is(this._keyboardMove.id, meta.id) || !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return;
29573
+ if (!this._keyboardMove || !this._keyboardMove.ids.some((candidate) => Object.is(candidate, meta.id)) || !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return;
28326
29574
  event.preventDefault();
28327
29575
  const move = this._keyboardMove;
28328
29576
  const laneModifier = event.altKey || event.ctrlKey || event.metaKey;
@@ -28339,10 +29587,14 @@ var zx = (() => {
28339
29587
  } else if (event.key === "ArrowUp" || event.key === "ArrowDown") {
28340
29588
  move.to.index += event.key === "ArrowUp" ? -1 : 1;
28341
29589
  }
28342
- const targetCount = this._recordsIn(move.to.column, move.to.lane).filter((entry) => !Object.is(this._viewRecordId(entry.record), move.id)).length;
28343
- move.to.index = normalizeMoveIndex(move.to.index, targetCount);
29590
+ const targetCount = this._visibleCount(move.to.column, move.to.lane, new Set(move.ids));
29591
+ move.to.index = clampKanbanIndex(move.to.index, targetCount);
28344
29592
  this._syncKeyboardTarget();
28345
- this._announce(`Target ${this._targetName(move.to)}, position ${move.to.index + 1} of ${targetCount + 1}.`);
29593
+ this._announce(this._text("target", {
29594
+ target: this._targetName(move.to),
29595
+ position: move.to.index + 1,
29596
+ total: targetCount + 1
29597
+ }));
28346
29598
  }
28347
29599
  /** @param {Event} event @returns {void} */
28348
29600
  _handlePreviewError(event) {
@@ -28358,80 +29610,246 @@ var zx = (() => {
28358
29610
  if (preview) preview.dataset.failed = "true";
28359
29611
  image.hidden = true;
28360
29612
  }
28361
- /** @param {DragEvent} event @returns {void} */
28362
- _handleDragStart(event) {
28363
- const handle = elementTarget(event)?.closest(".zx-kanban-view__move-handle");
28364
- const meta = this._metaForTarget(handle);
28365
- if (!handle || !meta) {
28366
- event.preventDefault();
28367
- return;
29613
+ /**
29614
+ * Records a candidate drag. Nothing moves yet: a press that never travels stays a click, and a
29615
+ * touch on the card body stays a scroll until the long press elapses.
29616
+ * @param {PointerEvent} event Pointer event.
29617
+ * @returns {void}
29618
+ */
29619
+ _handlePointerDown(event) {
29620
+ this._suppressClick = false;
29621
+ if (event.button != null && event.button !== 0) return;
29622
+ const target = elementTarget(event);
29623
+ const handle = target?.closest(".zx-kanban-view__move-handle");
29624
+ const meta = this._metaForTarget(handle ?? target);
29625
+ if (!meta) return;
29626
+ if (!handle) {
29627
+ if (this._kanbanOptions().dragFrom !== "card") return;
29628
+ if (target?.closest(INTERACTIVE) || target?.closest(".zx-record-card__selection")) return;
29629
+ }
29630
+ const ids = this._movingIds(meta.id);
29631
+ this._pointerStart = {
29632
+ x: event.clientX,
29633
+ y: event.clientY,
29634
+ id: event.pointerId,
29635
+ ids,
29636
+ touch: event.pointerType === "touch"
29637
+ };
29638
+ this._dragPoint = { x: event.clientX, y: event.clientY };
29639
+ clearTimeout(this._pointerHold);
29640
+ if (event.pointerType !== "touch" || handle) return;
29641
+ this._pointerHold = setTimeout(() => this._beginDrag(this._dragPoint.x, this._dragPoint.y), TOUCH_HOLD);
29642
+ }
29643
+ /** @param {PointerEvent} event @returns {void} */
29644
+ _handlePointerMove(event) {
29645
+ const start = this._pointerStart;
29646
+ if (!this._dragIds.length) {
29647
+ if (!start || event.pointerId !== start.id) return;
29648
+ const distance = Math.hypot(event.clientX - start.x, event.clientY - start.y);
29649
+ this._dragPoint = { x: event.clientX, y: event.clientY };
29650
+ if (distance <= DRAG_THRESHOLD) return;
29651
+ if (start.touch && this._pointerHold) {
29652
+ clearTimeout(this._pointerHold);
29653
+ this._pointerHold = 0;
29654
+ this._pointerStart = null;
29655
+ return;
29656
+ }
29657
+ this._beginDrag(event.clientX, event.clientY);
29658
+ if (!this._dragIds.length) return;
28368
29659
  }
28369
- this._dragId = meta.id;
28370
- const card = handle.closest(".zx-record-card");
28371
- if (card) card.dataset.dragging = "true";
28372
- event.dataTransfer?.setData("text/plain", "zx-kanban-record");
28373
- if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
29660
+ if (this._dragPointer != null && event.pointerId !== this._dragPointer) return;
29661
+ event.preventDefault();
29662
+ this._dragPoint = { x: event.clientX, y: event.clientY };
29663
+ this._positionDragPreview();
29664
+ this._updateDropTarget(event.clientX, event.clientY);
28374
29665
  }
28375
- /** @param {DragEvent} event @returns {void} */
28376
- _handleDragOver(event) {
28377
- if (this._dragId == null) return;
28378
- const section = elementTarget(event)?.closest(".zx-kanban-view__column");
28379
- const sectionMeta = section ? this._sectionMeta.get(section) : null;
28380
- if (!sectionMeta) return;
29666
+ /** @param {PointerEvent} event @returns {void} */
29667
+ _handlePointerUp(event) {
29668
+ if (this._dragIds.length && this._dragPointer != null && event.pointerId !== this._dragPointer) return;
29669
+ clearTimeout(this._pointerHold);
29670
+ this._pointerHold = 0;
29671
+ this._pointerStart = null;
29672
+ if (!this._dragIds.length) return;
28381
29673
  event.preventDefault();
28382
- const destination = this._pointerDestination(event, sectionMeta);
28383
- this._dragDestination = destination;
29674
+ const ids = [...this._dragIds];
29675
+ const destination = this._dragDestination ? { ...this._dragDestination } : null;
29676
+ this._clearDrag();
29677
+ this._suppressClick = true;
29678
+ if (destination) this._commitInteractionMove(ids, destination);
29679
+ }
29680
+ /**
29681
+ * Starts a pointer drag: the floating preview appears, the board stops selecting text, and the
29682
+ * auto-scroll loop begins.
29683
+ * @param {number} x Pointer client x.
29684
+ * @param {number} y Pointer client y.
29685
+ * @returns {void}
29686
+ */
29687
+ _beginDrag(x, y) {
29688
+ const start = this._pointerStart;
29689
+ if (!start || this._dragIds.length) return;
29690
+ this._pointerHold = 0;
29691
+ this._dragIds = start.ids.filter((id) => this.getRecord(id));
29692
+ if (!this._dragIds.length) return;
29693
+ this._dragPoint = { x, y };
29694
+ this._board.dataset.dragging = "true";
29695
+ for (const id of this._dragIds) {
29696
+ const card = this._cardsById.get(id);
29697
+ if (card) card.dataset.dragging = "true";
29698
+ }
29699
+ this._dragPointer = start.id;
29700
+ try {
29701
+ this._board.setPointerCapture(start.id);
29702
+ this._dragCaptured = true;
29703
+ } catch {
29704
+ }
29705
+ globalThis.getSelection?.()?.removeAllRanges();
29706
+ this._dragPreview = this._createDragPreview();
29707
+ this.el.append(this._dragPreview);
29708
+ this._positionDragPreview();
29709
+ this._updateDropTarget(x, y);
29710
+ if (this._kanbanOptions().autoScroll !== false) this._scheduleAutoScroll();
29711
+ }
29712
+ /** @returns {HTMLElement} Floating drag preview. */
29713
+ _createDragPreview() {
29714
+ const records = this._dragIds.map((id) => (
29715
+ /** @type {KanbanRecord} */
29716
+ this.getRecord(id)
29717
+ ));
29718
+ const custom = this._renderHook("renderDragPreview", {
29719
+ records,
29720
+ ids: [...this._dragIds],
29721
+ count: this._dragIds.length
29722
+ });
29723
+ const content = custom ?? (this._dragIds.length > 1 ? this._text("dragPreview", { count: this._dragIds.length }) : this._movingName(records));
29724
+ return h("div", {
29725
+ class: "zx-kanban-view__drag-preview",
29726
+ ariaHidden: "true",
29727
+ dataset: { count: String(this._dragIds.length) }
29728
+ }, content);
29729
+ }
29730
+ /** @returns {void} */
29731
+ _positionDragPreview() {
29732
+ if (!this._dragPreview) return;
29733
+ this._dragPreview.style.transform = `translate3d(${Math.round(this._dragPoint.x)}px, ${Math.round(this._dragPoint.y)}px, 0)`;
29734
+ }
29735
+ /**
29736
+ * Resolves and marks the drop destination under the pointer. Pointer capture retargets events to
29737
+ * the board, so the element under the pointer is looked up rather than read off the event.
29738
+ * @param {number} x Pointer client x.
29739
+ * @param {number} y Pointer client y.
29740
+ * @returns {void}
29741
+ */
29742
+ _updateDropTarget(x, y) {
29743
+ const element = document.elementFromPoint(x, y);
29744
+ const section = element?.closest(".zx-kanban-view__column");
29745
+ const sectionMeta = section && this._board.contains(section) ? this._sectionMeta.get(section) : null;
28384
29746
  this._clearDropMarkers();
28385
- const card = elementTarget(event)?.closest(".zx-record-card");
28386
- const cardMeta = card ? this._cardMeta.get(card) : null;
28387
- if (card && cardMeta && !Object.is(cardMeta.id, this._dragId)) {
28388
- const rectangle = card.getBoundingClientRect();
28389
- card.dataset.drop = event.clientY >= rectangle.top + rectangle.height / 2 ? "after" : "before";
28390
- } else {
28391
- sectionMeta.list.dataset.drop = "append";
29747
+ if (!sectionMeta || this._collapsedColumns.has(sectionMeta.column)) {
29748
+ this._dragDestination = null;
29749
+ return;
28392
29750
  }
28393
- if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
29751
+ const cards = this._draggableCards(sectionMeta.list);
29752
+ const index = cards.findIndex((card) => {
29753
+ const rectangle = card.getBoundingClientRect();
29754
+ return y < rectangle.top + rectangle.height / 2;
29755
+ });
29756
+ const position2 = index < 0 ? cards.length : index;
29757
+ if (cards[position2]) cards[position2].dataset.drop = "before";
29758
+ else if (cards.length) cards[cards.length - 1].dataset.drop = "after";
29759
+ else sectionMeta.list.dataset.drop = "append";
29760
+ this._dragDestination = { column: sectionMeta.column, lane: sectionMeta.lane, index: position2 };
28394
29761
  }
28395
- /** @param {DragEvent} event @returns {void} */
28396
- _handleDrop(event) {
28397
- if (this._dragId == null || !this._dragDestination) return;
28398
- event.preventDefault();
28399
- const id = this._dragId;
28400
- const destination = { ...this._dragDestination };
28401
- this._clearDrag();
28402
- this.moveRecord(id, destination);
28403
- }
28404
- /** @param {DragEvent} event @param {KanbanSectionMeta} section @returns {KanbanMovePoint} */
28405
- _pointerDestination(event, section) {
28406
- const cards = [...section.list.querySelectorAll(":scope > .zx-record-card")].filter((card) => !Object.is(this._cardMeta.get(card)?.id, this._dragId));
28407
- const target = elementTarget(event)?.closest(".zx-record-card");
28408
- const targetMeta = target ? this._cardMeta.get(target) : null;
28409
- if (target && targetMeta && Object.is(targetMeta.id, this._dragId)) {
28410
- const from = this._recordLocation(this._dragId);
28411
- return { column: section.column, lane: section.lane, index: from?.index ?? cards.length };
28412
- }
28413
- const targetIndex = target ? cards.indexOf(target) : -1;
28414
- if (targetIndex < 0) return { column: section.column, lane: section.lane, index: cards.length };
28415
- const rectangle = target.getBoundingClientRect();
28416
- return {
28417
- column: section.column,
28418
- lane: section.lane,
28419
- index: targetIndex + (event.clientY >= rectangle.top + rectangle.height / 2 ? 1 : 0)
28420
- };
29762
+ /** @returns {void} */
29763
+ _scheduleAutoScroll() {
29764
+ if (this._dragFrame) return;
29765
+ this._dragFrame = requestAnimationFrame(() => {
29766
+ this._dragFrame = 0;
29767
+ if (!this._dragIds.length) return;
29768
+ this._autoScroll();
29769
+ this._scheduleAutoScroll();
29770
+ });
29771
+ }
29772
+ /** Scrolls the board horizontally and the hovered column vertically near their edges. @returns {void} */
29773
+ _autoScroll() {
29774
+ const { x, y } = this._dragPoint;
29775
+ const board = this._board.getBoundingClientRect();
29776
+ if (x < board.left + SCROLL_EDGE) this._board.scrollLeft -= SCROLL_SPEED;
29777
+ else if (x > board.right - SCROLL_EDGE) this._board.scrollLeft += SCROLL_SPEED;
29778
+ const list = document.elementFromPoint(x, y)?.closest(".zx-kanban-view__cards");
29779
+ if (!list || !this._board.contains(list) || list.scrollHeight <= list.clientHeight) return;
29780
+ const bounds = list.getBoundingClientRect();
29781
+ if (y < bounds.top + SCROLL_EDGE) list.scrollTop -= SCROLL_SPEED;
29782
+ else if (y > bounds.bottom - SCROLL_EDGE) list.scrollTop += SCROLL_SPEED;
29783
+ }
29784
+ /**
29785
+ * Commits a move whose index came from rendered cards. Search and filters hide records without
29786
+ * removing them, so the visible position is translated back before anything is proposed.
29787
+ * @param {unknown[]} ids Moving ids.
29788
+ * @param {KanbanMovePoint} point Destination in visible-card space.
29789
+ * @returns {void}
29790
+ */
29791
+ _commitInteractionMove(ids, point) {
29792
+ const excluded = new Set(ids);
29793
+ this.moveRecords(ids, {
29794
+ column: point.column,
29795
+ lane: point.lane,
29796
+ index: this._absoluteIndex(point.column, point.lane, point.index, excluded)
29797
+ });
28421
29798
  }
28422
29799
  /** @returns {void} */
28423
29800
  _clearDrag() {
28424
- this._clearDropMarkers();
28425
- for (const card of this._board.querySelectorAll("[data-dragging]")) delete /** @type {HTMLElement} */
28426
- card.dataset.dragging;
28427
- this._dragId = null;
29801
+ if (this._dragFrame) cancelAnimationFrame(this._dragFrame);
29802
+ this._dragFrame = 0;
29803
+ clearTimeout(this._pointerHold);
29804
+ this._pointerHold = 0;
29805
+ this._pointerStart = null;
29806
+ this._dragPreview?.remove();
29807
+ this._dragPreview = null;
29808
+ this._dragIds = [];
28428
29809
  this._dragDestination = null;
29810
+ if (this._board) {
29811
+ if (this._dragCaptured && this._dragPointer != null) {
29812
+ try {
29813
+ this._board.releasePointerCapture(this._dragPointer);
29814
+ } catch {
29815
+ }
29816
+ }
29817
+ this._clearDropMarkers();
29818
+ delete this._board.dataset.dragging;
29819
+ for (const card of this._board.querySelectorAll("[data-dragging]")) delete /** @type {HTMLElement} */
29820
+ card.dataset.dragging;
29821
+ }
29822
+ this._dragPointer = null;
29823
+ this._dragCaptured = false;
28429
29824
  }
28430
29825
  /** @returns {void} */
28431
29826
  _clearDropMarkers() {
28432
29827
  for (const element of this._board.querySelectorAll("[data-drop]")) delete /** @type {HTMLElement} */
28433
29828
  element.dataset.drop;
28434
29829
  }
29830
+ /** @param {HTMLElement} list Card list. @returns {HTMLElement[]} Cards excluding the ones in flight. */
29831
+ _draggableCards(list) {
29832
+ const moving = new Set(this._dragIds.length ? this._dragIds : this._keyboardMove?.ids ?? []);
29833
+ return (
29834
+ /** @type {HTMLElement[]} */
29835
+ [...list.querySelectorAll(":scope > .zx-record-card")].filter((card) => !hasId(moving, this._cardMeta.get(card)?.id))
29836
+ );
29837
+ }
29838
+ /** @param {string} column @param {string|null} lane @param {Set<unknown>} excluded @returns {number} */
29839
+ _visibleCount(column, lane, excluded) {
29840
+ return this._recordsIn(column, lane).filter((entry) => {
29841
+ const id = this._viewRecordId(entry.record);
29842
+ return !hasId(excluded, id) && this._visibleIds.has(id);
29843
+ }).length;
29844
+ }
29845
+ /** @param {unknown} id Record id. @returns {number} Position among visible cards in its bucket. */
29846
+ _visibleIndex(id) {
29847
+ const location = this._recordLocation(id);
29848
+ if (!location) return 0;
29849
+ const visible = this._recordsIn(location.column, location.lane).filter((entry) => this._visibleIds.has(this._viewRecordId(entry.record)));
29850
+ const position2 = visible.findIndex((entry) => Object.is(this._viewRecordId(entry.record), id));
29851
+ return position2 < 0 ? visible.length : position2;
29852
+ }
28435
29853
  /** @returns {void} */
28436
29854
  _syncKeyboardTarget() {
28437
29855
  for (const element of this._board.querySelectorAll("[data-keyboard-target]")) {
@@ -28446,7 +29864,7 @@ var zx = (() => {
28446
29864
  });
28447
29865
  const sectionMeta = section ? this._sectionMeta.get(section) : null;
28448
29866
  if (!sectionMeta) return;
28449
- const cards = [...sectionMeta.list.querySelectorAll(":scope > .zx-record-card")].filter((card) => !Object.is(this._cardMeta.get(card)?.id, move.id));
29867
+ const cards = this._draggableCards(sectionMeta.list);
28450
29868
  const target = cards[move.to.index];
28451
29869
  if (target) target.dataset.keyboardTarget = "before";
28452
29870
  else sectionMeta.list.dataset.keyboardTarget = "append";
@@ -28456,7 +29874,7 @@ var zx = (() => {
28456
29874
  for (const [id, card] of this._cardsById) {
28457
29875
  const selected = this._isViewSelected(id);
28458
29876
  card.dataset.selected = String(selected);
28459
- if (this.options.selectable) card.setAttribute("aria-selected", String(selected));
29877
+ this._applyCardDescription(card, id);
28460
29878
  const input = (
28461
29879
  /** @type {HTMLInputElement|null} */
28462
29880
  card.querySelector("[data-record-selection]")
@@ -28476,6 +29894,50 @@ var zx = (() => {
28476
29894
  this._cardsById.get(id)?.querySelector(".zx-kanban-view__move-handle") ?? null
28477
29895
  );
28478
29896
  }
29897
+ /**
29898
+ * Resolves a collapse control after a board refresh, retaining the same lane copy for columns.
29899
+ * @param {'column'|'swimlane'} axis Collapse axis.
29900
+ * @param {string} id Descriptor id.
29901
+ * @param {string|null} [lane=null] Column copy's lane.
29902
+ * @returns {HTMLElement|null}
29903
+ */
29904
+ _collapseControl(axis, id, lane = null) {
29905
+ return (
29906
+ /** @type {HTMLElement|null} */
29907
+ [...this._board.querySelectorAll("[data-kanban-collapse]")].find((candidate) => {
29908
+ const control = (
29909
+ /** @type {HTMLElement} */
29910
+ candidate
29911
+ );
29912
+ if (control.dataset.kanbanCollapse !== axis || control.dataset.kanbanId !== id) return false;
29913
+ if (axis !== "column") return true;
29914
+ const section = control.closest(".zx-kanban-view__column");
29915
+ return section ? this._sectionMeta.get(section)?.lane === lane : false;
29916
+ }) ?? null
29917
+ );
29918
+ }
29919
+ /**
29920
+ * Runs one presentation hook, ignoring anything that is not a node so a hook cannot smuggle
29921
+ * markup in as a string.
29922
+ * @param {'renderCard'|'renderColumnHeader'|'renderSwimlaneHeader'|'renderColumnEmpty'|'renderDragPreview'} name Hook name.
29923
+ * @param {any} context Hook context.
29924
+ * @returns {Node|null} Node to adopt, or null for the default presentation.
29925
+ */
29926
+ _renderHook(name, context) {
29927
+ const hook = this._kanbanOptions()[name];
29928
+ if (typeof hook !== "function") return null;
29929
+ const result = hook(context);
29930
+ return result && typeof result === "object" && "nodeType" in result ? (
29931
+ /** @type {Node} */
29932
+ result
29933
+ ) : null;
29934
+ }
29935
+ /** @param {KanbanColumn} column @param {KanbanSwimlane|null} lane @returns {boolean} */
29936
+ _allowsAdd(column, lane) {
29937
+ const allow = this._kanbanOptions().allowAdd;
29938
+ if (typeof allow === "function") return allow(column, lane) === true;
29939
+ return allow === true;
29940
+ }
28479
29941
  /** @param {KanbanRecord} record @param {number} index @returns {string} */
28480
29942
  _recordName(record, index) {
28481
29943
  const source = this._kanbanOptions().titleField;
@@ -28483,10 +29945,16 @@ var zx = (() => {
28483
29945
  if (value && typeof value === "object" && "nodeType" in value) {
28484
29946
  return (
28485
29947
  /** @type {Node} */
28486
- value.textContent?.trim() || "record"
29948
+ value.textContent?.trim() || this._text("record")
28487
29949
  );
28488
29950
  }
28489
- return String(value ?? "record");
29951
+ return String(value ?? this._text("record"));
29952
+ }
29953
+ /** @param {KanbanRecord[]} records Moving records. @returns {string} */
29954
+ _movingName(records) {
29955
+ if (!records.length) return this._text("record");
29956
+ if (records.length === 1) return this._recordName(records[0], this._viewData.indexOf(records[0]));
29957
+ return this._text("dragPreview", { count: records.length });
28490
29958
  }
28491
29959
  /** @param {KanbanMovePoint} point @returns {string} */
28492
29960
  _targetName(point) {
@@ -28494,6 +29962,26 @@ var zx = (() => {
28494
29962
  const lane = point.lane == null ? null : this._resolvedSwimlanes().find((candidate) => candidate.id === point.lane);
28495
29963
  return `${column?.label ?? point.column}${lane ? `, ${lane.label}` : ""}`;
28496
29964
  }
29965
+ /** @param {KanbanMoveEvaluation} evaluation Capacity report. @returns {string} */
29966
+ _wipAnnouncement(evaluation) {
29967
+ if (evaluation.limit == null) return "";
29968
+ if (evaluation.limitExceeded) return this._text("limitExceeded", { limit: evaluation.limit });
29969
+ return evaluation.limitReached ? this._text("limitReached", { limit: evaluation.limit }) : "";
29970
+ }
29971
+ /**
29972
+ * Resolves one localizable string.
29973
+ * @param {keyof typeof DEFAULT_LABELS|string} key Label key.
29974
+ * @param {Record<string, unknown>} [args] Placeholder values.
29975
+ * @returns {string} Resolved text.
29976
+ */
29977
+ _text(key, args) {
29978
+ const overrides = this._kanbanOptions().labels;
29979
+ const source = overrides && typeof overrides === "object" && key in overrides ? overrides[key] : (
29980
+ /** @type {Record<string,string>} */
29981
+ DEFAULT_LABELS[key]
29982
+ );
29983
+ return printf(source == null ? String(key) : String(source), args);
29984
+ }
28497
29985
  /** @param {string} message @returns {void} */
28498
29986
  _announce(message) {
28499
29987
  const token = ++this._announcementToken;
@@ -28533,6 +30021,9 @@ var zx = (() => {
28533
30021
  if (source.accept != null && typeof source.accept !== "function") {
28534
30022
  throw new TypeError(`Kanban ${axis} ${id} accept must be a function`);
28535
30023
  }
30024
+ if (source.from != null && !Array.isArray(source.from)) {
30025
+ throw new TypeError(`Kanban ${axis} ${id} from must be an array or null`);
30026
+ }
28536
30027
  seen.add(id);
28537
30028
  const normalized = (
28538
30029
  /** @type {Record<string, any>} */
@@ -28540,11 +30031,21 @@ var zx = (() => {
28540
30031
  ...source,
28541
30032
  id,
28542
30033
  label: String(source.label ?? axisLabel(source.id)),
30034
+ limit: normalizeLimit2(source.limit),
30035
+ from: Array.isArray(source.from) ? source.from.map(String) : null,
28543
30036
  ...Object.prototype.hasOwnProperty.call(source, "value") ? {} : { value: source.id }
28544
30037
  }
28545
30038
  );
28546
- if (axis === "column") normalized.limit = normalizeLimit2(source.limit);
28547
- else delete normalized.limit;
30039
+ if (axis === "column") {
30040
+ const laneLimits = normalizeLaneLimits(source.laneLimits);
30041
+ if (laneLimits) normalized.laneLimits = laneLimits;
30042
+ if (source.wipPolicy === "block" || source.wipPolicy === "warn") {
30043
+ normalized.wipPolicy = source.wipPolicy;
30044
+ }
30045
+ } else {
30046
+ delete normalized.laneLimits;
30047
+ delete normalized.wipPolicy;
30048
+ }
28548
30049
  return normalized;
28549
30050
  })
28550
30051
  );
@@ -28567,37 +30068,11 @@ var zx = (() => {
28567
30068
  const preferred = normalizeOrder(order).filter((id) => allowed.has(id));
28568
30069
  return [...new Set(preferred), ...available.filter((id) => !preferred.includes(id))];
28569
30070
  }
28570
- function reorderKanbanData(records, id, movedRecord, options) {
28571
- if (!Array.isArray(records) || !movedRecord || typeof movedRecord !== "object") return null;
28572
- const sourceIndex = records.findIndex((record) => Object.is(viewRecordId(record, options.recordId), id));
28573
- if (sourceIndex < 0) return null;
28574
- const source = records[sourceIndex];
28575
- const sourceColumn = normalizeAxisId(readKanbanAccessor(source, sourceIndex, options.columnBy));
28576
- const sourceLane = options.swimlaneBy == null ? null : normalizeAxisId(readKanbanAccessor(source, sourceIndex, options.swimlaneBy));
28577
- const sourceGroup = records.filter((record, index) => normalizeAxisId(readKanbanAccessor(record, index, options.columnBy)) === sourceColumn && (options.swimlaneBy == null || normalizeAxisId(readKanbanAccessor(record, index, options.swimlaneBy)) === sourceLane));
28578
- const from = {
28579
- column: sourceColumn,
28580
- lane: sourceLane,
28581
- index: sourceGroup.findIndex((record) => Object.is(viewRecordId(record, options.recordId), id))
28582
- };
28583
- const next = records.filter((_, index) => index !== sourceIndex);
28584
- 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));
28585
- const targetIndex = normalizeMoveIndex(options.destination.index, destinationRecords.length);
28586
- let insertion = next.length;
28587
- if (destinationRecords[targetIndex]) insertion = next.indexOf(destinationRecords[targetIndex]);
28588
- else if (destinationRecords.length) insertion = next.indexOf(destinationRecords.at(-1)) + 1;
28589
- next.splice(insertion, 0, movedRecord);
28590
- return {
28591
- records: next,
28592
- from,
28593
- to: { column: options.destination.column, lane: options.swimlaneBy == null ? null : options.destination.lane, index: targetIndex }
28594
- };
28595
- }
28596
- function readKanbanAccessor(record, index, accessor) {
28597
- return typeof accessor === "function" ? accessor(record, index) : accessor == null ? null : record?.[accessor];
28598
- }
28599
- function normalizeAxisId(value) {
28600
- return value == null ? "" : String(value);
30071
+ function reconcileKanbanPreference(descriptors, order, preserveUnknown = false) {
30072
+ if (!preserveUnknown) return reconcileKanbanOrder(descriptors, order);
30073
+ const preferred = normalizeOrder(order);
30074
+ const available = descriptors.map((descriptor) => descriptor.id);
30075
+ return [...preferred, ...available.filter((id) => !preferred.includes(id))];
28601
30076
  }
28602
30077
  function axisLabel(value) {
28603
30078
  if (value == null || String(value).trim() === "") return "Unassigned";
@@ -28609,13 +30084,42 @@ var zx = (() => {
28609
30084
  if (!Number.isFinite(number2) || number2 < 0) throw new TypeError("Kanban column limit must be a non-negative number");
28610
30085
  return Math.floor(number2);
28611
30086
  }
28612
- function normalizeMoveIndex(value, maximum) {
28613
- if (value == null || !Number.isFinite(Number(value))) return maximum;
28614
- return Math.max(0, Math.min(maximum, Math.trunc(Number(value))));
30087
+ function normalizeLaneLimits(value) {
30088
+ if (value == null) return void 0;
30089
+ if (typeof value !== "object" || Array.isArray(value)) {
30090
+ throw new TypeError("Kanban column laneLimits must be an object");
30091
+ }
30092
+ const result = {};
30093
+ for (const [lane, limit] of Object.entries(
30094
+ /** @type {Record<string, unknown>} */
30095
+ value
30096
+ )) {
30097
+ const normalized = normalizeLimit2(limit);
30098
+ if (normalized != null) result[lane] = normalized;
30099
+ }
30100
+ return result;
30101
+ }
30102
+ function readColumnLaneLimit(column, lane) {
30103
+ const limits = column.laneLimits;
30104
+ if (!limits || typeof limits !== "object") return null;
30105
+ const value = limits[lane];
30106
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
30107
+ }
30108
+ function wipState(count2, limit) {
30109
+ if (limit == null) return "none";
30110
+ return count2 > limit ? "exceeded" : count2 === limit ? "limit" : "within";
28615
30111
  }
28616
30112
  function normalizeOrder(value) {
28617
30113
  return Array.isArray(value) ? [...new Set(value.map(String))] : [];
28618
30114
  }
30115
+ function stringifyValue(value) {
30116
+ if (value == null) return "";
30117
+ if (typeof value === "object") return "nodeType" in value ? String(
30118
+ /** @type {Node} */
30119
+ value.textContent ?? ""
30120
+ ) : "";
30121
+ return String(value);
30122
+ }
28619
30123
  function orderDescriptors(descriptors, order) {
28620
30124
  const byId = new Map(descriptors.map((descriptor) => [descriptor.id, descriptor]));
28621
30125
  return order.map((id) => byId.get(id)).filter(Boolean);
@@ -28629,12 +30133,28 @@ var zx = (() => {
28629
30133
  next.splice(target + (position2 === "after" ? 1 : 0), 0, String(id));
28630
30134
  return next;
28631
30135
  }
30136
+ function mergeKanbanResolvedOrder(preference, descriptors, resolvedOrder, preserveUnknown) {
30137
+ if (!preserveUnknown) return [...resolvedOrder];
30138
+ const available = new Set(descriptors.map((descriptor) => descriptor.id));
30139
+ const replacements = [...resolvedOrder];
30140
+ let cursor = 0;
30141
+ const merged = normalizeOrder(preference).flatMap((id) => {
30142
+ if (!available.has(id)) return [id];
30143
+ return cursor < replacements.length ? [replacements[cursor++]] : [];
30144
+ });
30145
+ merged.push(...replacements.slice(cursor));
30146
+ return normalizeOrder(merged);
30147
+ }
28632
30148
  function sameMovePoint(left, right) {
28633
30149
  return left.column === right.column && left.lane === right.lane && left.index === right.index;
28634
30150
  }
28635
30151
  function sameArray3(left, right) {
28636
30152
  return left.length === right.length && left.every((value, index) => Object.is(value, right[index]));
28637
30153
  }
30154
+ function hasId(haystack, needle) {
30155
+ for (const candidate of haystack) if (Object.is(candidate, needle)) return true;
30156
+ return false;
30157
+ }
28638
30158
  function elementTarget(event) {
28639
30159
  const target = event.target;
28640
30160
  if (!target) return null;