hazo_ui 6.5.0 → 6.8.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,136 @@ 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.8.0 (2026-08-03)
9
+
10
+ ### Added — text prefixes: `PrefixConfig.insert_as_text` + `CommandItem.insert_text`
11
+
12
+ A prefix can now be a snippet menu rather than an entity reference. With
13
+ `insert_as_text: true`, picking a command inserts plain editable characters
14
+ (`CommandItem.insert_text`, falling back to `action`) and consumes the trigger
15
+ character with the query — no pill node, no `InsertedCommand`, nothing for the
16
+ consumer to strip back out.
17
+
18
+ ```tsx
19
+ <HazoUiTextbox
20
+ prefixes={[
21
+ { char: "@", commands: variables }, // pills, unchanged
22
+ {
23
+ char: "/",
24
+ insert_as_text: true,
25
+ commands: [{ action: "SUM", action_label: "SUM(column)", insert_text: "SUM(" }],
26
+ },
27
+ ]}
28
+ />
29
+ ```
30
+
31
+ Motivated by hazo_collab_forms' formula editor, where `/` offers the grammar's
32
+ functions: what lands in the text has to be `SUM(`, valid formula source, not a
33
+ `/SUM` pill.
34
+
35
+ `parse_commands_from_text` (and therefore `text_to_tiptap_content`) now skips
36
+ text prefixes entirely. Without that, a controlled-value sync would re-read
37
+ `a/SUM(b)` and turn the division into a `/SUM` pill. Both `HazoUiTextbox` and
38
+ `HazoUiTextarea` honour the flag. New export: `insert_text_at_position`.
39
+
40
+ ## v6.7.1 (2026-08-03)
41
+
42
+ ### Fixed — `HazoUiTextbox` no longer warns "flushSync was called from inside a lifecycle method"
43
+
44
+ The controlled-value sync effect called `editor.commands.setContent(...)`
45
+ synchronously. That dispatches a ProseMirror transaction, and `@tiptap/react`
46
+ flushes the resulting re-render with `flushSync` — which, when the effect runs
47
+ while React is still rendering the owning tree, React reports as
48
+
49
+ ```
50
+ flushSync was called from inside a lifecycle method. React cannot flush when
51
+ React is already rendering.
52
+ ```
53
+
54
+ and then discards. Reported from a consumer that renders `HazoUiTextbox`
55
+ inside a large form tree (hazo_collab_forms' `TransformEditor`).
56
+
57
+ The write is now deferred to a `queueMicrotask` — after the commit, before
58
+ paint. The guard conditions (`isFocused`, value-still-differs, plus a new
59
+ `isDestroyed` check) are re-evaluated inside the microtask, because focus or
60
+ the controlled value can move between the effect and the flush. Behaviour is
61
+ otherwise unchanged; a cancelled/unmounted effect drops the pending write.
62
+
63
+ ## v6.7.0 (2026-08-02)
64
+
65
+ ### Added — `data-column-key` / `data-lane-key` on the kanban DOM
66
+
67
+ `cls_hazo_kanban_column` is one class shared by every column, so a consumer
68
+ whose columns carry meaning — an inbox is not a done pile — had no way to
69
+ style one differently. The only lever available was `:nth-child`, which
70
+ silently mis-paints the moment columns are reordered, filtered, or a lane
71
+ renders a subset. These attributes make the identity the component already
72
+ knows about addressable from CSS:
73
+
74
+ | Element | Attributes |
75
+ |---|---|
76
+ | `.cls_hazo_kanban_column` | `data-column-key`, `data-lane-key` |
77
+ | `.cls_hazo_kanban_column_head` | `data-column-key`, `data-lane-key` |
78
+ | `.cls_hazo_kanban_column_body` | `data-column-key`, `data-lane-key` |
79
+ | `.cls_hazo_kanban_card` | `data-column-key`, `data-lane-key`, `data-section-key` |
80
+
81
+ `data-lane-key` is absent (not empty) on an unlaned board, so
82
+ `[data-lane-key]` is a usable "is this board laned" test. Both layout trees
83
+ always render — pair with the ancestor `.cls_hazo_kanban_desktop` /
84
+ `.cls_hazo_kanban_mobile` when a rule must apply to only one.
85
+
86
+ Purely additive: no class, layout, or default paint changed.
87
+
88
+ ### Added — `--hazo-kanban-card-shadow`
89
+
90
+ Cards drew `background: hsl(var(--hazo-kanban-card-bg))` with a 1px border
91
+ and nothing else. Under any palette where `--card` and `--background` resolve
92
+ to the same value (the shadcn-derived default in `hazo_theme`, in both light
93
+ and dark), that border is the *only* thing separating a card from the column
94
+ behind it, and the board reads as flat.
95
+
96
+ The card's `box-shadow` now resolves `var(--hazo-kanban-card-shadow, none)`.
97
+ The fallback is `none`, so a consumer that never declares the token renders
98
+ byte-identically to v6.6.0. Set it (e.g. to `var(--shadow-sm)`) to give cards
99
+ a real lift.
100
+
101
+ ## v6.6.0 (2026-08-02)
102
+
103
+ ### Added — consumer slots on a laned `HazoUiKanban`
104
+
105
+ v6.5.0 gave the board a second, outer axis but no way to hang anything off
106
+ it. A lane could be dropped into and counted, and that was all — there was no
107
+ supported place to put "add an item to this lane", a per-lane overflow menu,
108
+ or an inline composer at the foot of a column. Both slots below are additive
109
+ and default to rendering nothing, so existing boards are byte-identical.
110
+
111
+ - **`KanbanLane.actions?: React.ReactNode`** — rendered in the lane heading
112
+ between the rule and the count, and right-aligned on a `collapseWhenEmpty`
113
+ lane's drop strip so a lane's controls do not vanish the moment it empties.
114
+ No `stopPropagation` is needed on controls placed here: a lane heading is a
115
+ `useDroppable` node, which registers a rect but attaches no pointer
116
+ handlers, unlike the `useSortable` card shell.
117
+ - **`HazoUiKanbanProps.renderColumnFooter?: (ctx) => React.ReactNode`** —
118
+ rendered under a column body, below the cards or the `emptyColumn`
119
+ placeholder. `ctx` is `{ columnKey, laneKey }` and the function is called
120
+ once per rendered copy of a column, so on a laned board it fires once per
121
+ (lane, column) pair — enough to build a composer scoped to one lane's
122
+ Inbox. Return `null` for the columns that should not have one.
123
+ - The footer sits inside the column's droppable but **outside** its
124
+ `SortableContext`, so it never joins the sort order or becomes a drop
125
+ target of its own.
126
+ - The unmatched-lane row is deliberately skipped: its lane key is the
127
+ private `__hazo_lane_unmatched__` sentinel, never a lane the consumer
128
+ declared, so a footer there would both leak an internal constant and ask
129
+ for controls scoped to a lane that does not exist.
130
+
131
+ New class hooks: `cls_hazo_kanban_lane_actions`,
132
+ `cls_hazo_kanban_column_footer`. The lane strip's title is now wrapped in its
133
+ own `cls_hazo_kanban_lane_title` span (it was a bare text node) so the
134
+ right-aligned actions have something to sit beside.
135
+
136
+ See `test-app/app/kanban/` §12.
137
+
8
138
  ## v6.5.0 (2026-08-02)
9
139
 
10
140
  ### Added — swimlanes for `HazoUiKanban`
package/dist/index.cjs CHANGED
@@ -5655,6 +5655,9 @@ var insert_command_at_position = (editor, command, prefix, range, variant = "def
5655
5655
  color: color2
5656
5656
  }).run();
5657
5657
  };
5658
+ var insert_text_at_position = (editor, command, range) => {
5659
+ editor.chain().focus().deleteRange(range).insertContent(command.insert_text ?? command.action).run();
5660
+ };
5658
5661
  var get_variant_classes2 = (variant = "default") => {
5659
5662
  switch (variant) {
5660
5663
  case "outline":
@@ -5857,7 +5860,9 @@ var CommandPopover = ({
5857
5860
  CommandPopover.displayName = "CommandPopover";
5858
5861
 
5859
5862
  // src/components/hazo_ui_command/index.tsx
5860
- var parse_commands_from_text = (text, prefixes) => {
5863
+ var parse_commands_from_text = (text, all_prefixes) => {
5864
+ const prefixes = all_prefixes.filter((p) => !p.insert_as_text);
5865
+ if (prefixes.length === 0) return [];
5861
5866
  const result = [];
5862
5867
  const action_to_label = /* @__PURE__ */ new Map();
5863
5868
  for (const prefix_config of prefixes) {
@@ -6167,13 +6172,18 @@ var HazoUiTextbox = ({
6167
6172
  }
6168
6173
  }, [suggestion_state, editor]);
6169
6174
  React27__namespace.useEffect(() => {
6170
- if (is_controlled && editor && !editor.isFocused) {
6171
- const current_text = editor.getText();
6172
- if (value !== current_text) {
6173
- const content = text_to_tiptap_content(value || "", prefixes, pill_variant);
6174
- editor.commands.setContent(content, { emitUpdate: false });
6175
- }
6176
- }
6175
+ if (!is_controlled || !editor || editor.isFocused) return;
6176
+ if (value === editor.getText()) return;
6177
+ let cancelled = false;
6178
+ queueMicrotask(() => {
6179
+ if (cancelled || editor.isDestroyed || editor.isFocused) return;
6180
+ if (value === editor.getText()) return;
6181
+ const content = text_to_tiptap_content(value || "", prefixes, pill_variant);
6182
+ editor.commands.setContent(content, { emitUpdate: false });
6183
+ });
6184
+ return () => {
6185
+ cancelled = true;
6186
+ };
6177
6187
  }, [value, editor, is_controlled, prefixes, pill_variant]);
6178
6188
  React27__namespace.useEffect(() => {
6179
6189
  if (editor) {
@@ -6185,6 +6195,11 @@ var HazoUiTextbox = ({
6185
6195
  if (!editor || !suggestion_state) return;
6186
6196
  const prefix_config = prefixes.find((p) => p.char === suggestion_state.prefix);
6187
6197
  const prefix_color = prefix_config?.color;
6198
+ if (prefix_config?.insert_as_text) {
6199
+ insert_text_at_position(editor, command, suggestion_state.range);
6200
+ set_suggestion_state(null);
6201
+ return;
6202
+ }
6188
6203
  insert_command_at_position(
6189
6204
  editor,
6190
6205
  command,
@@ -6735,6 +6750,11 @@ var HazoUiTextarea = ({
6735
6750
  if (!editor || !suggestion_state) return;
6736
6751
  const prefix_config = prefixes.find((p) => p.char === suggestion_state.prefix);
6737
6752
  const prefix_color = prefix_config?.color;
6753
+ if (prefix_config?.insert_as_text) {
6754
+ insert_text_at_position(editor, command, suggestion_state.range);
6755
+ set_suggestion_state(null);
6756
+ return;
6757
+ }
6738
6758
  insert_command_at_position(
6739
6759
  editor,
6740
6760
  command,
@@ -9108,6 +9128,12 @@ function KanbanCard({
9108
9128
  backgroundColor: "hsl(var(--hazo-kanban-card-bg))",
9109
9129
  border: `1px solid hsl(var(--hazo-kanban-card-border))`,
9110
9130
  borderLeft: priority_var ? `4px solid hsl(var(${priority_var}))` : `1px solid hsl(var(--hazo-kanban-card-border))`,
9131
+ // Opt-in lift. A card whose background equals the column's (the case for
9132
+ // every palette where --card and --background resolve to the same value)
9133
+ // is separated by nothing but a 1px border, which reads as flat at a
9134
+ // glance. The fallback is `none`, so a consumer that never sets the token
9135
+ // renders exactly as before.
9136
+ boxShadow: "var(--hazo-kanban-card-shadow, none)",
9111
9137
  opacity: isDragging && !dragging ? 0.4 : 1,
9112
9138
  cursor: "grab"
9113
9139
  };
@@ -9116,6 +9142,9 @@ function KanbanCard({
9116
9142
  {
9117
9143
  ref: setNodeRef,
9118
9144
  style,
9145
+ "data-column-key": item.columnKey,
9146
+ "data-lane-key": item.laneKey,
9147
+ "data-section-key": item.sectionKey,
9119
9148
  className: cn(
9120
9149
  "cls_hazo_kanban_card group relative rounded-md p-3 mb-2 select-none",
9121
9150
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
@@ -9187,6 +9216,7 @@ function KanbanColumn({
9187
9216
  items,
9188
9217
  renderCard,
9189
9218
  emptyColumn,
9219
+ footer,
9190
9220
  cardClassName,
9191
9221
  showEdit,
9192
9222
  onEditRequest,
@@ -9232,6 +9262,8 @@ function KanbanColumn({
9232
9262
  "div",
9233
9263
  {
9234
9264
  ref: setNodeRef,
9265
+ "data-column-key": column.key,
9266
+ "data-lane-key": laneKey,
9235
9267
  className: cn(
9236
9268
  "cls_hazo_kanban_column flex flex-col rounded-md p-2",
9237
9269
  isOver && "cls_hazo_kanban_column_over bg-accent/30 ring-2 ring-ring/40"
@@ -9253,34 +9285,43 @@ function KanbanColumn({
9253
9285
  {
9254
9286
  items: ordered.map((i) => i.id),
9255
9287
  strategy: sortable.verticalListSortingStrategy,
9256
- 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: [
9257
- expanded_sections.map((section) => /* @__PURE__ */ jsxRuntime.jsx(
9258
- KanbanSectionBand,
9259
- {
9260
- columnKey: column.key,
9261
- laneKey,
9262
- treeKey,
9263
- section,
9264
- items: bySection.get(section.key) ?? [],
9265
- emptyColumn,
9266
- renderCards: render_cards
9267
- },
9268
- section.key
9269
- )),
9270
- unmatched.length > 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cls_hazo_kanban_section cls_hazo_kanban_section_unmatched", children: render_cards(unmatched) }),
9271
- 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(
9272
- KanbanSectionStrip,
9273
- {
9274
- columnKey: column.key,
9275
- laneKey,
9276
- treeKey,
9277
- section
9278
- },
9279
- section.key
9280
- )) })
9281
- ] }) })
9288
+ children: /* @__PURE__ */ jsxRuntime.jsx(
9289
+ "div",
9290
+ {
9291
+ "data-column-key": column.key,
9292
+ "data-lane-key": laneKey,
9293
+ className: "cls_hazo_kanban_column_body flex-1 min-h-[40px]",
9294
+ 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: [
9295
+ expanded_sections.map((section) => /* @__PURE__ */ jsxRuntime.jsx(
9296
+ KanbanSectionBand,
9297
+ {
9298
+ columnKey: column.key,
9299
+ laneKey,
9300
+ treeKey,
9301
+ section,
9302
+ items: bySection.get(section.key) ?? [],
9303
+ emptyColumn,
9304
+ renderCards: render_cards
9305
+ },
9306
+ section.key
9307
+ )),
9308
+ unmatched.length > 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cls_hazo_kanban_section cls_hazo_kanban_section_unmatched", children: render_cards(unmatched) }),
9309
+ 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(
9310
+ KanbanSectionStrip,
9311
+ {
9312
+ columnKey: column.key,
9313
+ laneKey,
9314
+ treeKey,
9315
+ section
9316
+ },
9317
+ section.key
9318
+ )) })
9319
+ ] })
9320
+ }
9321
+ )
9282
9322
  }
9283
- )
9323
+ ),
9324
+ footer ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cls_hazo_kanban_column_footer mt-1", children: footer }) : null
9284
9325
  ]
9285
9326
  }
9286
9327
  );
@@ -9302,6 +9343,8 @@ function KanbanColumnHead({
9302
9343
  "div",
9303
9344
  {
9304
9345
  ref: droppable ? setNodeRef : void 0,
9346
+ "data-column-key": columnKey,
9347
+ "data-lane-key": laneKey,
9305
9348
  className: cn(
9306
9349
  "cls_hazo_kanban_column_head mb-2 flex items-center justify-between rounded-sm px-1 text-xs uppercase tracking-wider text-muted-foreground",
9307
9350
  droppable && "py-1",
@@ -9391,6 +9434,7 @@ function KanbanLaneHead({
9391
9434
  children: [
9392
9435
  /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cls_hazo_kanban_lane_title text-xs font-semibold uppercase tracking-wider text-foreground", children: lane.title }),
9393
9436
  /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cls_hazo_kanban_lane_rule h-px flex-1 bg-border" }),
9437
+ lane.actions ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cls_hazo_kanban_lane_actions flex items-center gap-1", children: lane.actions }) : null,
9394
9438
  /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cls_hazo_kanban_lane_count rounded-full bg-muted px-2 py-0.5 text-[11px] tabular-nums text-foreground", children: count })
9395
9439
  ]
9396
9440
  }
@@ -9404,15 +9448,18 @@ function KanbanLaneStrip({
9404
9448
  id: lane_droppable_id(lane.key, treeKey),
9405
9449
  data: { laneKey: lane.key, isLane: true }
9406
9450
  });
9407
- return /* @__PURE__ */ jsxRuntime.jsx(
9451
+ return /* @__PURE__ */ jsxRuntime.jsxs(
9408
9452
  "div",
9409
9453
  {
9410
9454
  ref: setNodeRef,
9411
9455
  className: cn(
9412
- "cls_hazo_kanban_lane_strip mb-2 rounded-md border border-dashed border-border px-2 py-1 text-[11px] uppercase tracking-wider text-muted-foreground",
9456
+ "cls_hazo_kanban_lane_strip mb-2 flex items-center gap-3 rounded-md border border-dashed border-border px-2 py-1 text-[11px] uppercase tracking-wider text-muted-foreground",
9413
9457
  isOver && "cls_hazo_kanban_lane_strip_over border-solid bg-accent/30 text-foreground ring-1 ring-ring/30"
9414
9458
  ),
9415
- children: lane.title
9459
+ children: [
9460
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cls_hazo_kanban_lane_title", children: lane.title }),
9461
+ lane.actions ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cls_hazo_kanban_lane_actions ml-auto flex items-center gap-1", children: lane.actions }) : null
9462
+ ]
9416
9463
  }
9417
9464
  );
9418
9465
  }
@@ -9885,6 +9932,7 @@ function HazoUiKanban({
9885
9932
  mobileBreakpoint = 640,
9886
9933
  announcements,
9887
9934
  emptyColumn = "\u2014",
9935
+ renderColumnFooter,
9888
9936
  className,
9889
9937
  cardClassName,
9890
9938
  itemLabel,
@@ -10138,7 +10186,7 @@ function HazoUiKanban({
10138
10186
  total += (grouped.get(bucket_key(lane_key, col.key)) ?? []).length;
10139
10187
  return total;
10140
10188
  }
10141
- function render_column(col, tree_key, lane_key) {
10189
+ function render_column(col, tree_key, lane_key, with_footer = true) {
10142
10190
  return /* @__PURE__ */ jsxRuntime.jsx(
10143
10191
  KanbanColumn,
10144
10192
  {
@@ -10148,6 +10196,7 @@ function HazoUiKanban({
10148
10196
  items: grouped.get(bucket_key(lane_key, col.key)) ?? [],
10149
10197
  renderCard,
10150
10198
  emptyColumn,
10199
+ footer: with_footer ? renderColumnFooter?.({ columnKey: col.key, laneKey: lane_key }) ?? null : null,
10151
10200
  cardClassName,
10152
10201
  showEdit: show_edit,
10153
10202
  onEditRequest: handle_edit_request
@@ -10168,8 +10217,8 @@ function HazoUiKanban({
10168
10217
  function render_unmatched_lane(tree_key, only_column) {
10169
10218
  const count = only_column ? (grouped.get(bucket_key(UNMATCHED_LANE_KEY, only_column.key)) ?? []).length : lane_total(UNMATCHED_LANE_KEY);
10170
10219
  if (count === 0) return null;
10171
- return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cls_hazo_kanban_lane cls_hazo_kanban_lane_unmatched", children: only_column ? render_column(only_column, tree_key, UNMATCHED_LANE_KEY) : /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cls_hazo_kanban_lane_grid grid gap-3", style: grid_style, children: columns.map(
10172
- (col) => render_column(col, tree_key, UNMATCHED_LANE_KEY)
10220
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cls_hazo_kanban_lane cls_hazo_kanban_lane_unmatched", children: only_column ? render_column(only_column, tree_key, UNMATCHED_LANE_KEY, false) : /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cls_hazo_kanban_lane_grid grid gap-3", style: grid_style, children: columns.map(
10221
+ (col) => render_column(col, tree_key, UNMATCHED_LANE_KEY, false)
10173
10222
  ) }) });
10174
10223
  }
10175
10224
  return /* @__PURE__ */ jsxRuntime.jsxs(