@nyaruka/temba-components 0.142.1 → 0.142.3

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 (140) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/temba-components.js +953 -708
  3. package/dist/temba-components.js.map +1 -1
  4. package/out-tsc/src/Icons.js +1 -0
  5. package/out-tsc/src/Icons.js.map +1 -1
  6. package/out-tsc/src/flow/CanvasMenu.js +38 -38
  7. package/out-tsc/src/flow/CanvasMenu.js.map +1 -1
  8. package/out-tsc/src/flow/CanvasNode.js +171 -17
  9. package/out-tsc/src/flow/CanvasNode.js.map +1 -1
  10. package/out-tsc/src/flow/Editor.js +491 -22
  11. package/out-tsc/src/flow/Editor.js.map +1 -1
  12. package/out-tsc/src/flow/NodeEditor.js +346 -10
  13. package/out-tsc/src/flow/NodeEditor.js.map +1 -1
  14. package/out-tsc/src/flow/NodeTypeSelector.js +2 -0
  15. package/out-tsc/src/flow/NodeTypeSelector.js.map +1 -1
  16. package/out-tsc/src/flow/Plumber.js +92 -28
  17. package/out-tsc/src/flow/Plumber.js.map +1 -1
  18. package/out-tsc/src/flow/StickyNote.js +63 -3
  19. package/out-tsc/src/flow/StickyNote.js.map +1 -1
  20. package/out-tsc/src/flow/actions/add_contact_urn.js +2 -6
  21. package/out-tsc/src/flow/actions/add_contact_urn.js.map +1 -1
  22. package/out-tsc/src/flow/actions/enter_flow.js +2 -2
  23. package/out-tsc/src/flow/actions/enter_flow.js.map +1 -1
  24. package/out-tsc/src/flow/actions/say_msg.js +2 -1
  25. package/out-tsc/src/flow/actions/say_msg.js.map +1 -1
  26. package/out-tsc/src/flow/actions/send_broadcast.js +2 -6
  27. package/out-tsc/src/flow/actions/send_broadcast.js.map +1 -1
  28. package/out-tsc/src/flow/actions/send_email.js +2 -6
  29. package/out-tsc/src/flow/actions/send_email.js.map +1 -1
  30. package/out-tsc/src/flow/actions/send_msg.js +55 -35
  31. package/out-tsc/src/flow/actions/send_msg.js.map +1 -1
  32. package/out-tsc/src/flow/actions/set_contact_channel.js +2 -1
  33. package/out-tsc/src/flow/actions/set_contact_channel.js.map +1 -1
  34. package/out-tsc/src/flow/actions/set_contact_field.js +4 -5
  35. package/out-tsc/src/flow/actions/set_contact_field.js.map +1 -1
  36. package/out-tsc/src/flow/actions/set_contact_language.js +3 -3
  37. package/out-tsc/src/flow/actions/set_contact_language.js.map +1 -1
  38. package/out-tsc/src/flow/actions/set_contact_name.js +2 -1
  39. package/out-tsc/src/flow/actions/set_contact_name.js.map +1 -1
  40. package/out-tsc/src/flow/actions/set_contact_status.js +2 -1
  41. package/out-tsc/src/flow/actions/set_contact_status.js.map +1 -1
  42. package/out-tsc/src/flow/actions/set_run_result.js +3 -3
  43. package/out-tsc/src/flow/actions/set_run_result.js.map +1 -1
  44. package/out-tsc/src/flow/actions/start_session.js +2 -2
  45. package/out-tsc/src/flow/actions/start_session.js.map +1 -1
  46. package/out-tsc/src/flow/nodes/split_by_llm.js +4 -5
  47. package/out-tsc/src/flow/nodes/split_by_llm.js.map +1 -1
  48. package/out-tsc/src/flow/nodes/split_by_resthook.js +3 -8
  49. package/out-tsc/src/flow/nodes/split_by_resthook.js.map +1 -1
  50. package/out-tsc/src/flow/nodes/split_by_subflow.js +4 -2
  51. package/out-tsc/src/flow/nodes/split_by_subflow.js.map +1 -1
  52. package/out-tsc/src/flow/nodes/split_by_webhook.js +25 -33
  53. package/out-tsc/src/flow/nodes/split_by_webhook.js.map +1 -1
  54. package/out-tsc/src/flow/nodes/wait_for_response.js +1 -0
  55. package/out-tsc/src/flow/nodes/wait_for_response.js.map +1 -1
  56. package/out-tsc/src/flow/types.js.map +1 -1
  57. package/out-tsc/src/flow/utils.js +66 -0
  58. package/out-tsc/src/flow/utils.js.map +1 -1
  59. package/out-tsc/src/form/FieldRenderer.js +17 -2
  60. package/out-tsc/src/form/FieldRenderer.js.map +1 -1
  61. package/out-tsc/src/interfaces.js +1 -0
  62. package/out-tsc/src/interfaces.js.map +1 -1
  63. package/out-tsc/src/list/SortableList.js +104 -43
  64. package/out-tsc/src/list/SortableList.js.map +1 -1
  65. package/out-tsc/src/simulator/Simulator.js +6 -2
  66. package/out-tsc/src/simulator/Simulator.js.map +1 -1
  67. package/out-tsc/test/temba-canvas-menu.test.js +13 -9
  68. package/out-tsc/test/temba-canvas-menu.test.js.map +1 -1
  69. package/out-tsc/test/temba-flow-reflow.test.js.map +1 -1
  70. package/out-tsc/test/temba-node-editor.test.js +9 -10
  71. package/out-tsc/test/temba-node-editor.test.js.map +1 -1
  72. package/out-tsc/test/temba-node-type-selector.test.js +3 -3
  73. package/out-tsc/test/temba-node-type-selector.test.js.map +1 -1
  74. package/out-tsc/test/temba-simulator.test.js +2 -2
  75. package/out-tsc/test/temba-simulator.test.js.map +1 -1
  76. package/package.json +1 -1
  77. package/screenshots/truth/actions/enter_flow/render/basic-flow.png +0 -0
  78. package/screenshots/truth/actions/enter_flow/render/long-flow-name.png +0 -0
  79. package/screenshots/truth/actions/send_email/render/long-subject.png +0 -0
  80. package/screenshots/truth/actions/send_msg/editor/long-quick-replies.png +0 -0
  81. package/screenshots/truth/actions/send_msg/editor/multiline-text-with-replies.png +0 -0
  82. package/screenshots/truth/actions/send_msg/editor/simple-text.png +0 -0
  83. package/screenshots/truth/actions/send_msg/editor/text-with-linebreaks.png +0 -0
  84. package/screenshots/truth/actions/send_msg/editor/text-with-many-quick-replies.png +0 -0
  85. package/screenshots/truth/actions/send_msg/editor/text-with-quick-replies.png +0 -0
  86. package/screenshots/truth/actions/send_msg/editor/text-without-quick-replies.png +0 -0
  87. package/screenshots/truth/actions/send_msg/render/long-quick-replies.png +0 -0
  88. package/screenshots/truth/actions/send_msg/render/multiline-text-with-replies.png +0 -0
  89. package/screenshots/truth/actions/send_msg/render/text-with-many-quick-replies.png +0 -0
  90. package/screenshots/truth/actions/send_msg/render/text-with-quick-replies.png +0 -0
  91. package/screenshots/truth/actions/start_session/render/contact-query.png +0 -0
  92. package/screenshots/truth/actions/start_session/render/contacts-only.png +0 -0
  93. package/screenshots/truth/actions/start_session/render/create-contact.png +0 -0
  94. package/screenshots/truth/actions/start_session/render/groups-and-contacts.png +0 -0
  95. package/screenshots/truth/actions/start_session/render/groups-only.png +0 -0
  96. package/screenshots/truth/actions/start_session/render/many-recipients.png +0 -0
  97. package/screenshots/truth/canvas-menu/open.png +0 -0
  98. package/screenshots/truth/node-type-selector/action-mode.png +0 -0
  99. package/screenshots/truth/node-type-selector/split-mode.png +0 -0
  100. package/screenshots/truth/nodes/split_by_llm/render/information-extraction.png +0 -0
  101. package/screenshots/truth/nodes/split_by_llm/render/sentiment-analysis.png +0 -0
  102. package/screenshots/truth/nodes/split_by_llm_categorize/editor/feedback-categorization.png +0 -0
  103. package/screenshots/truth/nodes/split_by_llm_categorize/render/feedback-categorization.png +0 -0
  104. package/src/Icons.ts +1 -0
  105. package/src/flow/CanvasMenu.ts +50 -43
  106. package/src/flow/CanvasNode.ts +201 -17
  107. package/src/flow/Editor.ts +585 -25
  108. package/src/flow/NodeEditor.ts +373 -10
  109. package/src/flow/NodeTypeSelector.ts +2 -0
  110. package/src/flow/Plumber.ts +104 -37
  111. package/src/flow/StickyNote.ts +76 -4
  112. package/src/flow/actions/add_contact_urn.ts +5 -6
  113. package/src/flow/actions/enter_flow.ts +2 -2
  114. package/src/flow/actions/say_msg.ts +2 -1
  115. package/src/flow/actions/send_broadcast.ts +2 -6
  116. package/src/flow/actions/send_email.ts +2 -6
  117. package/src/flow/actions/send_msg.ts +59 -38
  118. package/src/flow/actions/set_contact_channel.ts +5 -1
  119. package/src/flow/actions/set_contact_field.ts +10 -5
  120. package/src/flow/actions/set_contact_language.ts +6 -3
  121. package/src/flow/actions/set_contact_name.ts +5 -1
  122. package/src/flow/actions/set_contact_status.ts +5 -1
  123. package/src/flow/actions/set_run_result.ts +6 -3
  124. package/src/flow/actions/start_session.ts +2 -2
  125. package/src/flow/nodes/split_by_llm.ts +5 -5
  126. package/src/flow/nodes/split_by_resthook.ts +3 -8
  127. package/src/flow/nodes/split_by_subflow.ts +4 -2
  128. package/src/flow/nodes/split_by_webhook.ts +26 -34
  129. package/src/flow/nodes/wait_for_response.ts +1 -0
  130. package/src/flow/types.ts +25 -2
  131. package/src/flow/utils.ts +79 -1
  132. package/src/form/FieldRenderer.ts +32 -3
  133. package/src/interfaces.ts +1 -0
  134. package/src/list/SortableList.ts +117 -47
  135. package/src/simulator/Simulator.ts +6 -2
  136. package/test/temba-canvas-menu.test.ts +13 -9
  137. package/test/temba-flow-reflow.test.ts +4 -2
  138. package/test/temba-node-editor.test.ts +9 -10
  139. package/test/temba-node-type-selector.test.ts +3 -3
  140. package/test/temba-simulator.test.ts +2 -2
@@ -9,7 +9,8 @@ import {
9
9
  MessageEditorFieldConfig,
10
10
  KeyValueFieldConfig,
11
11
  ArrayFieldConfig,
12
- MediaFieldConfig
12
+ MediaFieldConfig,
13
+ TemplateEditorFieldConfig
13
14
  } from '../flow/types';
14
15
  import { Attachment } from '../interfaces';
15
16
  import { DEFAULT_MEDIA_ENDPOINT } from '../utils';
@@ -101,6 +102,14 @@ export class FieldRenderer {
101
102
  context
102
103
  );
103
104
 
105
+ case 'template-editor':
106
+ return FieldRenderer.renderTemplateEditor(
107
+ fieldName,
108
+ config as TemplateEditorFieldConfig,
109
+ value,
110
+ context
111
+ );
112
+
104
113
  default:
105
114
  return html`<div>Unsupported field type: ${(config as any).type}</div>`;
106
115
  }
@@ -129,7 +138,7 @@ export class FieldRenderer {
129
138
  .errors="${errors}"
130
139
  .value="${value || ''}"
131
140
  placeholder="${config.placeholder || ''}"
132
- expressions="session"
141
+ session
133
142
  .helpText="${config.helpText || ''}"
134
143
  class="${extraClasses}"
135
144
  style="${style}"
@@ -181,7 +190,7 @@ export class FieldRenderer {
181
190
  .value="${value || ''}"
182
191
  placeholder="${config.placeholder || ''}"
183
192
  textarea
184
- expressions="session"
193
+ session
185
194
  .helpText="${config.helpText || ''}"
186
195
  class="${extraClasses}"
187
196
  style="${combinedStyle}"
@@ -462,6 +471,7 @@ export class FieldRenderer {
462
471
  ?autogrow="${config.autogrow}"
463
472
  ?gsm="${config.gsm}"
464
473
  ?disableCompletion="${config.disableCompletion}"
474
+ session
465
475
  counter="${config.counter || ''}"
466
476
  accept="${config.accept || ''}"
467
477
  endpoint="${config.endpoint || ''}"
@@ -472,6 +482,25 @@ export class FieldRenderer {
472
482
  @change="${onChange || (() => {})}"
473
483
  ></temba-message-editor>`;
474
484
  }
485
+
486
+ private static renderTemplateEditor(
487
+ _fieldName: string,
488
+ config: TemplateEditorFieldConfig,
489
+ value: any,
490
+ context: FieldRenderContext
491
+ ): TemplateResult {
492
+ const { onChange, additionalData = {} } = context;
493
+ const templateUuid = value?.uuid || '';
494
+ const variables = JSON.stringify(additionalData.template_variables || []);
495
+
496
+ return html`<temba-template-editor
497
+ url="${config.endpoint || '/api/internal/templates.json'}"
498
+ template="${templateUuid}"
499
+ variables="${variables}"
500
+ @temba-context-changed="${onChange || (() => {})}"
501
+ @temba-content-changed="${onChange || (() => {})}"
502
+ ></temba-template-editor>`;
503
+ }
475
504
  }
476
505
 
477
506
  export interface FieldRenderContext {
package/src/interfaces.ts CHANGED
@@ -305,5 +305,6 @@ export enum CustomEventType {
305
305
  NodeEditCancelled = 'temba-node-edit-cancelled',
306
306
  FollowSimulation = 'temba-follow-simulation',
307
307
  ContactClicked = 'temba-contact-clicked',
308
+ FlowClicked = 'temba-flow-clicked',
308
309
  ShowIssue = 'temba-show-issue'
309
310
  }
@@ -44,6 +44,7 @@ export class SortableList extends RapidElement {
44
44
 
45
45
  slot > * {
46
46
  user-select: none;
47
+ touch-action: none;
47
48
  }
48
49
 
49
50
  temba-icon {
@@ -103,6 +104,9 @@ export class SortableList extends RapidElement {
103
104
  this.handleMouseMove = this.handleMouseMove.bind(this);
104
105
  this.handleMouseUp = this.handleMouseUp.bind(this);
105
106
  this.handleMouseDown = this.handleMouseDown.bind(this);
107
+ this.handleTouchMove = this.handleTouchMove.bind(this);
108
+ this.handleTouchEnd = this.handleTouchEnd.bind(this);
109
+ this.handleTouchStart = this.handleTouchStart.bind(this);
106
110
  }
107
111
 
108
112
  private getSortableElements(): Element[] {
@@ -369,48 +373,81 @@ export class SortableList extends RapidElement {
369
373
  this.downEle.insertAdjacentElement('afterend', this.dropPlaceholder);
370
374
  }
371
375
 
372
- private handleMouseDown(event: MouseEvent) {
373
- let ele = event.target as HTMLDivElement;
374
-
376
+ /**
377
+ * Shared drag-start logic for both mouse and touch.
378
+ * Returns true if a drag target was found and state was initialised.
379
+ */
380
+ private beginDrag(
381
+ target: HTMLElement,
382
+ clientX: number,
383
+ clientY: number
384
+ ): boolean {
375
385
  // if we have a drag handle, only allow dragging from that element
376
386
  if (this.dragHandle) {
377
- if (!ele.classList.contains(this.dragHandle)) {
378
- return;
387
+ if (!target.classList.contains(this.dragHandle)) {
388
+ return false;
379
389
  }
380
390
  }
381
391
 
382
- ele = ele.closest('.sortable');
383
- if (ele) {
392
+ const ele = target.closest('.sortable') as HTMLDivElement;
393
+ if (!ele) return false;
394
+
395
+ this.downEle = ele;
396
+ this.draggingId = ele.id;
397
+ this.draggingIdx = this.getRowIndex(ele.id);
398
+ this.draggingEle = ele;
399
+
400
+ const rect = ele.getBoundingClientRect();
401
+ this.originalElementRect = rect;
402
+ this.originalLayoutSize = {
403
+ width: ele.offsetWidth,
404
+ height: ele.offsetHeight
405
+ };
406
+ this.xOffset = clientX - rect.left;
407
+ this.yOffset = clientY - rect.top;
408
+ this.yDown = clientY;
409
+ this.xDown = clientX;
410
+
411
+ return true;
412
+ }
413
+
414
+ private handleMouseDown(event: MouseEvent) {
415
+ if (
416
+ this.beginDrag(event.target as HTMLElement, event.clientX, event.clientY)
417
+ ) {
384
418
  event.preventDefault();
385
419
  event.stopPropagation();
386
- this.downEle = ele;
387
- this.draggingId = ele.id;
388
- this.draggingIdx = this.getRowIndex(ele.id);
389
- this.draggingEle = ele;
390
-
391
- // Use getBoundingClientRect for accurate offsets and store original dimensions
392
- const rect = ele.getBoundingClientRect();
393
- this.originalElementRect = rect; // Store viewport rect for ghost positioning
394
- this.originalLayoutSize = {
395
- width: ele.offsetWidth,
396
- height: ele.offsetHeight
397
- }; // Store layout dimensions for placeholders
398
- this.xOffset = event.clientX - rect.left;
399
- this.yOffset = event.clientY - rect.top;
400
- this.yDown = event.clientY;
401
- this.xDown = event.clientX;
402
-
403
420
  document.addEventListener('mousemove', this.handleMouseMove);
404
421
  document.addEventListener('mouseup', this.handleMouseUp);
405
422
  }
406
423
  }
407
424
 
408
- private handleMouseMove(event: MouseEvent) {
425
+ /* c8 ignore start -- touch-only handlers */
426
+ private handleTouchStart(event: TouchEvent) {
427
+ const touch = event.touches[0];
428
+ if (!touch) return;
429
+ if (
430
+ this.beginDrag(event.target as HTMLElement, touch.clientX, touch.clientY)
431
+ ) {
432
+ event.stopPropagation();
433
+ document.addEventListener('touchmove', this.handleTouchMove, {
434
+ passive: false
435
+ });
436
+ document.addEventListener('touchend', this.handleTouchEnd);
437
+ document.addEventListener('touchcancel', this.handleTouchEnd);
438
+ }
439
+ }
440
+ /* c8 ignore stop */
441
+
442
+ /**
443
+ * Shared drag-move logic for both mouse and touch.
444
+ */
445
+ private processDragMove(clientX: number, clientY: number) {
409
446
  if (
410
447
  !this.ghostElement &&
411
448
  this.downEle &&
412
- (Math.abs(event.clientY - this.yDown) > DRAG_THRESHOLD ||
413
- Math.abs(event.clientX - this.xDown) > DRAG_THRESHOLD)
449
+ (Math.abs(clientY - this.yDown) > DRAG_THRESHOLD ||
450
+ Math.abs(clientX - this.xDown) > DRAG_THRESHOLD)
414
451
  ) {
415
452
  this.fireCustomEvent(CustomEventType.DragStart, {
416
453
  id: this.downEle.id
@@ -440,8 +477,8 @@ export class SortableList extends RapidElement {
440
477
  const hasAncestorScale = Math.abs(ancestorScale - 1) > 0.001;
441
478
 
442
479
  this.ghostElement.style.position = 'fixed';
443
- this.ghostElement.style.left = event.clientX - this.xOffset + 'px';
444
- this.ghostElement.style.top = event.clientY - this.yOffset + 'px';
480
+ this.ghostElement.style.left = clientX - this.xOffset + 'px';
481
+ this.ghostElement.style.top = clientY - this.yOffset + 'px';
445
482
  this.ghostElement.style.zIndex = '99999';
446
483
  this.ghostElement.style.opacity = '0.8';
447
484
 
@@ -480,12 +517,12 @@ export class SortableList extends RapidElement {
480
517
  }
481
518
 
482
519
  if (this.ghostElement) {
483
- this.ghostElement.style.left = event.clientX - this.xOffset + 'px';
484
- this.ghostElement.style.top = event.clientY - this.yOffset + 'px';
520
+ this.ghostElement.style.left = clientX - this.xOffset + 'px';
521
+ this.ghostElement.style.top = clientY - this.yOffset + 'px';
485
522
 
486
523
  // check if the drag is over the container (only if external dragging is allowed)
487
524
  const isOverContainer = this.externalDrag
488
- ? this.isMouseOverContainer(event.clientX, event.clientY)
525
+ ? this.isMouseOverContainer(clientX, clientY)
489
526
  : true; // always consider "over container" if external drag is disabled
490
527
 
491
528
  // detect transition between internal and external drag (only if allowed)
@@ -501,8 +538,8 @@ export class SortableList extends RapidElement {
501
538
 
502
539
  this.fireCustomEvent(CustomEventType.DragExternal, {
503
540
  id: this.downEle.id,
504
- mouseX: event.clientX,
505
- mouseY: event.clientY
541
+ mouseX: clientX,
542
+ mouseY: clientY
506
543
  });
507
544
  } else if (this.externalDrag && isOverContainer && this.isExternalDrag) {
508
545
  // transitioning back to internal drag
@@ -520,7 +557,7 @@ export class SortableList extends RapidElement {
520
557
 
521
558
  // only show drop placeholder and calculate drop position if internal drag
522
559
  if (!this.isExternalDrag) {
523
- const targetInfo = this.getDropTargetInfo(event.clientX, event.clientY);
560
+ const targetInfo = this.getDropTargetInfo(clientX, clientY);
524
561
  if (targetInfo) {
525
562
  const { element: targetElement, insertAfter } = targetInfo;
526
563
  const targetIdx = this.getRowIndex(targetElement.id);
@@ -537,9 +574,6 @@ export class SortableList extends RapidElement {
537
574
  dropIdx = insertAfter ? targetIdx + 1 : targetIdx;
538
575
  } else {
539
576
  // Target was originally after the drag position - moving forward
540
- // When moving the dragged element forward (i.e., to a higher index), the targetIdx is based on the current DOM,
541
- // which no longer includes the dragged element. This means all elements after the original position have shifted left by one,
542
- // so we need to subtract 1 from targetIdx to get the correct insertion index. If inserting after the target, we use targetIdx as is.
543
577
  dropIdx = insertAfter ? targetIdx : targetIdx - 1;
544
578
  }
545
579
 
@@ -560,18 +594,30 @@ export class SortableList extends RapidElement {
560
594
  // external drag - continue firing external drag events with updated position
561
595
  this.fireCustomEvent(CustomEventType.DragExternal, {
562
596
  id: this.downEle.id,
563
- mouseX: event.clientX,
564
- mouseY: event.clientY
597
+ mouseX: clientX,
598
+ mouseY: clientY
565
599
  });
566
600
  }
567
601
  }
568
602
  }
569
603
 
570
- private handleMouseUp(evt: MouseEvent) {
571
- if (this.draggingId && this.ghostElement) {
572
- evt.preventDefault();
573
- evt.stopPropagation();
604
+ private handleMouseMove(event: MouseEvent) {
605
+ this.processDragMove(event.clientX, event.clientY);
606
+ }
607
+
608
+ /* c8 ignore next 6 -- touch-only */
609
+ private handleTouchMove(event: TouchEvent) {
610
+ const touch = event.touches[0];
611
+ if (!touch) return;
612
+ event.preventDefault();
613
+ this.processDragMove(touch.clientX, touch.clientY);
614
+ }
574
615
 
616
+ /**
617
+ * Shared drag-end logic for both mouse and touch.
618
+ */
619
+ private processDragEnd(clientX: number, clientY: number) {
620
+ if (this.draggingId && this.ghostElement) {
575
621
  // Remove the ghost clone from document.body
576
622
  if (this.ghostElement) {
577
623
  this.ghostElement.remove();
@@ -610,8 +656,8 @@ export class SortableList extends RapidElement {
610
656
  this.fireCustomEvent(CustomEventType.DragStop, {
611
657
  id: this.draggingId,
612
658
  isExternal: this.isExternalDrag,
613
- mouseX: evt.clientX,
614
- mouseY: evt.clientY
659
+ mouseX: clientX,
660
+ mouseY: clientY
615
661
  });
616
662
 
617
663
  this.draggingId = null;
@@ -639,18 +685,42 @@ export class SortableList extends RapidElement {
639
685
  }, 100);
640
686
  }
641
687
  }
688
+ }
689
+
690
+ private handleMouseUp(evt: MouseEvent) {
691
+ if (this.draggingId && this.ghostElement) {
692
+ evt.preventDefault();
693
+ evt.stopPropagation();
694
+ }
695
+ this.processDragEnd(evt.clientX, evt.clientY);
642
696
  document.removeEventListener('mousemove', this.handleMouseMove);
643
697
  document.removeEventListener('mouseup', this.handleMouseUp);
644
698
  this.dispatchEvent(new Event('change'));
645
699
  }
646
700
 
701
+ /* c8 ignore start -- touch-only */
702
+ private handleTouchEnd(evt: TouchEvent) {
703
+ const touch = evt.changedTouches[0];
704
+ const clientX = touch?.clientX ?? 0;
705
+ const clientY = touch?.clientY ?? 0;
706
+ this.processDragEnd(clientX, clientY);
707
+ document.removeEventListener('touchmove', this.handleTouchMove);
708
+ document.removeEventListener('touchend', this.handleTouchEnd);
709
+ document.removeEventListener('touchcancel', this.handleTouchEnd);
710
+ this.dispatchEvent(new Event('change'));
711
+ }
712
+ /* c8 ignore stop */
713
+
647
714
  public render(): TemplateResult {
648
715
  return html`
649
716
  <div
650
717
  class="container ${this.horizontal ? 'horizontal' : ''}"
651
718
  style="gap: ${this.gap}"
652
719
  >
653
- <slot @mousedown=${this.handleMouseDown}></slot>
720
+ <slot
721
+ @mousedown=${this.handleMouseDown}
722
+ @touchstart=${this.handleTouchStart}
723
+ ></slot>
654
724
  </div>
655
725
  `;
656
726
  }
@@ -582,6 +582,7 @@ export class Simulator extends RapidElement {
582
582
  flex-wrap: wrap;
583
583
  justify-content: center;
584
584
  gap: 6px;
585
+ padding: 0 12px;
585
586
  z-index: 9;
586
587
  }
587
588
 
@@ -594,7 +595,10 @@ export class Simulator extends RapidElement {
594
595
  font-size: 11px;
595
596
  cursor: pointer;
596
597
  transition: all 0.2s ease;
597
- flex-shrink: 0;
598
+ min-width: 0;
599
+ overflow: hidden;
600
+ text-overflow: ellipsis;
601
+ white-space: nowrap;
598
602
  }
599
603
 
600
604
  .quick-reply-btn:hover:not(:disabled) {
@@ -1944,7 +1948,7 @@ export class Simulator extends RapidElement {
1944
1948
  </button>
1945
1949
 
1946
1950
  <button class="option-btn" @click=${this.handleReset} title="Reset">
1947
- <temba-icon name="delete" size="1.5"></temba-icon>
1951
+ <temba-icon name="refresh" size="1.5"></temba-icon>
1948
1952
  </button>
1949
1953
  </div>
1950
1954
  </div>
@@ -48,19 +48,21 @@ describe('temba-canvas-menu', () => {
48
48
  await assertScreenshot('canvas-menu/open', getClip(menu));
49
49
  });
50
50
 
51
- it('has three menu items', async () => {
51
+ it('has five menu items', async () => {
52
52
  const menu = await createCanvasMenu();
53
53
  menu.show(100, 100, { x: 50, y: 50 });
54
54
  await menu.updateComplete;
55
55
 
56
56
  const menuItems = menu.shadowRoot?.querySelectorAll('.menu-item');
57
- expect(menuItems?.length).to.equal(3);
57
+ expect(menuItems?.length).to.equal(5);
58
58
 
59
59
  // check menu item titles
60
60
  const titles = Array.from(menuItems || []).map(
61
61
  (item) => item.querySelector('.menu-item-title')?.textContent
62
62
  );
63
63
  expect(titles).to.deep.equal([
64
+ 'Send Message',
65
+ 'Wait for Response',
64
66
  'Add Action',
65
67
  'Add Split',
66
68
  'Add Sticky Note'
@@ -93,9 +95,9 @@ describe('temba-canvas-menu', () => {
93
95
  selectionDetail = event.detail;
94
96
  });
95
97
 
96
- // click on sticky note option (now the third item)
98
+ // click on sticky note option (now the fifth item)
97
99
  const menuItems = menu.shadowRoot?.querySelectorAll('.menu-item');
98
- const stickyItem = menuItems?.[2] as HTMLElement;
100
+ const stickyItem = menuItems?.[4] as HTMLElement;
99
101
  stickyItem.click();
100
102
  await menu.updateComplete;
101
103
 
@@ -113,12 +115,14 @@ describe('temba-canvas-menu', () => {
113
115
  await menu.updateComplete;
114
116
 
115
117
  const menuItems = menu.shadowRoot?.querySelectorAll('.menu-item');
116
- expect(menuItems?.length).to.equal(4);
118
+ expect(menuItems?.length).to.equal(6);
117
119
 
118
120
  const titles = Array.from(menuItems || []).map(
119
121
  (item) => item.querySelector('.menu-item-title')?.textContent
120
122
  );
121
123
  expect(titles).to.deep.equal([
124
+ 'Send Message',
125
+ 'Wait for Response',
122
126
  'Add Action',
123
127
  'Add Split',
124
128
  'Add Sticky Note',
@@ -137,7 +141,7 @@ describe('temba-canvas-menu', () => {
137
141
  });
138
142
 
139
143
  const menuItems = menu.shadowRoot?.querySelectorAll('.menu-item');
140
- const reflowItem = menuItems?.[3] as HTMLElement;
144
+ const reflowItem = menuItems?.[5] as HTMLElement;
141
145
  reflowItem.click();
142
146
  await menu.updateComplete;
143
147
 
@@ -154,7 +158,7 @@ describe('temba-canvas-menu', () => {
154
158
  await menu.updateComplete;
155
159
 
156
160
  const menuItems = menu.shadowRoot?.querySelectorAll('.menu-item');
157
- expect(menuItems?.length).to.equal(3);
161
+ expect(menuItems?.length).to.equal(5);
158
162
 
159
163
  const titles = Array.from(menuItems || []).map(
160
164
  (item) => item.querySelector('.menu-item-title')?.textContent
@@ -200,8 +204,8 @@ describe('temba-canvas-menu', () => {
200
204
  });
201
205
 
202
206
  const menuItems = menu.shadowRoot?.querySelectorAll('.menu-item');
203
- const actionItem = menuItems?.[0] as HTMLElement;
204
- actionItem.click();
207
+ const sendMsgItem = menuItems?.[0] as HTMLElement;
208
+ sendMsgItem.click();
205
209
  await menu.updateComplete;
206
210
 
207
211
  expect(selectionFired).to.be.true;
@@ -375,8 +375,10 @@ describe('Reflow Layout', () => {
375
375
  ),
376
376
  ...childIds.map((id) => makeNode(id, []))
377
377
  ];
378
- const nodeUIs: Record<string, { position: { left: number; top: number } }> =
379
- { A: { position: { left: 0, top: 0 } } };
378
+ const nodeUIs: Record<
379
+ string,
380
+ { position: { left: number; top: number } }
381
+ > = { A: { position: { left: 0, top: 0 } } };
380
382
  childIds.forEach((id, i) => {
381
383
  nodeUIs[id] = { position: { left: i * 260, top: 200 } };
382
384
  });
@@ -343,7 +343,7 @@ describe('temba-node-editor', () => {
343
343
  expect(shadowRoot).to.not.be.null;
344
344
  });
345
345
 
346
- it('displays bubble count for group value counts', async () => {
346
+ it('displays bubble count for accordion value counts', async () => {
347
347
  const action = {
348
348
  uuid: 'test-action-uuid',
349
349
  type: 'send_msg',
@@ -362,11 +362,11 @@ describe('temba-node-editor', () => {
362
362
  await new Promise((resolve) => setTimeout(resolve, 200));
363
363
  await el.updateComplete;
364
364
 
365
- // Check that bubble counts are displayed
365
+ // Check that bubble counts are displayed in accordion sections
366
366
  const shadowRoot = el.shadowRoot;
367
- const bubbles = shadowRoot.querySelectorAll('.group-count-bubble');
367
+ const bubbles = shadowRoot.querySelectorAll('.accordion-count-bubble');
368
368
 
369
- // Should have bubbles for groups with values
369
+ // Should have bubbles for sections with values
370
370
  expect(bubbles.length).to.be.greaterThan(0);
371
371
 
372
372
  // Check specific bubble values (trim to handle whitespace in rendered text)
@@ -374,12 +374,11 @@ describe('temba-node-editor', () => {
374
374
  bubble.textContent?.trim()
375
375
  );
376
376
 
377
- // Runtime attachments group should show bubble when collapsed and has values
377
+ // Runtime attachments section should show bubble when collapsed and has values
378
378
  expect(bubbleTexts).to.include('2'); // 2 runtime attachments
379
- // Note: Quick replies group auto-expands when it has content, so no bubble is shown
380
379
  });
381
380
 
382
- it('shows arrow when group has no values', async () => {
381
+ it('shows arrow when accordion section has no values', async () => {
383
382
  const action = {
384
383
  uuid: 'test-action-uuid',
385
384
  type: 'send_msg',
@@ -399,13 +398,13 @@ describe('temba-node-editor', () => {
399
398
 
400
399
  // Check that arrows are displayed instead of bubbles
401
400
  const shadowRoot = el.shadowRoot;
402
- const bubbles = shadowRoot.querySelectorAll('.group-count-bubble');
403
- const arrows = shadowRoot.querySelectorAll('.group-toggle-icon');
401
+ const bubbles = shadowRoot.querySelectorAll('.accordion-count-bubble');
402
+ const arrows = shadowRoot.querySelectorAll('.accordion-toggle-icon');
404
403
 
405
404
  // Should have no bubbles when counts are 0
406
405
  expect(bubbles.length).to.equal(0);
407
406
 
408
- // Should have arrows for collapsible groups
407
+ // Should have arrows for accordion sections
409
408
  expect(arrows.length).to.be.greaterThan(0);
410
409
  });
411
410
 
@@ -186,7 +186,7 @@ describe('temba-node-type-selector', () => {
186
186
  expect(titles).to.not.include('Play Recording');
187
187
  });
188
188
 
189
- it('filters splits by flow type - message flow should show wait for response', async () => {
189
+ it('filters splits by flow type - message flow should not show wait for response in split dialog', async () => {
190
190
  const selector = await createSelector();
191
191
  selector.flowType = 'message';
192
192
  await selector.updateComplete;
@@ -199,8 +199,8 @@ describe('temba-node-type-selector', () => {
199
199
  item.textContent?.trim()
200
200
  );
201
201
 
202
- // message flow should have Wait for Response
203
- expect(titles).to.include('Wait for Response');
202
+ // Wait for Response is now promoted to the context menu, not in split dialog
203
+ expect(titles).to.not.include('Wait for Response');
204
204
  });
205
205
 
206
206
  it('filters splits by flow type - voice flow should not show wait for response', async () => {
@@ -734,12 +734,12 @@ describe('temba-simulator', () => {
734
734
  // mock the start response for reset
735
735
  mockSimulatorStart();
736
736
 
737
- // click the reset button (has delete icon)
737
+ // click the reset button (has refresh icon)
738
738
  const optionButtons = Array.from(
739
739
  simulator.shadowRoot.querySelectorAll('.option-btn')
740
740
  );
741
741
  const resetButton = optionButtons.find((btn) =>
742
- btn.querySelector('temba-icon[name="delete"]')
742
+ btn.querySelector('temba-icon[name="refresh"]')
743
743
  ) as HTMLElement;
744
744
  expect(resetButton).to.exist;
745
745
  resetButton.click();