@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.
- package/CHANGELOG.md +3 -20
- package/dist/temba-components.js +65 -64
- package/dist/temba-components.js.map +1 -1
- package/out-tsc/src/flow/nodes/split_by_random.js +1 -0
- package/out-tsc/src/flow/nodes/split_by_random.js.map +1 -1
- package/out-tsc/src/flow/nodes/wait_for_response.js +254 -65
- package/out-tsc/src/flow/nodes/wait_for_response.js.map +1 -1
- package/out-tsc/src/form/ArrayEditor.js +195 -2
- package/out-tsc/src/form/ArrayEditor.js.map +1 -1
- package/out-tsc/src/form/select/Omnibox.js +4 -0
- package/out-tsc/src/form/select/Omnibox.js.map +1 -1
- package/out-tsc/test/nodes/wait_for_response.test.js +373 -8
- package/out-tsc/test/nodes/wait_for_response.test.js.map +1 -1
- package/package.json +1 -1
- package/screenshots/truth/nodes/split_by_random/editor/ab-test-multiple-variants.png +0 -0
- package/screenshots/truth/nodes/split_by_random/editor/sampling-split.png +0 -0
- package/screenshots/truth/nodes/split_by_random/editor/three-way-split.png +0 -0
- package/screenshots/truth/nodes/split_by_random/editor/two-bucket-split.png +0 -0
- package/screenshots/truth/nodes/wait_for_response/render/basic-wait.png +0 -0
- package/screenshots/truth/nodes/wait_for_response/render/custom-result-name.png +0 -0
- package/screenshots/truth/nodes/wait_for_response/render/no-timeout.png +0 -0
- package/screenshots/truth/nodes/wait_for_response/render/short-timeout.png +0 -0
- package/src/flow/nodes/split_by_random.ts +1 -0
- package/src/flow/nodes/wait_for_response.ts +327 -72
- package/src/form/ArrayEditor.ts +260 -2
- package/src/form/select/Omnibox.ts +3 -0
- package/test/nodes/wait_for_response.test.ts +426 -8
package/src/form/ArrayEditor.ts
CHANGED
|
@@ -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"
|