@nyaruka/temba-components 0.171.0 → 0.172.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +23 -1
  2. package/dist/temba-components.js +1015 -747
  3. package/dist/temba-components.js.map +1 -1
  4. package/package.json +1 -1
  5. package/src/RapidElement.ts +83 -41
  6. package/src/display/Button.ts +14 -2
  7. package/src/display/Chat.ts +41 -22
  8. package/src/display/Label.ts +18 -3
  9. package/src/events/eventRenderers.ts +4 -0
  10. package/src/flow/FlowSearch.ts +12 -392
  11. package/src/flow/actions/send_msg.ts +2 -1
  12. package/src/flow/actions/set_contact_field.ts +1 -0
  13. package/src/flow/actions/set_contact_name.ts +1 -0
  14. package/src/flow/nodes/split_by_ticket.ts +1 -0
  15. package/src/flow/nodes/wait_for_dial.ts +1 -0
  16. package/src/form/RangePicker.ts +10 -2
  17. package/src/form/select/Select.ts +15 -2
  18. package/src/layout/SearchModal.ts +564 -0
  19. package/src/list/BroadcastList.ts +17 -6
  20. package/src/list/ContentList.ts +29 -13
  21. package/src/list/FieldList.ts +8 -1
  22. package/src/list/TembaMenu.ts +71 -13
  23. package/src/live/CampaignEvents.ts +31 -11
  24. package/src/live/ContactChat.ts +276 -76
  25. package/src/live/ContactTimeline.ts +46 -24
  26. package/src/live/ContactWatch.ts +166 -63
  27. package/src/live/Realtime.ts +137 -8
  28. package/src/live/SocketService.ts +115 -10
  29. package/src/live/TicketSearch.ts +290 -0
  30. package/src/live/Watchers.ts +93 -0
  31. package/src/simulator/Simulator.ts +59 -36
  32. package/src/store/Store.ts +17 -34
  33. package/src/styles/designTokens.ts +57 -17
  34. package/src/styles/pillVariants.ts +42 -10
  35. package/static/css/design-system.css +47 -12
  36. package/static/css/temba-components.css +31 -9
  37. package/temba-modules.ts +2 -0
@@ -1,5 +1,4 @@
1
- import { html, css, LitElement, TemplateResult } from 'lit';
2
- import { property, state, query } from 'lit/decorators.js';
1
+ import { property } from 'lit/decorators.js';
3
2
  import {
4
3
  FlowDefinition,
5
4
  Node,
@@ -37,8 +36,8 @@ import {
37
36
  NodeConfig
38
37
  } from './types';
39
38
  import { localizeAction } from './utils';
40
- import { Icon } from '../Icons';
41
39
  import { getTranslatableCategoriesForNode } from './categoryLocalization';
40
+ import { ModalSearchResult, SearchModal } from '../layout/SearchModal';
42
41
 
43
42
  // Sticky note badge colors: border matches the sticky's border, fill is 25% lighter
44
43
  const STICKY_BADGE_COLORS: Record<
@@ -52,24 +51,10 @@ const STICKY_BADGE_COLORS: Record<
52
51
  gray: { border: '#6b7280', fill: '#8f949f', text: '#fff' }
53
52
  };
54
53
 
55
- export interface SearchResult {
54
+ export interface SearchResult extends ModalSearchResult {
56
55
  nodeUuid: string;
57
56
  // For actions: the action object; for nodes (splits): null
58
57
  action: Action | null;
59
- // The config name (e.g. "Send Message", "Call Webhook")
60
- typeName: string;
61
- // Color for the type badge background
62
- color: string;
63
- // Optional border color for the badge (used by sticky notes)
64
- borderColor?: string;
65
- // Optional text color override for the badge
66
- textColor?: string;
67
- // The full text that was searched
68
- fullText: string;
69
- // The index of the match start in fullText
70
- matchStart: number;
71
- // The length of the match
72
- matchLength: number;
73
58
  // For sticky notes: which field matched ('title' or 'body')
74
59
  stickyField?: 'title' | 'body';
75
60
  }
@@ -353,183 +338,7 @@ function localizeCategoryName(
353
338
  return originalName;
354
339
  }
355
340
 
356
- export class FlowSearch extends LitElement {
357
- static styles = css`
358
- :host {
359
- position: fixed;
360
- top: 0;
361
- left: 0;
362
- right: 0;
363
- bottom: 0;
364
- z-index: 100000;
365
- display: none;
366
- font-family: var(--font-family, sans-serif);
367
- }
368
-
369
- :host([open]) {
370
- display: flex;
371
- align-items: flex-start;
372
- justify-content: center;
373
- }
374
-
375
- .backdrop {
376
- position: absolute;
377
- top: 0;
378
- left: 0;
379
- right: 0;
380
- bottom: 0;
381
- background: rgba(0, 0, 0, 0.3);
382
- }
383
-
384
- .search-container {
385
- position: relative;
386
- margin-top: 80px;
387
- width: 560px;
388
- max-height: calc(100vh - 160px);
389
- background: white;
390
- border-radius: 14px;
391
- box-shadow:
392
- 0 24px 80px rgba(0, 0, 0, 0.2),
393
- 0 0 0 1px rgba(0, 0, 0, 0.05);
394
- display: flex;
395
- flex-direction: column;
396
- overflow: hidden;
397
- animation: searchSlideIn 0.15s ease-out;
398
- }
399
-
400
- @keyframes searchSlideIn {
401
- from {
402
- opacity: 0;
403
- transform: scale(0.98) translateY(-8px);
404
- }
405
- to {
406
- opacity: 1;
407
- transform: scale(1) translateY(0);
408
- }
409
- }
410
-
411
- .search-input-row {
412
- display: flex;
413
- align-items: center;
414
- padding: 14px 18px;
415
- border-bottom: 1px solid #e5e7eb;
416
- gap: 10px;
417
- }
418
-
419
- .search-icon {
420
- color: #9ca3af;
421
- flex-shrink: 0;
422
- }
423
-
424
- .search-icon temba-icon {
425
- --icon-color: #9ca3af;
426
- display: block;
427
- }
428
-
429
- input {
430
- flex: 1;
431
- border: none;
432
- outline: none;
433
- font-size: 16px;
434
- background: transparent;
435
- color: #111;
436
- font-family: inherit;
437
- }
438
-
439
- input::placeholder {
440
- color: #9ca3af;
441
- }
442
-
443
- .results {
444
- overflow-y: auto;
445
- max-height: 400px;
446
- }
447
-
448
- .result-item {
449
- display: flex;
450
- align-items: center;
451
- padding: 10px 18px;
452
- gap: 12px;
453
- cursor: pointer;
454
- border-bottom: 1px solid #f3f4f6;
455
- transition: background 0.08s;
456
- }
457
-
458
- .result-item:last-child {
459
- border-bottom: none;
460
- }
461
-
462
- .result-item:hover,
463
- .result-item.highlighted {
464
- background: #f0f4ff;
465
- }
466
-
467
- .result-type-badge {
468
- flex-shrink: 0;
469
- font-size: 11px;
470
- font-weight: 600;
471
- color: white;
472
- padding: 2px 10px;
473
- border-radius: 8px;
474
- border: 2px solid transparent;
475
- white-space: nowrap;
476
- text-align: center;
477
- box-sizing: border-box;
478
- }
479
-
480
- .result-text {
481
- min-width: 0;
482
- font-size: 14px;
483
- color: #374151;
484
- line-height: 1.4;
485
- white-space: nowrap;
486
- overflow: hidden;
487
- text-overflow: ellipsis;
488
- }
489
-
490
- .result-text mark {
491
- background: #fef08a;
492
- color: inherit;
493
- border-radius: 2px;
494
- padding: 0 1px;
495
- }
496
-
497
- .no-results {
498
- padding: 24px 18px;
499
- text-align: center;
500
- color: #9ca3af;
501
- font-size: 14px;
502
- }
503
-
504
- .result-count {
505
- padding: 8px 18px;
506
- font-size: 12px;
507
- color: #9ca3af;
508
- border-top: 1px solid #e5e7eb;
509
- text-align: right;
510
- flex-shrink: 0;
511
- }
512
-
513
- .hint {
514
- padding: 10px 18px;
515
- font-size: 12px;
516
- color: #9ca3af;
517
- text-align: center;
518
- }
519
-
520
- kbd {
521
- background: #f3f4f6;
522
- border: 1px solid #d1d5db;
523
- border-radius: 4px;
524
- padding: 1px 5px;
525
- font-size: 11px;
526
- font-family: inherit;
527
- }
528
- `;
529
-
530
- @property({ type: Boolean, reflect: true })
531
- open = false;
532
-
341
+ export class FlowSearch extends SearchModal<SearchResult> {
533
342
  @property({ attribute: false })
534
343
  definition: FlowDefinition | null = null;
535
344
 
@@ -542,96 +351,16 @@ export class FlowSearch extends LitElement {
542
351
  @property({ type: Boolean })
543
352
  includeCategories = false;
544
353
 
545
- @state()
546
- private searchQuery = '';
547
-
548
- @state()
549
- private results: SearchResult[] = [];
550
-
551
- @state()
552
- private highlightedIndex = 0;
553
-
554
- @query('input')
555
- private inputEl!: HTMLInputElement;
556
-
557
- public show(): void {
558
- this.open = true;
559
- this.searchQuery = '';
560
- this.results = [];
561
- this.highlightedIndex = 0;
562
- this.updateComplete.then(() => {
563
- this.inputEl?.focus();
564
- this.inputEl?.select();
565
- });
566
- }
567
-
568
- public hide(): void {
569
- this.open = false;
570
- this.searchQuery = '';
571
- this.results = [];
572
- }
573
-
574
- private handleInput(e: InputEvent): void {
575
- const input = e.target as HTMLInputElement;
576
- this.searchQuery = input.value;
577
- this.performSearch();
578
- this.highlightedIndex = 0;
579
- }
580
-
581
- private handleKeyDown(e: KeyboardEvent): void {
582
- if (e.key === 'Escape') {
583
- e.preventDefault();
584
- this.hide();
585
- return;
586
- }
587
- if (e.key === 'ArrowDown' || (e.ctrlKey && e.key === 'n')) {
588
- e.preventDefault();
589
- if (this.results.length > 0) {
590
- this.highlightedIndex =
591
- (this.highlightedIndex + 1) % this.results.length;
592
- }
593
- return;
594
- }
595
- if (e.key === 'ArrowUp' || (e.ctrlKey && e.key === 'p')) {
596
- e.preventDefault();
597
- if (this.results.length > 0) {
598
- this.highlightedIndex =
599
- (this.highlightedIndex - 1 + this.results.length) %
600
- this.results.length;
601
- }
602
- return;
603
- }
604
- if (e.key === 'Enter') {
605
- e.preventDefault();
606
- if (this.results.length > 0) {
607
- this.selectResult(this.results[this.highlightedIndex]);
608
- }
609
- return;
610
- }
611
- }
612
-
613
- private handleBackdropClick(): void {
614
- this.hide();
615
- }
616
-
617
- private selectResult(result: SearchResult): void {
618
- this.hide();
619
- this.dispatchEvent(
620
- new CustomEvent('temba-search-result-selected', {
621
- detail: result,
622
- bubbles: true,
623
- composed: true
624
- })
625
- );
354
+ protected getSearchLabel(): string {
355
+ return this.scope === 'table' ? 'Search this table' : 'Search this flow';
626
356
  }
627
357
 
628
- private performSearch(): void {
629
- if (!this.definition || !this.searchQuery.trim()) {
630
- this.results = [];
631
- return;
358
+ protected performSearch(rawQuery: string): SearchResult[] {
359
+ if (!this.definition) {
360
+ return [];
632
361
  }
633
362
 
634
- const query = this.searchQuery.toLowerCase();
363
+ const query = rawQuery.toLowerCase();
635
364
 
636
365
  // Get the localization map for the active language (if translating)
637
366
  const isTranslating =
@@ -641,11 +370,10 @@ export class FlowSearch extends LitElement {
641
370
  : undefined;
642
371
 
643
372
  if (this.scope === 'table') {
644
- this.results = this.performTableSearch(query, langLocalization);
645
- return;
373
+ return this.performTableSearch(query, langLocalization);
646
374
  }
647
375
 
648
- this.results = this.performFlowSearch(query, langLocalization);
376
+ return this.performFlowSearch(query, langLocalization);
649
377
  }
650
378
 
651
379
  private performFlowSearch(
@@ -889,112 +617,4 @@ export class FlowSearch extends LitElement {
889
617
 
890
618
  return results;
891
619
  }
892
-
893
- private renderMatchText(result: SearchResult): TemplateResult {
894
- const { matchStart, matchLength } = result;
895
- const matchEnd = matchStart + matchLength;
896
-
897
- // Replace newlines with spaces for single-line display
898
- const text = result.fullText.replace(/\n/g, ' ');
899
-
900
- // Type-name matches show an unhighlighted content preview
901
- if (matchLength === 0) {
902
- return html`${text}`;
903
- }
904
-
905
- // Show leading context before the match, then the match, then trailing.
906
- // CSS text-overflow:ellipsis clips the trailing text, so we keep the match
907
- // near the start. When the match is near the end of the text, show more
908
- // leading context since there's less trailing text to display.
909
- const afterMatch = text.length - matchEnd;
910
- const contextBefore = afterMatch < 30 ? 50 : 20;
911
- const start = Math.max(0, matchStart - contextBefore);
912
- const prefix = start > 0 ? '\u2026' : '';
913
-
914
- const before = text.slice(start, matchStart);
915
- const match = text.slice(matchStart, matchEnd);
916
- const after = text.slice(matchEnd);
917
-
918
- return html`${prefix}${before}<mark>${match}</mark>${after}`;
919
- }
920
-
921
- updated(changedProps: Map<string, unknown>): void {
922
- if (changedProps.has('highlightedIndex')) {
923
- const highlighted = this.shadowRoot?.querySelector(
924
- '.result-item.highlighted'
925
- );
926
- highlighted?.scrollIntoView({ block: 'nearest' });
927
- }
928
- }
929
-
930
- render(): TemplateResult {
931
- const isTableScope = this.scope === 'table';
932
- const searchLabel = isTableScope ? 'Search this table' : 'Search this flow';
933
-
934
- return html`
935
- <div class="backdrop" @click=${this.handleBackdropClick}></div>
936
- <div
937
- class="search-container"
938
- role="dialog"
939
- aria-modal="true"
940
- aria-label=${searchLabel}
941
- @click=${(e: Event) => e.stopPropagation()}
942
- >
943
- <div class="search-input-row">
944
- <div class="search-icon">
945
- <temba-icon name=${Icon.search} size="1.5"></temba-icon>
946
- </div>
947
- <input
948
- type="text"
949
- placeholder="${searchLabel}..."
950
- aria-label=${searchLabel}
951
- .value=${this.searchQuery}
952
- @input=${this.handleInput}
953
- @keydown=${this.handleKeyDown}
954
- />
955
- </div>
956
- ${this.searchQuery.trim()
957
- ? this.results.length > 0
958
- ? html`
959
- <div class="results">
960
- ${this.results.map(
961
- (result, index) => html`
962
- <div
963
- class="result-item ${index === this.highlightedIndex
964
- ? 'highlighted'
965
- : ''}"
966
- @click=${() => this.selectResult(result)}
967
- @mouseenter=${() => {
968
- this.highlightedIndex = index;
969
- }}
970
- >
971
- <div
972
- class="result-type-badge"
973
- style="background:${result.color};border-color:${result.borderColor ||
974
- result.color}${result.textColor
975
- ? `;color:${result.textColor}`
976
- : ''}"
977
- >
978
- ${result.typeName}
979
- </div>
980
- <div class="result-text">
981
- ${this.renderMatchText(result)}
982
- </div>
983
- </div>
984
- `
985
- )}
986
- </div>
987
- <div class="result-count">
988
- ${this.results.length}
989
- result${this.results.length !== 1 ? 's' : ''}
990
- </div>
991
- `
992
- : html`<div class="no-results">No matches found</div>`
993
- : html`<div class="hint">
994
- <kbd>↑</kbd> <kbd>↓</kbd> to navigate &nbsp; <kbd>Enter</kbd> to
995
- open &nbsp; <kbd>Esc</kbd> to close
996
- </div>`}
997
- </div>
998
- `;
999
- }
1000
620
  }
@@ -117,7 +117,8 @@ export const send_msg: ActionConfig = {
117
117
  type: 'text',
118
118
  placeholder: 'Expression (e.g. @contact.photo)',
119
119
  required: true,
120
- evaluated: true
120
+ evaluated: true,
121
+ maxLength: 8192
121
122
  }
122
123
  }
123
124
  }
@@ -45,6 +45,7 @@ export const set_contact_field: ActionConfig = {
45
45
  label: 'Value',
46
46
  placeholder: 'Enter field value...',
47
47
  evaluated: true,
48
+ maxLength: 10000,
48
49
  helpText:
49
50
  'The new value for the contact field. You can use expressions like @contact.name'
50
51
  }
@@ -20,6 +20,7 @@ export const set_contact_name: ActionConfig = {
20
20
  placeholder: 'Enter contact name...',
21
21
  required: true,
22
22
  evaluated: true,
23
+ maxLength: 1000,
23
24
  helpText:
24
25
  'The new name for the contact. You can use expressions like @contact.name'
25
26
  }
@@ -43,6 +43,7 @@ export const split_by_ticket: NodeConfig = {
43
43
  required: false,
44
44
  evaluated: true,
45
45
  placeholder: 'Enter a note for the ticket (optional)',
46
+ maxLength: 10000,
46
47
  minHeight: 100
47
48
  }
48
49
  },
@@ -34,6 +34,7 @@ export const wait_for_dial: NodeConfig = {
34
34
  label: 'Phone Number',
35
35
  required: true,
36
36
  evaluated: true,
37
+ maxLength: 1000,
37
38
  placeholder: 'Phone number or expression'
38
39
  },
39
40
  dial_limit_seconds: {
@@ -123,12 +123,20 @@ export class RangePicker extends RapidElement {
123
123
  /* The hover wash sits on the sunken track, so --sunken itself
124
124
  would be invisible here. Derived from --text-1 rather than a raw
125
125
  rgba literal so it follows the palette (the tokens file mixes
126
- against transparent the same way for --focus-halo). */
126
+ against transparent the same way for --focus-halo). The rgba
127
+ fallback pre-mixes the default --text-1 for browsers without
128
+ color-mix(). */
127
129
  .range-btn:hover:not(.selected) {
128
- background: color-mix(in srgb, var(--text-1) 6%, transparent);
130
+ background: rgba(26, 31, 38, 0.06);
129
131
  color: var(--text-1);
130
132
  }
131
133
 
134
+ @supports (color: color-mix(in srgb, red, red)) {
135
+ .range-btn:hover:not(.selected) {
136
+ background: color-mix(in srgb, var(--text-1) 6%, transparent);
137
+ }
138
+ }
139
+
132
140
  .range-btn.selected {
133
141
  background: var(--surface);
134
142
  color: var(--accent-700);
@@ -371,14 +371,27 @@ export class Select<T extends SelectOption> extends FieldElement {
371
371
  margin: 0;
372
372
  border: 0;
373
373
  border-radius: 999px;
374
- background: color-mix(in oklab, currentColor 25%, transparent);
374
+ /* Neutral wash first for browsers without color-mix(); the
375
+ currentColor-tinted version below needs the @supports gate
376
+ because currentColor keeps the declaration valid until
377
+ computed-value time. */
378
+ background: rgba(0, 0, 0, 0.12);
375
379
  color: inherit;
376
380
  opacity: 0.8;
377
381
  --icon-color: currentColor;
378
382
  }
379
383
  .multi .remove-item:hover {
380
384
  opacity: 1;
381
- background: color-mix(in oklab, currentColor 45%, transparent);
385
+ background: rgba(0, 0, 0, 0.22);
386
+ }
387
+
388
+ @supports (color: color-mix(in srgb, red, red)) {
389
+ .multi .remove-item {
390
+ background: color-mix(in oklab, currentColor 25%, transparent);
391
+ }
392
+ .multi .remove-item:hover {
393
+ background: color-mix(in oklab, currentColor 45%, transparent);
394
+ }
382
395
  }
383
396
 
384
397
  input {