@jackuait/blok 0.10.8 → 0.10.9

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 (41) hide show
  1. package/dist/blok.mjs +2 -2
  2. package/dist/chunks/{blok-ClCrnWuI.mjs → blok-DbRn9adY.mjs} +2454 -2057
  3. package/dist/chunks/{constants-BoE5frJm.mjs → constants-C9lsSOXl.mjs} +4 -3
  4. package/dist/chunks/{tools-HQPJLj5m.mjs → tools-D0W3_dlA.mjs} +502 -497
  5. package/dist/full.mjs +3 -3
  6. package/dist/react.mjs +2 -2
  7. package/dist/tools.mjs +2 -2
  8. package/package.json +3 -6
  9. package/src/components/block/index.ts +36 -0
  10. package/src/components/blocks.ts +191 -5
  11. package/src/components/modules/api/blocks.ts +6 -4
  12. package/src/components/modules/blockEvents/composers/keyboardNavigation.ts +17 -6
  13. package/src/components/modules/blockManager/blockManager.ts +364 -23
  14. package/src/components/modules/blockManager/hierarchy.ts +164 -8
  15. package/src/components/modules/blockManager/operations.ts +223 -26
  16. package/src/components/modules/blockManager/types.ts +13 -1
  17. package/src/components/modules/blockManager/yjs-sync.ts +48 -3
  18. package/src/components/modules/drag/DragController.ts +209 -8
  19. package/src/components/modules/drag/operations/DragOperations.ts +153 -20
  20. package/src/components/modules/paste/handlers/base.ts +48 -20
  21. package/src/components/modules/paste/handlers/blok-data-handler.ts +93 -45
  22. package/src/components/modules/paste/index.ts +20 -0
  23. package/src/components/modules/saver.ts +75 -5
  24. package/src/components/modules/toolbar/index.ts +41 -60
  25. package/src/components/modules/uiControllers/controllers/keyboard.ts +20 -0
  26. package/src/components/modules/yjs/block-observer.ts +87 -23
  27. package/src/components/modules/yjs/document-store.ts +37 -11
  28. package/src/components/modules/yjs/index.ts +83 -7
  29. package/src/components/modules/yjs/types.ts +35 -2
  30. package/src/components/modules/yjs/undo-history.ts +116 -5
  31. package/src/components/utils/data-model-transform.ts +81 -7
  32. package/src/components/utils/hierarchy-invariant.ts +137 -0
  33. package/src/styles/main.css +5 -0
  34. package/src/tools/callout/constants.ts +0 -1
  35. package/src/tools/callout/dom-builder.ts +1 -11
  36. package/src/tools/callout/index.ts +0 -6
  37. package/src/tools/header/index.ts +14 -1
  38. package/src/tools/toggle/constants.ts +2 -1
  39. package/src/tools/toggle/dom-builder.ts +7 -0
  40. package/src/tools/toggle/index.ts +14 -1
  41. package/src/tools/toggle/toggle-lifecycle.ts +24 -0
@@ -844,31 +844,105 @@ const processRootCalloutItem = (
844
844
  * @param blocks - array of flat blocks with parent/content references
845
845
  * @returns collapsed array with nested structures
846
846
  */
847
+ /**
848
+ * Groups one block under its parent in the derived-content map if it has a
849
+ * valid parent reference. Helper extracted for collapseToLegacy reconciliation.
850
+ */
851
+ const appendChildToDerivedContent = (
852
+ block: OutputBlockData,
853
+ blockById: Map<BlockId, OutputBlockData>,
854
+ derivedContent: Map<BlockId, BlockId[]>
855
+ ): void => {
856
+ if (!block.id || !block.parent || !blockById.has(block.parent)) {
857
+ return;
858
+ }
859
+ const siblings = derivedContent.get(block.parent);
860
+
861
+ if (siblings === undefined) {
862
+ derivedContent.set(block.parent, [block.id]);
863
+
864
+ return;
865
+ }
866
+ siblings.push(block.id);
867
+ };
868
+
869
+ /**
870
+ * Merges live (parent-derived) ids into the existing content[] preserving its
871
+ * order, dropping any dead ids that don't resolve to a block in the input.
872
+ */
873
+ const mergeContentIds = (
874
+ existingContent: BlockId[] | undefined,
875
+ derivedIds: BlockId[],
876
+ blockById: Map<BlockId, OutputBlockData>
877
+ ): BlockId[] => {
878
+ const existing = Array.isArray(existingContent) ? existingContent : [];
879
+ const merged = existing.filter((id) => blockById.has(id));
880
+
881
+ for (const id of derivedIds) {
882
+ if (!merged.includes(id)) {
883
+ merged.push(id);
884
+ }
885
+ }
886
+
887
+ return merged;
888
+ };
889
+
847
890
  export const collapseToLegacy = (blocks: OutputBlockData[]): OutputBlockData[] => {
891
+ // Defense-in-depth: reconcile each parent's content[] from children's parent
892
+ // fields before processing. Saver is the primary source of truth for content[]
893
+ // (see src/components/modules/saver.ts#doSave), but this pass guarantees the
894
+ // invariant `child.parent === X ⇒ X.content.includes(child.id)` even when
895
+ // OutputBlockData originates from a path that bypassed the saver — migrations,
896
+ // external JSON, tests, 3rd-party consumers. Without this, stale content[]
897
+ // causes processRootCalloutItem to eject real children as root siblings.
898
+ const reconciledBlocks = blocks.map((block) => ({ ...block }));
899
+ const reconciledById = new Map<BlockId, OutputBlockData>();
900
+
901
+ for (const block of reconciledBlocks) {
902
+ if (block.id) {
903
+ reconciledById.set(block.id, block);
904
+ }
905
+ }
906
+
907
+ const derivedContent = new Map<BlockId, BlockId[]>();
908
+
909
+ for (const block of reconciledBlocks) {
910
+ appendChildToDerivedContent(block, reconciledById, derivedContent);
911
+ }
912
+
913
+ for (const [parentId, derivedIds] of derivedContent) {
914
+ const parent = reconciledById.get(parentId);
915
+
916
+ if (parent === undefined) {
917
+ continue;
918
+ }
919
+ parent.content = mergeContentIds(parent.content, derivedIds, reconciledById);
920
+ }
921
+
848
922
  // Build a map of blocks by ID for quick lookup
849
923
  const blockMap = new Map<BlockId, OutputBlockData>();
850
924
 
851
- for (const block of blocks) {
925
+ for (const block of reconciledBlocks) {
852
926
  if (block.id) {
853
927
  blockMap.set(block.id, block);
854
928
  }
855
929
  }
856
930
 
857
931
  // If no flat-model list, toggle, or callout blocks, just strip hierarchy fields and return
858
- const hasFlatListBlocks = blocks.some(isFlatModelListBlock);
859
- const hasFlatToggleBlocks = blocks.some(isFlatModelToggleBlock);
860
- const hasFlatToggleableHeaders = blocks.some(b => isToggleableHeaderBlock(b) && !b.parent);
861
- const hasFlatCalloutBlocks = blocks.some(isFlatModelCalloutBlock);
932
+ const hasFlatListBlocks = reconciledBlocks.some(isFlatModelListBlock);
933
+ const hasFlatToggleBlocks = reconciledBlocks.some(isFlatModelToggleBlock);
934
+ const hasFlatToggleableHeaders = reconciledBlocks.some(b => isToggleableHeaderBlock(b) && !b.parent);
935
+ const hasFlatCalloutBlocks = reconciledBlocks.some(isFlatModelCalloutBlock);
862
936
 
863
937
  if (!hasFlatListBlocks && !hasFlatToggleBlocks && !hasFlatToggleableHeaders && !hasFlatCalloutBlocks) {
864
- return blocks.map(stripHierarchyFields);
938
+ return reconciledBlocks.map(stripHierarchyFields);
865
939
  }
866
940
 
867
941
  // Process blocks, converting root flat-model list blocks to legacy List blocks
868
942
  const result: OutputBlockData[] = [];
869
943
  const processedIds = new Set<BlockId>();
870
944
 
871
- for (const block of blocks) {
945
+ for (const block of reconciledBlocks) {
872
946
  const alreadyProcessed = block.id && processedIds.has(block.id);
873
947
 
874
948
  if (alreadyProcessed) {
@@ -0,0 +1,137 @@
1
+ import type { OutputBlockData } from '@/types';
2
+ import type { BlockId } from '../../../types/data-formats/block-id';
3
+
4
+ /**
5
+ * Hierarchy invariant validator.
6
+ *
7
+ * Every block with `parent: X` must appear in `X.content`, and every id in a
8
+ * block's `content[]` must resolve to a block whose `parent` points back. Any
9
+ * drift between the two representations is the signature of the callout paste
10
+ * ejection bug (and its siblings across toggle, toggleable header, list, and
11
+ * any future container block).
12
+ *
13
+ * This util exists so tests and saver-level assertions can detect drift at
14
+ * any point in the pipeline — load, save, collapse, or post-mutation — without
15
+ * hand-rolling the same loop. Treat it as the single source of truth for the
16
+ * parent/content invariant.
17
+ */
18
+
19
+ export interface HierarchyViolation {
20
+ kind:
21
+ | 'child-parent-missing'
22
+ | 'child-not-in-parent-content'
23
+ | 'content-id-dangling'
24
+ | 'content-parent-mismatch'
25
+ | 'content-duplicate';
26
+ blockId: BlockId | undefined;
27
+ parentId?: BlockId;
28
+ childId?: BlockId;
29
+ message: string;
30
+ }
31
+
32
+ const pushViolation = (violations: HierarchyViolation[], v: HierarchyViolation): void => {
33
+ violations.push(v);
34
+ };
35
+
36
+ const checkParentLinks = (
37
+ block: OutputBlockData,
38
+ blockById: Map<BlockId, OutputBlockData>,
39
+ violations: HierarchyViolation[]
40
+ ): void => {
41
+ if (block.parent === undefined || block.parent === null) {
42
+ return;
43
+ }
44
+ const parent = blockById.get(block.parent);
45
+
46
+ if (parent === undefined) {
47
+ pushViolation(violations, {
48
+ kind: 'child-parent-missing',
49
+ blockId: block.id,
50
+ parentId: block.parent,
51
+ message: `Block ${String(block.id)} references missing parent ${String(block.parent)}`,
52
+ });
53
+
54
+ return;
55
+ }
56
+ if (block.id === undefined || !Array.isArray(parent.content) || !parent.content.includes(block.id)) {
57
+ pushViolation(violations, {
58
+ kind: 'child-not-in-parent-content',
59
+ blockId: block.id,
60
+ parentId: block.parent,
61
+ message: `Block ${String(block.id)} has parent=${String(block.parent)} but that parent's content[] does not include it`,
62
+ });
63
+ }
64
+ };
65
+
66
+ const checkContentArray = (
67
+ block: OutputBlockData,
68
+ blockById: Map<BlockId, OutputBlockData>,
69
+ violations: HierarchyViolation[]
70
+ ): void => {
71
+ if (!Array.isArray(block.content)) {
72
+ return;
73
+ }
74
+ const seen = new Set<BlockId>();
75
+
76
+ for (const childId of block.content) {
77
+ if (seen.has(childId)) {
78
+ pushViolation(violations, {
79
+ kind: 'content-duplicate',
80
+ blockId: block.id,
81
+ childId,
82
+ message: `Block ${String(block.id)}.content[] contains duplicate id ${String(childId)}`,
83
+ });
84
+ continue;
85
+ }
86
+ seen.add(childId);
87
+
88
+ const child = blockById.get(childId);
89
+
90
+ if (child === undefined) {
91
+ pushViolation(violations, {
92
+ kind: 'content-id-dangling',
93
+ blockId: block.id,
94
+ childId,
95
+ message: `Block ${String(block.id)}.content[] references missing child ${String(childId)}`,
96
+ });
97
+ continue;
98
+ }
99
+ if (child.parent !== block.id) {
100
+ pushViolation(violations, {
101
+ kind: 'content-parent-mismatch',
102
+ blockId: block.id,
103
+ childId,
104
+ message: `Block ${String(block.id)}.content[] includes ${String(childId)} but that child's parent is ${String(child.parent)}`,
105
+ });
106
+ }
107
+ }
108
+ };
109
+
110
+ export const validateHierarchy = (blocks: OutputBlockData[]): HierarchyViolation[] => {
111
+ const violations: HierarchyViolation[] = [];
112
+ const blockById = new Map<BlockId, OutputBlockData>();
113
+
114
+ for (const block of blocks) {
115
+ if (block.id !== undefined) {
116
+ blockById.set(block.id, block);
117
+ }
118
+ }
119
+
120
+ for (const block of blocks) {
121
+ checkParentLinks(block, blockById, violations);
122
+ checkContentArray(block, blockById, violations);
123
+ }
124
+
125
+ return violations;
126
+ };
127
+
128
+ export const assertHierarchy = (blocks: OutputBlockData[], context: string): void => {
129
+ const violations = validateHierarchy(blocks);
130
+
131
+ if (violations.length === 0) {
132
+ return;
133
+ }
134
+ const summary = violations.map(v => ` - ${v.message}`).join('\n');
135
+
136
+ throw new Error(`Hierarchy invariant violated at ${context}:\n${summary}`);
137
+ };
@@ -1306,6 +1306,11 @@
1306
1306
  @apply p-0 m-0 min-h-[1.6em];
1307
1307
  }
1308
1308
 
1309
+ /* List items inside table cells use tight 2px top/bottom spacing */
1310
+ [data-blok-table-cell-blocks] [data-blok-tool="list"] {
1311
+ @apply py-0 mt-[2px] mb-[2px];
1312
+ }
1313
+
1309
1314
  /* ─── Cell content placement ──────────────────────────────────── */
1310
1315
 
1311
1316
  [data-blok-cell-placement="top-center"] {
@@ -29,4 +29,3 @@ export const WRAPPER_STYLES = 'rounded-xl pl-8 pr-4 py-[5px] my-1 flex items-sta
29
29
  // h-[38px] = py-[7px]×2 + 1.5rem×1 = 14+24; explicit height prevents platform-specific emoji font metrics from inflating the button
30
30
  export const EMOJI_BUTTON_STYLES = 'text-[1.5rem] leading-[1] cursor-pointer bg-transparent border-0 px-0 py-[7px] h-[38px] flex-shrink-0 select-none';
31
31
  export const CHILDREN_STYLES = 'flex-1 min-w-0';
32
- export const DRAG_ZONE_STYLES = 'absolute left-0 top-0 h-full cursor-grab select-none';
@@ -6,14 +6,12 @@ import {
6
6
  WRAPPER_STYLES,
7
7
  EMOJI_BUTTON_STYLES,
8
8
  CHILDREN_STYLES,
9
- DRAG_ZONE_STYLES,
10
9
  } from './constants';
11
10
 
12
11
  export interface CalloutDOMRefs {
13
12
  wrapper: HTMLElement;
14
13
  emojiButton: HTMLButtonElement;
15
14
  childContainer: HTMLElement;
16
- dragZone: HTMLElement;
17
15
  }
18
16
 
19
17
  export interface BuildCalloutDOMOptions {
@@ -53,13 +51,5 @@ export function buildCalloutDOM(options: BuildCalloutDOMOptions): CalloutDOMRefs
53
51
  wrapper.appendChild(emojiButton);
54
52
  wrapper.appendChild(childContainer);
55
53
 
56
- // Drag zone — covers left padding area (x=[0,16px]) for drag handle,
57
- // sits behind emoji button so emoji clicks pass through
58
- const dragZone = document.createElement('span');
59
- dragZone.className = DRAG_ZONE_STYLES;
60
- dragZone.style.width = '32px'; // matches pl-8 left padding
61
- dragZone.setAttribute('data-callout-drag-zone', '');
62
- wrapper.prepend(dragZone);
63
-
64
- return { wrapper, emojiButton, childContainer, dragZone };
54
+ return { wrapper, emojiButton, childContainer };
65
55
  }
@@ -66,7 +66,6 @@ export class CalloutTool implements BlockTool {
66
66
  private _dom: CalloutDOMRefs | null = null;
67
67
  private _emojiPicker: EmojiPicker | null = null;
68
68
  private _colorPicker: ColorPickerHandle | null = null;
69
- private _dragZone: HTMLElement | null = null;
70
69
  private blockId?: string;
71
70
 
72
71
  constructor({ data, api, readOnly, block }: BlockToolConstructorOptions<CalloutData, CalloutConfig>) {
@@ -121,7 +120,6 @@ export class CalloutTool implements BlockTool {
121
120
  });
122
121
 
123
122
  this._dom = dom;
124
- this._dragZone = dom.dragZone;
125
123
  this.applyColors();
126
124
 
127
125
  if (!this.readOnly) {
@@ -262,10 +260,6 @@ export class CalloutTool implements BlockTool {
262
260
  }
263
261
  }
264
262
 
265
- public get dragZone(): HTMLElement | null {
266
- return this._dragZone;
267
- }
268
-
269
263
  private syncPickerActiveColors(): void {
270
264
  if (this._colorPicker === null) {
271
265
  return;
@@ -23,7 +23,7 @@ import { PLACEHOLDER_CLASSES, setupPlaceholder } from '../../components/utils/pl
23
23
  import { twMerge } from '../../components/utils/tw';
24
24
  import { BODY_PLACEHOLDER_STYLES, TOGGLE_ATTR } from '../toggle/constants';
25
25
  import { buildArrow } from '../toggle/dom-builder';
26
- import { updateArrowState, updateBodyPlaceholderVisibility, updateChildrenVisibility } from '../toggle/toggle-lifecycle';
26
+ import { updateArrowState, updateBodyPlaceholderVisibility, updateChildrenVisibility, updateToggleEmptyState } from '../toggle/toggle-lifecycle';
27
27
  import { handleHeaderToggleEnter, handleHeaderToggleBackspace } from './header-toggle-keyboard';
28
28
 
29
29
  /**
@@ -764,6 +764,7 @@ export class Header implements BlockTool {
764
764
  */
765
765
  private buildWrapper(): HTMLElement {
766
766
  const wrapper = document.createElement('div');
767
+ wrapper.setAttribute(TOGGLE_ATTR.toggleEmpty, 'true');
767
768
 
768
769
  // Inner row: positioning context for the arrow (only heading height, not children).
769
770
  const headerRow = document.createElement('div');
@@ -799,12 +800,21 @@ export class Header implements BlockTool {
799
800
  // Block DOM mutations inside the children container from triggering the header tool's
800
801
  // didMutated → syncBlockDataToYjs path (same rationale as the toggle list tool).
801
802
  childContainer.setAttribute('data-blok-mutation-free', 'true');
803
+ /**
804
+ * Listen for typing inside child blocks so the empty-state attribute
805
+ * (and the grayish arrow it drives) tracks what the user types in real time.
806
+ */
807
+ childContainer.addEventListener('input', this.handleChildContainerInput);
802
808
  this._childContainerElement = childContainer;
803
809
  wrapper.appendChild(childContainer);
804
810
 
805
811
  return wrapper;
806
812
  }
807
813
 
814
+ private handleChildContainerInput = (): void => {
815
+ updateToggleEmptyState(this._wrapper, this._childContainerElement);
816
+ };
817
+
808
818
  /**
809
819
  * Wrap the heading element in a new wrapper div containing the toggle arrow,
810
820
  * replacing the heading's current position in the DOM.
@@ -814,6 +824,7 @@ export class Header implements BlockTool {
814
824
  const parent = this._element.parentNode;
815
825
 
816
826
  this._wrapper = document.createElement('div');
827
+ this._wrapper.setAttribute(TOGGLE_ATTR.toggleEmpty, 'true');
817
828
 
818
829
  // Inner row: positioning context for the arrow (only heading height, not children).
819
830
  const headerRow = document.createElement('div');
@@ -916,6 +927,8 @@ export class Header implements BlockTool {
916
927
  this._isOpen,
917
928
  this.readOnly
918
929
  );
930
+
931
+ updateToggleEmptyState(this._wrapper, this._childContainerElement);
919
932
  }
920
933
 
921
934
  /**
@@ -43,7 +43,7 @@ export const TOGGLE_WRAPPER_STYLES = 'flex items-center';
43
43
  /**
44
44
  * Styles for the toggle arrow button
45
45
  */
46
- export const ARROW_STYLES = 'flex-shrink-0 p-[8px] flex items-center justify-center cursor-pointer select-none rounded can-hover:hover:bg-item-hover-bg transition-colors duration-200 ease-in-out focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:outline-none';
46
+ export const ARROW_STYLES = 'flex-shrink-0 p-[8px] flex items-center justify-center cursor-pointer select-none rounded can-hover:hover:bg-item-hover-bg transition-colors duration-200 ease-in-out focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:outline-none in-data-[blok-toggle-empty=true]:text-gray-text';
47
47
 
48
48
  /**
49
49
  * SVG icon for the toggle arrow
@@ -77,4 +77,5 @@ export const TOGGLE_ATTR = {
77
77
  toggleContent: 'data-blok-toggle-content',
78
78
  toggleBodyPlaceholder: 'data-blok-toggle-body-placeholder',
79
79
  toggleChildren: 'data-blok-toggle-children',
80
+ toggleEmpty: 'data-blok-toggle-empty',
80
81
  } as const;
@@ -73,6 +73,13 @@ export const buildToggleItem = (context: ToggleDOMBuilderContext): ToggleBuildRe
73
73
  wrapper.className = BASE_STYLES;
74
74
  wrapper.setAttribute(DATA_ATTR.tool, TOOL_NAME);
75
75
  wrapper.setAttribute(TOGGLE_ATTR.toggleOpen, String(isOpen));
76
+ /**
77
+ * Empty state default: assume no children until the tool syncs real state
78
+ * via updateToggleEmptyState() in its lifecycle methods. This drives the
79
+ * grayish arrow styling applied through the in-data-[blok-toggle-empty=true]
80
+ * Tailwind variant on ARROW_STYLES.
81
+ */
82
+ wrapper.setAttribute(TOGGLE_ATTR.toggleEmpty, 'true');
76
83
 
77
84
  const headerRow = document.createElement('div');
78
85
  headerRow.className = TOGGLE_WRAPPER_STYLES;
@@ -27,7 +27,7 @@ import {
27
27
  import { clean } from '../../components/utils/sanitizer';
28
28
  import { ARIA_LABEL_COLLAPSE_KEY, ARIA_LABEL_EXPAND_KEY, BODY_PLACEHOLDER_KEY, PLACEHOLDER_KEY, TOOL_NAME } from './constants';
29
29
  import { IconToggleList } from '../../components/icons';
30
- import { renderToggleItem, updateArrowState, updateChildrenVisibility, updateBodyPlaceholderVisibility } from './toggle-lifecycle';
30
+ import { renderToggleItem, updateArrowState, updateChildrenVisibility, updateBodyPlaceholderVisibility, updateToggleEmptyState } from './toggle-lifecycle';
31
31
  import { handleToggleEnter, handleToggleBackspace } from './toggle-keyboard';
32
32
  import type { ToggleItemData, ToggleItemConfig } from './types';
33
33
 
@@ -127,9 +127,20 @@ export class ToggleItem implements BlockTool {
127
127
  this._bodyPlaceholderElement = result.bodyPlaceholderElement;
128
128
  this._childContainerElement = result.childContainerElement;
129
129
 
130
+ /**
131
+ * Listen for input events from child blocks so the empty-state attribute
132
+ * (and the grayish arrow it drives) tracks what the user is typing in
133
+ * real time.
134
+ */
135
+ this._childContainerElement.addEventListener('input', this.handleChildContainerInput);
136
+
130
137
  return this._element;
131
138
  }
132
139
 
140
+ private handleChildContainerInput = (): void => {
141
+ updateToggleEmptyState(this._element, this._childContainerElement);
142
+ };
143
+
133
144
  public rendered(): void {
134
145
  this.updateChildrenVisibility();
135
146
  this.updateBodyPlaceholderVisibility();
@@ -322,6 +333,8 @@ export class ToggleItem implements BlockTool {
322
333
  this._isOpen,
323
334
  this.readOnly
324
335
  );
336
+
337
+ updateToggleEmptyState(this._element, this._childContainerElement);
325
338
  }
326
339
 
327
340
  private handleBodyPlaceholderClick(): void {
@@ -13,6 +13,30 @@ import { TOGGLE_ATTR } from './constants';
13
13
  import { buildToggleItem } from './dom-builder';
14
14
  import type { ToggleDOMBuilderContext } from './dom-builder';
15
15
 
16
+ /**
17
+ * Sync the wrapper's data-blok-toggle-empty attribute to reflect whether the
18
+ * toggle's body has any visible text. Reads `textContent` off the child
19
+ * container directly so the state updates live as the user types (or deletes).
20
+ * The attribute drives the grayish arrow styling via the
21
+ * in-data-[blok-toggle-empty=true] Tailwind variant on ARROW_STYLES.
22
+ *
23
+ * @param wrapper - The toggle wrapper element that receives the attribute
24
+ * @param childContainer - The element that hosts the child block holders
25
+ */
26
+ export const updateToggleEmptyState = (
27
+ wrapper: HTMLElement | null,
28
+ childContainer: HTMLElement | null
29
+ ): void => {
30
+ if (wrapper === null) {
31
+ return;
32
+ }
33
+
34
+ const text = childContainer?.textContent ?? '';
35
+ const isEmpty = text.trim() === '';
36
+
37
+ wrapper.setAttribute(TOGGLE_ATTR.toggleEmpty, String(isEmpty));
38
+ };
39
+
16
40
  /**
17
41
  * Context for rendering a toggle item
18
42
  */