@nyaruka/temba-components 0.130.5 → 0.131.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 (27) hide show
  1. package/CHANGELOG.md +3 -20
  2. package/dist/temba-components.js +65 -64
  3. package/dist/temba-components.js.map +1 -1
  4. package/out-tsc/src/flow/nodes/split_by_random.js +1 -0
  5. package/out-tsc/src/flow/nodes/split_by_random.js.map +1 -1
  6. package/out-tsc/src/flow/nodes/wait_for_response.js +254 -65
  7. package/out-tsc/src/flow/nodes/wait_for_response.js.map +1 -1
  8. package/out-tsc/src/form/ArrayEditor.js +195 -2
  9. package/out-tsc/src/form/ArrayEditor.js.map +1 -1
  10. package/out-tsc/src/form/select/Omnibox.js +4 -0
  11. package/out-tsc/src/form/select/Omnibox.js.map +1 -1
  12. package/out-tsc/test/nodes/wait_for_response.test.js +373 -8
  13. package/out-tsc/test/nodes/wait_for_response.test.js.map +1 -1
  14. package/package.json +1 -1
  15. package/screenshots/truth/nodes/split_by_random/editor/ab-test-multiple-variants.png +0 -0
  16. package/screenshots/truth/nodes/split_by_random/editor/sampling-split.png +0 -0
  17. package/screenshots/truth/nodes/split_by_random/editor/three-way-split.png +0 -0
  18. package/screenshots/truth/nodes/split_by_random/editor/two-bucket-split.png +0 -0
  19. package/screenshots/truth/nodes/wait_for_response/render/basic-wait.png +0 -0
  20. package/screenshots/truth/nodes/wait_for_response/render/custom-result-name.png +0 -0
  21. package/screenshots/truth/nodes/wait_for_response/render/no-timeout.png +0 -0
  22. package/screenshots/truth/nodes/wait_for_response/render/short-timeout.png +0 -0
  23. package/src/flow/nodes/split_by_random.ts +1 -0
  24. package/src/flow/nodes/wait_for_response.ts +327 -72
  25. package/src/form/ArrayEditor.ts +260 -2
  26. package/src/form/select/Omnibox.ts +3 -0
  27. package/test/nodes/wait_for_response.test.ts +426 -8
@@ -31,6 +31,14 @@ export class TembaArrayEditor extends BaseListEditor<ListItem> {
31
31
  @property({ type: Boolean })
32
32
  maintainEmptyItem = true; // Enable by default for better UX
33
33
 
34
+ // Focus preservation properties
35
+ private focusInfo: {
36
+ itemIndex: number;
37
+ fieldName: string;
38
+ selectionStart?: number;
39
+ selectionEnd?: number;
40
+ } | null = null;
41
+
34
42
  constructor() {
35
43
  super();
36
44
  this._items = [];
@@ -79,6 +87,236 @@ export class TembaArrayEditor extends BaseListEditor<ListItem> {
79
87
  });
80
88
  }
81
89
 
90
+ // Capture focus information before update
91
+ private captureFocus(): void {
92
+ const activeElement = this.shadowRoot?.activeElement as HTMLElement;
93
+
94
+ // Also try document.activeElement as a fallback
95
+ const globalActive = document.activeElement as HTMLElement;
96
+ let targetElement = activeElement || globalActive;
97
+
98
+ // If active element is within this component's shadow root, use it
99
+ if (globalActive && this.shadowRoot?.contains(globalActive)) {
100
+ targetElement = globalActive;
101
+ }
102
+
103
+ if (!targetElement) {
104
+ this.focusInfo = null;
105
+ return;
106
+ }
107
+
108
+ // Find the array item container by traversing up the DOM
109
+ let currentElement = targetElement;
110
+ let arrayItemElement: HTMLElement | null = null;
111
+
112
+ // Traverse up through shadow DOM boundaries
113
+ while (currentElement) {
114
+ if (currentElement.classList?.contains('array-item')) {
115
+ arrayItemElement = currentElement;
116
+ break;
117
+ }
118
+
119
+ // Move up to parent, or cross shadow boundaries
120
+ if (currentElement.parentElement) {
121
+ currentElement = currentElement.parentElement;
122
+ } else if (
123
+ currentElement.parentNode &&
124
+ (currentElement.parentNode as any).host
125
+ ) {
126
+ // Cross shadow boundary
127
+ currentElement = (currentElement.parentNode as any).host;
128
+ } else {
129
+ break;
130
+ }
131
+ }
132
+
133
+ if (!arrayItemElement) {
134
+ this.focusInfo = null;
135
+ return;
136
+ }
137
+
138
+ // Find the item index by looking at the item ID
139
+ const itemIdMatch = arrayItemElement.id?.match(/array-item-(\d+)/);
140
+ if (!itemIdMatch) {
141
+ this.focusInfo = null;
142
+ return;
143
+ }
144
+
145
+ const itemIndex = parseInt(itemIdMatch[1], 10);
146
+
147
+ // Determine the field name by examining the input element and its containers
148
+ let fieldName = '';
149
+
150
+ // First, check if it's a temba component with a name attribute
151
+ if (targetElement.tagName?.toLowerCase().startsWith('temba-')) {
152
+ fieldName =
153
+ (targetElement as any).name || targetElement.getAttribute('name') || '';
154
+ }
155
+
156
+ // If not found, check regular HTML elements
157
+ if (
158
+ !fieldName &&
159
+ targetElement.hasAttribute &&
160
+ targetElement.hasAttribute('name')
161
+ ) {
162
+ fieldName = targetElement.getAttribute('name') || '';
163
+ }
164
+
165
+ // If still not found, look for data-field-name in parent containers
166
+ if (!fieldName) {
167
+ let searchElement = targetElement;
168
+ while (searchElement && searchElement !== arrayItemElement) {
169
+ if (
170
+ searchElement.hasAttribute &&
171
+ searchElement.hasAttribute('data-field-name')
172
+ ) {
173
+ fieldName = searchElement.getAttribute('data-field-name') || '';
174
+ break;
175
+ }
176
+ searchElement = searchElement.parentElement;
177
+ }
178
+ }
179
+
180
+ if (!fieldName) {
181
+ this.focusInfo = null;
182
+ return;
183
+ }
184
+
185
+ // Capture selection for text inputs (try the actual input element inside temba components)
186
+ let inputForSelection = targetElement;
187
+ if (targetElement.tagName?.toLowerCase().startsWith('temba-')) {
188
+ // Look for the actual input element inside the temba component
189
+ const innerInput =
190
+ targetElement.shadowRoot?.querySelector('input, textarea') ||
191
+ targetElement.querySelector('input, textarea');
192
+ if (innerInput) {
193
+ inputForSelection = innerInput as HTMLElement;
194
+ }
195
+ }
196
+
197
+ const selectionStart = (inputForSelection as any).selectionStart;
198
+ const selectionEnd = (inputForSelection as any).selectionEnd;
199
+
200
+ this.focusInfo = {
201
+ itemIndex,
202
+ fieldName,
203
+ selectionStart,
204
+ selectionEnd
205
+ };
206
+ }
207
+
208
+ // Restore focus after update
209
+ private restoreFocus(): void {
210
+ if (!this.focusInfo) {
211
+ return;
212
+ }
213
+
214
+ const { itemIndex, fieldName, selectionStart, selectionEnd } =
215
+ this.focusInfo;
216
+
217
+ // Find the target element by array item index
218
+ const arrayItemId = `array-item-${itemIndex}`;
219
+ const arrayItemElement = this.shadowRoot?.getElementById(arrayItemId);
220
+
221
+ if (!arrayItemElement) {
222
+ // If the exact item doesn't exist (e.g., due to reordering), try to find by field name
223
+ const allItems = this.shadowRoot?.querySelectorAll('.array-item');
224
+ if (allItems && allItems.length > itemIndex) {
225
+ const fallbackItem = allItems[itemIndex];
226
+ if (fallbackItem) {
227
+ this.attemptFocusRestore(
228
+ fallbackItem as HTMLElement,
229
+ fieldName,
230
+ selectionStart,
231
+ selectionEnd
232
+ );
233
+ }
234
+ }
235
+ this.focusInfo = null;
236
+ return;
237
+ }
238
+
239
+ this.attemptFocusRestore(
240
+ arrayItemElement,
241
+ fieldName,
242
+ selectionStart,
243
+ selectionEnd
244
+ );
245
+ this.focusInfo = null;
246
+ }
247
+
248
+ private attemptFocusRestore(
249
+ container: HTMLElement,
250
+ fieldName: string,
251
+ selectionStart?: number,
252
+ selectionEnd?: number
253
+ ): void {
254
+ // Look for the field container first
255
+ const fieldContainer = container.querySelector(
256
+ `[data-field-name="${fieldName}"]`
257
+ );
258
+
259
+ let targetElement: HTMLElement | null = null;
260
+
261
+ if (fieldContainer) {
262
+ // Look for temba components or input elements within the field container
263
+ targetElement = fieldContainer.querySelector(
264
+ 'temba-textinput, temba-completion, input, textarea'
265
+ ) as HTMLElement;
266
+ }
267
+
268
+ // Fallback: search entire container
269
+ if (!targetElement) {
270
+ const selectors = [
271
+ `temba-textinput[name="${fieldName}"]`,
272
+ `temba-completion[name="${fieldName}"]`,
273
+ `input[name="${fieldName}"]`,
274
+ `textarea[name="${fieldName}"]`,
275
+ `[name="${fieldName}"]`
276
+ ];
277
+
278
+ for (const selector of selectors) {
279
+ targetElement = container.querySelector(selector) as HTMLElement;
280
+ if (targetElement) break;
281
+ }
282
+ }
283
+
284
+ if (targetElement) {
285
+ // Use multiple animation frames to ensure DOM is fully settled
286
+ requestAnimationFrame(() => {
287
+ requestAnimationFrame(() => {
288
+ try {
289
+ targetElement.focus();
290
+
291
+ // Restore selection if it's a text input
292
+ if (selectionStart !== undefined && selectionEnd !== undefined) {
293
+ // For temba components, we need to focus the inner input
294
+ let inputForSelection = targetElement;
295
+ if (targetElement.tagName?.toLowerCase().startsWith('temba-')) {
296
+ const innerInput =
297
+ targetElement.shadowRoot?.querySelector('input, textarea') ||
298
+ targetElement.querySelector('input, textarea');
299
+ if (innerInput && 'setSelectionRange' in innerInput) {
300
+ inputForSelection = innerInput as any;
301
+ }
302
+ }
303
+
304
+ if ('setSelectionRange' in inputForSelection) {
305
+ (inputForSelection as any).setSelectionRange(
306
+ selectionStart,
307
+ selectionEnd
308
+ );
309
+ }
310
+ }
311
+ } catch (error) {
312
+ // Ignore focus errors - element might not be focusable
313
+ // Focus restoration failed, silently continue
314
+ }
315
+ });
316
+ });
317
+ }
318
+ }
319
+
82
320
  createEmptyItem(): ListItem {
83
321
  return {};
84
322
  }
@@ -108,6 +346,25 @@ export class TembaArrayEditor extends BaseListEditor<ListItem> {
108
346
  this.updateValue(updatedItems);
109
347
  }
110
348
 
349
+ // Override Lit's update lifecycle methods for focus preservation
350
+ protected willUpdate(changedProperties: Map<string, any>): void {
351
+ super.willUpdate(changedProperties);
352
+
353
+ // Capture focus before update if items are changing
354
+ if (changedProperties.has('_items') || changedProperties.has('value')) {
355
+ this.captureFocus();
356
+ }
357
+ }
358
+
359
+ updated(changedProperties: Map<string, any>): void {
360
+ super.updated(changedProperties);
361
+
362
+ // Restore focus after update if items changed
363
+ if (changedProperties.has('_items') || changedProperties.has('value')) {
364
+ this.restoreFocus();
365
+ }
366
+ }
367
+
111
368
  private handleOrderChanged(event: CustomEvent): void {
112
369
  const detail = event.detail;
113
370
 
@@ -286,6 +543,7 @@ export class TembaArrayEditor extends BaseListEditor<ListItem> {
286
543
 
287
544
  fieldElements.push(html`
288
545
  <div
546
+ data-field-name="${fieldName}"
289
547
  style="${config.width || config.maxWidth || config.type === 'select'
290
548
  ? 'flex:none'
291
549
  : 'flex:1'}"
@@ -302,12 +560,12 @@ export class TembaArrayEditor extends BaseListEditor<ListItem> {
302
560
  fieldElements.splice(
303
561
  -1,
304
562
  0,
305
- html`<div class="field field-flex spacer"></div>`
563
+ html`<div class="field field-flex spacer" style="flex-grow:1"></div>`
306
564
  );
307
565
  }
308
566
 
309
567
  return html`
310
- <div class="array-item">
568
+ <div class="array-item" id="array-item-${index}">
311
569
  <div
312
570
  class="item-fields ${canRemove ? '' : 'removable'}"
313
571
  style="display: flex; gap: 12px; align-items: center"
@@ -41,6 +41,9 @@ export class Omnibox extends Select<OmniOption> {
41
41
  @property({ type: Boolean })
42
42
  multi = true;
43
43
 
44
+ @property({ type: Boolean })
45
+ jsonValue = true;
46
+
44
47
  @property({ type: Boolean })
45
48
  searchable = true;
46
49