hazo_ui 6.3.1 → 6.4.0

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.
package/CHANGE_LOG.md CHANGED
@@ -5,6 +5,48 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## v6.4.0 (2026-08-01)
9
+
10
+ ### Added — in-column sections for `HazoUiKanban`
11
+
12
+ A kanban column can now declare `sections`, splitting its body into
13
+ separator-headed bands so a board can group on two axes at once (e.g. status
14
+ across, tag down) without a mode toggle between them.
15
+
16
+ - `KanbanColumn.sections?: KanbanSection[]` — `{ key, title, accepts?,
17
+ collapseWhenEmpty? }`. Cards are placed by the new
18
+ `KanbanItem.sectionKey`. Omit `sections` and nothing changes: existing
19
+ boards render exactly as before.
20
+ - Each band is its own droppable, so a single drop expresses both column and
21
+ section. `KanbanMoveEvent` gained `fromSection`/`toSection`, and
22
+ `KanbanReorderEvent` gained `sectionKey`.
23
+ - Move-vs-reorder is now decided on both axes — same column *and* same
24
+ section is a reorder; changing either is a move. Unsectioned boards have
25
+ `undefined` on both sides, so this collapses to the previous behaviour.
26
+ - On a sectioned column the **header becomes a droppable** carrying the
27
+ column but no section — the status-only drop target ("move column, keep
28
+ section"). Required, not cosmetic: section bands cover the column body and
29
+ win every `closestCorners` contest, so without it a sectioned column has no
30
+ reachable spot that isn't a band. Unsectioned columns keep an inert header.
31
+ - `collapseWhenEmpty` renders a section with no cards as a thin dashed drop
32
+ strip at the foot of the column. This is what makes "declare every section
33
+ in every column" practical for a long vocabulary: every section stays a
34
+ drop target without filling the column with empty headers.
35
+ - The optimistic overlay tracks the destination section too, so a
36
+ same-column cross-section drop moves immediately rather than snapping back
37
+ until the consumer's async commit lands.
38
+ - Dropping on a column's own padding leaves the card's section alone; a card
39
+ whose `sectionKey` matches no declared section renders in an unlabelled
40
+ trailing group rather than disappearing.
41
+
42
+ New styling hooks (no new CSS variables): `cls_hazo_kanban_section`,
43
+ `_section_head`, `_section_title`, `_section_rule`, `_section_count`,
44
+ `_section_body`, `_section_empty`, `_section_over`, `_section_strips`,
45
+ `cls_hazo_kanban_column_head_over`,
46
+ `_section_strip`, `_section_strip_over`, `_section_unmatched`.
47
+
48
+ Demo: test-app `/kanban` §10.
49
+
8
50
  ## v6.3.1 (2026-07-29)
9
51
 
10
52
  ### Fixed — test-app builds again
package/dist/index.cjs CHANGED
@@ -9089,7 +9089,13 @@ function KanbanCard({
9089
9089
  transform,
9090
9090
  transition,
9091
9091
  isDragging
9092
- } = sortable.useSortable({ id: item.id, data: { columnKey: item.columnKey } });
9092
+ } = sortable.useSortable({
9093
+ id: item.id,
9094
+ // sectionKey rides along so a drop *onto a card* resolves to that card's
9095
+ // section, not just its column (kanban_column.tsx's section bands cover
9096
+ // the gaps between cards, but the cards themselves are the sortable).
9097
+ data: { columnKey: item.columnKey, sectionKey: item.sectionKey }
9098
+ });
9093
9099
  const priority_var = item.priority ? `--hazo-kanban-priority-${String(item.priority).toLowerCase()}` : null;
9094
9100
  const style = {
9095
9101
  transform: utilities.CSS.Transform.toString(transform),
@@ -9149,6 +9155,20 @@ function KanbanCard({
9149
9155
  }
9150
9156
  );
9151
9157
  }
9158
+ function section_droppable_id(column_key, section_key) {
9159
+ return `section:${column_key}::${section_key}`;
9160
+ }
9161
+ function group_items_by_section(items, sections) {
9162
+ const bySection = /* @__PURE__ */ new Map();
9163
+ for (const section of sections) bySection.set(section.key, []);
9164
+ const unmatched = [];
9165
+ for (const item of items) {
9166
+ const bucket = item.sectionKey != null ? bySection.get(item.sectionKey) : void 0;
9167
+ if (bucket) bucket.push(item);
9168
+ else unmatched.push(item);
9169
+ }
9170
+ return { bySection, unmatched };
9171
+ }
9152
9172
  function KanbanColumn({
9153
9173
  column,
9154
9174
  items,
@@ -9162,6 +9182,37 @@ function KanbanColumn({
9162
9182
  id: `column:${column.key}`,
9163
9183
  data: { columnKey: column.key, isColumn: true }
9164
9184
  });
9185
+ const sections = column.sections;
9186
+ const { bySection, unmatched } = React27__namespace.useMemo(
9187
+ () => sections ? group_items_by_section(items, sections) : { bySection: /* @__PURE__ */ new Map(), unmatched: items },
9188
+ [items, sections]
9189
+ );
9190
+ const ordered = React27__namespace.useMemo(() => {
9191
+ if (!sections) return items;
9192
+ const out = [];
9193
+ for (const section of sections) out.push(...bySection.get(section.key) ?? []);
9194
+ out.push(...unmatched);
9195
+ return out;
9196
+ }, [items, sections, bySection, unmatched]);
9197
+ function render_cards(list) {
9198
+ return list.map((item) => /* @__PURE__ */ jsxRuntime.jsx(
9199
+ KanbanCard,
9200
+ {
9201
+ item,
9202
+ renderCard,
9203
+ cardClassName,
9204
+ showEdit,
9205
+ onEditRequest
9206
+ },
9207
+ item.id
9208
+ ));
9209
+ }
9210
+ const expanded_sections = (sections ?? []).filter(
9211
+ (s) => !(s.collapseWhenEmpty && (bySection.get(s.key) ?? []).length === 0)
9212
+ );
9213
+ const collapsed_sections = (sections ?? []).filter(
9214
+ (s) => s.collapseWhenEmpty && (bySection.get(s.key) ?? []).length === 0
9215
+ );
9165
9216
  return /* @__PURE__ */ jsxRuntime.jsxs(
9166
9217
  "div",
9167
9218
  {
@@ -9171,32 +9222,125 @@ function KanbanColumn({
9171
9222
  isOver && "cls_hazo_kanban_column_over bg-accent/30 ring-2 ring-ring/40"
9172
9223
  ),
9173
9224
  children: [
9174
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cls_hazo_kanban_column_head flex items-center justify-between mb-2 px-1 text-xs uppercase tracking-wider text-muted-foreground", children: [
9175
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cls_hazo_kanban_column_title", children: column.title }),
9176
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cls_hazo_kanban_column_count rounded-full bg-muted px-2 py-0.5 text-foreground", children: items.length })
9177
- ] }),
9225
+ /* @__PURE__ */ jsxRuntime.jsx(
9226
+ KanbanColumnHead,
9227
+ {
9228
+ columnKey: column.key,
9229
+ title: column.title,
9230
+ count: items.length,
9231
+ droppable: sections != null
9232
+ }
9233
+ ),
9178
9234
  /* @__PURE__ */ jsxRuntime.jsx(
9179
9235
  sortable.SortableContext,
9180
9236
  {
9181
- items: items.map((i) => i.id),
9237
+ items: ordered.map((i) => i.id),
9182
9238
  strategy: sortable.verticalListSortingStrategy,
9183
- children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cls_hazo_kanban_column_body flex-1 min-h-[40px]", children: items.length === 0 ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cls_hazo_kanban_column_empty text-xs text-muted-foreground py-2 px-1", children: emptyColumn }) : items.map((item) => /* @__PURE__ */ jsxRuntime.jsx(
9184
- KanbanCard,
9185
- {
9186
- item,
9187
- renderCard,
9188
- cardClassName,
9189
- showEdit,
9190
- onEditRequest
9191
- },
9192
- item.id
9193
- )) })
9239
+ children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cls_hazo_kanban_column_body flex-1 min-h-[40px]", children: !sections ? items.length === 0 ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cls_hazo_kanban_column_empty text-xs text-muted-foreground py-2 px-1", children: emptyColumn }) : render_cards(items) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
9240
+ expanded_sections.map((section) => /* @__PURE__ */ jsxRuntime.jsx(
9241
+ KanbanSectionBand,
9242
+ {
9243
+ columnKey: column.key,
9244
+ section,
9245
+ items: bySection.get(section.key) ?? [],
9246
+ emptyColumn,
9247
+ renderCards: render_cards
9248
+ },
9249
+ section.key
9250
+ )),
9251
+ unmatched.length > 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cls_hazo_kanban_section cls_hazo_kanban_section_unmatched", children: render_cards(unmatched) }),
9252
+ collapsed_sections.length > 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cls_hazo_kanban_section_strips mt-2 flex flex-wrap gap-1 border-t border-dashed border-border pt-2", children: collapsed_sections.map((section) => /* @__PURE__ */ jsxRuntime.jsx(
9253
+ KanbanSectionStrip,
9254
+ {
9255
+ columnKey: column.key,
9256
+ section
9257
+ },
9258
+ section.key
9259
+ )) })
9260
+ ] }) })
9194
9261
  }
9195
9262
  )
9196
9263
  ]
9197
9264
  }
9198
9265
  );
9199
9266
  }
9267
+ function KanbanColumnHead({
9268
+ columnKey,
9269
+ title,
9270
+ count,
9271
+ droppable
9272
+ }) {
9273
+ const { setNodeRef, isOver } = core$1.useDroppable({
9274
+ id: `columnhead:${columnKey}`,
9275
+ data: { columnKey, isColumn: true },
9276
+ disabled: !droppable
9277
+ });
9278
+ return /* @__PURE__ */ jsxRuntime.jsxs(
9279
+ "div",
9280
+ {
9281
+ ref: droppable ? setNodeRef : void 0,
9282
+ className: cn(
9283
+ "cls_hazo_kanban_column_head mb-2 flex items-center justify-between rounded-sm px-1 text-xs uppercase tracking-wider text-muted-foreground",
9284
+ droppable && "py-1",
9285
+ isOver && "cls_hazo_kanban_column_head_over bg-accent/30 text-foreground ring-1 ring-ring/40"
9286
+ ),
9287
+ children: [
9288
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cls_hazo_kanban_column_title", children: title }),
9289
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cls_hazo_kanban_column_count rounded-full bg-muted px-2 py-0.5 text-foreground", children: count })
9290
+ ]
9291
+ }
9292
+ );
9293
+ }
9294
+ function KanbanSectionBand({
9295
+ columnKey,
9296
+ section,
9297
+ items,
9298
+ emptyColumn,
9299
+ renderCards
9300
+ }) {
9301
+ const { setNodeRef, isOver } = core$1.useDroppable({
9302
+ id: section_droppable_id(columnKey, section.key),
9303
+ data: { columnKey, sectionKey: section.key, isSection: true }
9304
+ });
9305
+ return /* @__PURE__ */ jsxRuntime.jsxs(
9306
+ "div",
9307
+ {
9308
+ ref: setNodeRef,
9309
+ className: cn(
9310
+ "cls_hazo_kanban_section rounded-sm",
9311
+ isOver && "cls_hazo_kanban_section_over bg-accent/20 ring-1 ring-ring/30"
9312
+ ),
9313
+ children: [
9314
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cls_hazo_kanban_section_head mt-2 mb-1 flex items-center gap-2 px-1 first:mt-0", children: [
9315
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cls_hazo_kanban_section_title text-[11px] font-medium uppercase tracking-wider text-muted-foreground", children: section.title }),
9316
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cls_hazo_kanban_section_rule h-px flex-1 bg-border" }),
9317
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cls_hazo_kanban_section_count text-[11px] tabular-nums text-muted-foreground", children: items.length })
9318
+ ] }),
9319
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cls_hazo_kanban_section_body min-h-[32px]", children: items.length === 0 ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cls_hazo_kanban_section_empty px-1 py-1 text-xs text-muted-foreground", children: emptyColumn }) : renderCards(items) })
9320
+ ]
9321
+ }
9322
+ );
9323
+ }
9324
+ function KanbanSectionStrip({
9325
+ columnKey,
9326
+ section
9327
+ }) {
9328
+ const { setNodeRef, isOver } = core$1.useDroppable({
9329
+ id: section_droppable_id(columnKey, section.key),
9330
+ data: { columnKey, sectionKey: section.key, isSection: true }
9331
+ });
9332
+ return /* @__PURE__ */ jsxRuntime.jsx(
9333
+ "div",
9334
+ {
9335
+ ref: setNodeRef,
9336
+ className: cn(
9337
+ "cls_hazo_kanban_section_strip rounded-full border border-dashed border-border px-2 py-0.5 text-[11px] text-muted-foreground",
9338
+ isOver && "cls_hazo_kanban_section_strip_over border-solid bg-accent/30 text-foreground ring-1 ring-ring/30"
9339
+ ),
9340
+ children: section.title
9341
+ }
9342
+ );
9343
+ }
9200
9344
  var DEFAULT_PRIORITIES = ["P0", "P1", "P2", "P3"];
9201
9345
  function humanize(key) {
9202
9346
  return key.replace(/[_\-]+/g, " ").replace(/([a-z])([A-Z])/g, "$1 $2").replace(/\s+/g, " ").trim().split(" ").map((w) => w ? w[0].toUpperCase() + w.slice(1) : w).join(" ");
@@ -9621,7 +9765,8 @@ function group_items_by_column(items, overlay, columns) {
9621
9765
  if (ov) {
9622
9766
  const bucket = overlay_inserts.get(ov.columnKey);
9623
9767
  if (bucket) {
9624
- bucket.push({ item, index: ov.index });
9768
+ const placed = ov.sectionKey === item.sectionKey ? item : { ...item, sectionKey: ov.sectionKey };
9769
+ bucket.push({ item: placed, index: ov.index });
9625
9770
  }
9626
9771
  } else {
9627
9772
  const bucket = out.get(item.columnKey);
@@ -9689,7 +9834,7 @@ function HazoUiKanban({
9689
9834
  const next = new Map(prev);
9690
9835
  for (const [item_id, pos] of prev.entries()) {
9691
9836
  const item = items.find((i) => i.id === item_id);
9692
- if (item && item.columnKey === pos.columnKey) {
9837
+ if (item && item.columnKey === pos.columnKey && item.sectionKey === pos.sectionKey) {
9693
9838
  next.delete(item_id);
9694
9839
  changed = true;
9695
9840
  }
@@ -9755,7 +9900,8 @@ function HazoUiKanban({
9755
9900
  const over_col_key = over ? over.data?.current?.columnKey ?? String(over.id).replace(/^column:/, "") : null;
9756
9901
  const to_col = over_col_key ? columns.find((c) => c.key === over_col_key) ?? from_col : from_col;
9757
9902
  const target = over_col_key ? grouped.get(over_col_key) ?? [] : grouped.get(item.columnKey) ?? [];
9758
- const new_index = over && String(over.id) !== `column:${over_col_key}` ? target.findIndex((it) => it.id === String(over.id)) : target.length;
9903
+ const on_shell = !over || String(over.id).startsWith("column:") || String(over.id).startsWith("columnhead:") || String(over.id).startsWith("section:");
9904
+ const new_index = on_shell ? target.length : target.findIndex((it) => it.id === String(over.id));
9759
9905
  return merged_announcements.onDragEnd({
9760
9906
  item,
9761
9907
  fromColumn: from_col?.title ?? item.columnKey,
@@ -9804,22 +9950,28 @@ function HazoUiKanban({
9804
9950
  const dest_column_key = over_data?.columnKey ?? over_id.replace(/^column:/, "");
9805
9951
  const dest_column = columns.find((c) => c.key === dest_column_key);
9806
9952
  if (!dest_column) return;
9953
+ const dest_section_key = dest_column.sections ? over_data?.sectionKey ?? item.sectionKey : void 0;
9954
+ const dest_section = dest_column.sections?.find(
9955
+ (s) => s.key === dest_section_key
9956
+ );
9807
9957
  if (dest_column.accepts && !dest_column.accepts(item)) return;
9958
+ if (dest_section?.accepts && !dest_section.accepts(item)) return;
9808
9959
  const dest_items_pre_overlay = grouped.get(dest_column_key) ?? [];
9809
9960
  let new_index;
9810
- if (over_data?.isColumn) {
9961
+ if (over_data?.isColumn || over_data?.isSection) {
9811
9962
  new_index = dest_items_pre_overlay.length;
9812
9963
  } else {
9813
9964
  const idx = dest_items_pre_overlay.findIndex((i) => i.id === over_id);
9814
9965
  new_index = idx === -1 ? dest_items_pre_overlay.length : idx;
9815
9966
  }
9816
9967
  const handle = make_revert_handle(active_id_str);
9817
- if (item.columnKey === dest_column_key) {
9968
+ if (item.columnKey === dest_column_key && item.sectionKey === dest_section_key) {
9818
9969
  if (active_id_str === over_id) return;
9819
9970
  set_overlay((prev) => {
9820
9971
  const next = new Map(prev);
9821
9972
  next.set(active_id_str, {
9822
9973
  columnKey: dest_column_key,
9974
+ sectionKey: dest_section_key,
9823
9975
  index: new_index
9824
9976
  });
9825
9977
  return next;
@@ -9828,6 +9980,7 @@ function HazoUiKanban({
9828
9980
  itemId: active_id_str,
9829
9981
  item,
9830
9982
  columnKey: dest_column_key,
9983
+ sectionKey: dest_section_key,
9831
9984
  newIndex: new_index,
9832
9985
  revert: handle.revert
9833
9986
  };
@@ -9838,6 +9991,7 @@ function HazoUiKanban({
9838
9991
  const next = new Map(prev);
9839
9992
  next.set(active_id_str, {
9840
9993
  columnKey: dest_column_key,
9994
+ sectionKey: dest_section_key,
9841
9995
  index: new_index
9842
9996
  });
9843
9997
  return next;
@@ -9847,6 +10001,8 @@ function HazoUiKanban({
9847
10001
  item,
9848
10002
  fromColumn: item.columnKey,
9849
10003
  toColumn: dest_column_key,
10004
+ fromSection: item.sectionKey,
10005
+ toSection: dest_section_key,
9850
10006
  newIndex: new_index,
9851
10007
  revert: handle.revert
9852
10008
  };