@nyaruka/temba-components 0.156.17 → 0.157.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 (39) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/temba-components.js +1189 -767
  3. package/dist/temba-components.js.map +1 -1
  4. package/package.json +1 -1
  5. package/src/display/Chat.ts +14 -0
  6. package/src/display/Label.ts +156 -2
  7. package/src/display/Options.ts +71 -16
  8. package/src/display/TembaUser.ts +23 -5
  9. package/src/events/eventRenderers.ts +104 -41
  10. package/src/excellent/caret-utils.ts +0 -1
  11. package/src/flow/RevisionsWindow.ts +53 -9
  12. package/src/flow/nodes/shared.ts +14 -0
  13. package/src/flow/nodes/split_by_llm_categorize.ts +33 -8
  14. package/src/flow/revision-summary.ts +25 -0
  15. package/src/flow/utils.ts +38 -40
  16. package/src/form/ArrayEditor.ts +9 -11
  17. package/src/form/Checkbox.ts +2 -2
  18. package/src/form/Compose.ts +1 -1
  19. package/src/form/FieldElement.ts +8 -8
  20. package/src/form/KeyValueEditor.ts +4 -4
  21. package/src/form/MessageEditor.ts +2 -3
  22. package/src/form/RangePicker.ts +17 -17
  23. package/src/form/TembaSlider.ts +10 -10
  24. package/src/form/TemplateEditor.ts +4 -4
  25. package/src/form/TextInput.ts +19 -1
  26. package/src/form/select/Omnibox.ts +22 -19
  27. package/src/form/select/Select.ts +379 -171
  28. package/src/form/select/WorkspaceSelect.ts +7 -1
  29. package/src/layout/Accordion.ts +2 -2
  30. package/src/layout/Modax.ts +1 -1
  31. package/src/list/SortableList.ts +159 -0
  32. package/src/live/ContactChat.ts +46 -44
  33. package/src/live/ContactDetails.ts +1 -0
  34. package/src/live/ContactFieldEditor.ts +38 -31
  35. package/src/live/FieldManager.ts +4 -4
  36. package/src/styles/designTokens.ts +145 -0
  37. package/src/styles/pillVariants.ts +136 -0
  38. package/static/css/temba-components.css +106 -28
  39. package/web-test-runner.config.mjs +98 -0
@@ -35,6 +35,11 @@ import {
35
35
  tokenCss
36
36
  } from '../../excellent/token-styles';
37
37
  import { Store } from '../../store/Store';
38
+ import {
39
+ pillVariants,
40
+ PILL_TYPES,
41
+ PILL_TYPE_ICONS
42
+ } from '../../styles/pillVariants';
38
43
  import { StyleInfo, styleMap } from 'lit-html/directives/style-map.js';
39
44
  import { Icon } from '../../Icons';
40
45
  import { msg } from '@lit/localize';
@@ -47,14 +52,38 @@ export interface SelectOption {
47
52
  expression?: boolean;
48
53
  selected?: boolean;
49
54
  arbitrary?: boolean;
55
+ /**
56
+ * Pill variant for this option. Lets a single select hold mixed types
57
+ * (e.g. Omnibox: groups + contacts) and color each chip correctly.
58
+ * Falls back to the widget's resolved pill type — see `Select.getPillType`.
59
+ */
60
+ type?: string;
50
61
  }
51
62
 
63
+ /**
64
+ * Endpoint URL → pill variant. Each pattern is anchored on a path
65
+ * terminator (`/`, `.json`, `?`, end-of-string) so it can't match
66
+ * substrings like `/groupsearch` or `/contact/group/<uuid>/`. Adding a
67
+ * new entry here is the only change needed to make a new endpoint
68
+ * auto-color its chips. (PILL_TYPES / PILL_TYPE_ICONS live in
69
+ * src/styles/pillVariants.ts — the single source of truth shared with
70
+ * Label / flow utils.)
71
+ */
72
+ const ENDPOINT_PILL_TYPES: { pattern: RegExp; type: string }[] = [
73
+ { pattern: /\/groups(\.json|\/|\?|$)/, type: 'group' },
74
+ { pattern: /\/contacts(\.json|\/|\?|$)/, type: 'contact' },
75
+ { pattern: /\/labels(\.json|\/|\?|$)/, type: 'label' },
76
+ { pattern: /\/flows(\.json|\/|\?|$)/, type: 'flow' },
77
+ { pattern: /\/fields(\.json|\/|\?|$)/, type: 'field' }
78
+ ];
79
+
52
80
  export class Select<T extends SelectOption> extends FieldElement {
53
81
  private hiddenInputs: HTMLInputElement[] = [];
54
82
 
55
83
  static get styles() {
56
84
  return css`
57
85
  ${super.styles}
86
+ ${pillVariants}
58
87
 
59
88
  :host {
60
89
  --transition-speed: 0;
@@ -71,10 +100,13 @@ export class Select<T extends SelectOption> extends FieldElement {
71
100
  --temba-options-font-size: var(--temba-select-selected-font-size);
72
101
  --icon-color: var(--color-text-dark);
73
102
  --color-options-bg: #fff;
74
- /* Always use normal border colors for options popup, even when select is in error state */
75
- --color-widget-border: #ddd;
76
- --color-focus: #007bff;
77
- --widget-box-shadow-focused: 0 0 0 3px rgba(0, 123, 255, 0.25);
103
+ /* Keep the popup neutral when the parent field is in an
104
+ error state — FieldElement's .has-error sets
105
+ --color-widget-border / --color-focus to red, which would
106
+ otherwise cascade into the popup. The popup itself uses
107
+ --focus-muted / --focus-halo (error-immune) for its
108
+ outline; here we only reset the widget-border alias. */
109
+ --color-widget-border: var(--border-strong);
78
110
  }
79
111
 
80
112
  :host:focus {
@@ -88,22 +120,6 @@ export class Select<T extends SelectOption> extends FieldElement {
88
120
  height: 25px;
89
121
  }
90
122
 
91
- .remove-item {
92
- cursor: pointer;
93
- display: inline-block;
94
- padding: 3px 6px;
95
- border-right: 1px solid rgba(100, 100, 100, 0.2);
96
- margin: 0;
97
- }
98
-
99
- .selected-item.multi .remove-item {
100
- display: none;
101
- }
102
-
103
- .remove-item:hover {
104
- background: rgba(100, 100, 100, 0.1);
105
- }
106
-
107
123
  input:focus {
108
124
  outline: none;
109
125
  box-shadow: none;
@@ -111,11 +127,8 @@ export class Select<T extends SelectOption> extends FieldElement {
111
127
  }
112
128
 
113
129
  .wrapper-bg {
114
- background: var(--select-wrapper-bg, #fff);
115
- box-shadow: var(
116
- --select-wrapper-shadow,
117
- inset 0px 0px 4px rgb(0 0 0 / 10%)
118
- );
130
+ background: var(--select-wrapper-bg, var(--surface));
131
+ box-shadow: var(--select-wrapper-shadow, none);
119
132
  border-radius: var(--curvature-widget);
120
133
  }
121
134
 
@@ -125,16 +138,21 @@ export class Select<T extends SelectOption> extends FieldElement {
125
138
  flex-wrap: nowrap;
126
139
  align-items: center;
127
140
  border: 1px solid var(--color-widget-border);
128
- transition: all ease-in-out var(--transition-speed);
141
+ transition:
142
+ border-color 120ms ease-in-out,
143
+ box-shadow 120ms ease-in-out;
129
144
  cursor: pointer;
130
145
  border-radius: var(--curvature-widget);
131
146
  background: var(--color-widget-bg);
132
- padding-top: 1px;
133
147
  box-shadow: var(--widget-box-shadow);
134
148
  position: relative;
135
- min-height: var(--temba-select-min-height, 2.4em);
149
+ min-height: var(--temba-select-min-height, 34px);
136
150
  max-height: var(--temba-select-max-height, none);
137
- overflow: hidden;
151
+ /* Default clip so chevron / chip overflow doesn't escape the
152
+ widget. Embedded-label use cases (e.g. ContactFieldEditor's
153
+ location selects) override this to visible so the slotted
154
+ prefix label can extend above the top border. */
155
+ overflow: var(--temba-select-container-overflow, hidden);
138
156
  }
139
157
 
140
158
  temba-icon.select-open:hover,
@@ -150,15 +168,23 @@ export class Select<T extends SelectOption> extends FieldElement {
150
168
  /* background: var(--color-widget-bg); */
151
169
  }
152
170
 
171
+ /* Focus border + halo are global DS tokens — change them once
172
+ in designTokens.ts and every form widget follows. The
173
+ per-component tokens still exist so embedded use cases (e.g.
174
+ WorkspaceSelect inside TembaMenu) can opt out by setting them
175
+ to transparent/none on their own :host. */
153
176
  .select-container.focused {
154
177
  background: var(--color-widget-bg-focused);
155
- border-color: var(--color-focus);
156
- box-shadow: var(--widget-box-shadow-focused);
178
+ border-color: var(--temba-select-focus-border, var(--color-focus));
179
+ box-shadow: var(
180
+ --temba-select-focus-halo,
181
+ var(--widget-box-shadow-focused)
182
+ );
157
183
  }
158
184
 
159
185
  .left-side {
160
186
  flex: 1;
161
- overflow: hidden;
187
+ overflow: var(--temba-select-container-overflow, hidden);
162
188
  display: flex;
163
189
  align-items: center;
164
190
  }
@@ -167,70 +193,144 @@ export class Select<T extends SelectOption> extends FieldElement {
167
193
  display: block;
168
194
  }
169
195
 
196
+ /* layout container — padding is the single source of left/right
197
+ offset for placeholder, selected values, and the input. Keep it
198
+ stable across empty/focused/typing/selected states so the widget
199
+ never reflows. */
170
200
  .selected {
171
201
  flex: 1;
172
202
  display: flex;
173
203
  flex-direction: row;
174
- align-items: stretch;
204
+ align-items: center;
175
205
  user-select: none;
176
- padding: var(--temba-select-selected-padding, 0px 4px);
177
- }
178
-
179
- .searchable .selected {
180
- padding: 4px !important;
206
+ padding: var(--temba-select-selected-padding, 0 var(--pad));
207
+ min-width: 0;
181
208
  }
182
209
 
210
+ /* Multi mode: when empty, placeholder/search input share the
211
+ var(--pad) inset with adjacent text widgets so the cursor
212
+ lines up. Once chips appear, tighten to 4px so the first
213
+ pill sits flush to the left. */
183
214
  .multi .selected {
184
215
  flex-wrap: wrap;
185
- padding: 4px;
216
+ gap: 4px;
217
+ padding: 4px var(--pad);
186
218
  }
187
-
188
- .multi.empty .selected {
189
- padding: var(--temba-select-selected-padding);
219
+ .multi:not(.empty) .selected {
220
+ padding: 4px;
190
221
  }
191
222
 
192
223
  .selected .selected-item {
193
224
  display: flex;
194
- overflow: hidden;
195
- color: var(--color-widget-text);
225
+ align-items: center;
196
226
  line-height: var(--temba-select-selected-line-height);
197
- --icon-color: var(--color-text-dark);
198
227
  }
199
228
 
229
+ /* Single-mode selected text uses the widget's own colors. Multi-
230
+ mode chips get color/icon-color/border-color from
231
+ pillVariants (.pill-{type}) — don't set them on the shared
232
+ base rule or they'll outrank the variant on specificity. */
233
+ .single .selected .selected-item {
234
+ flex: 1;
235
+ min-width: 0;
236
+ color: var(--color-widget-text);
237
+ --icon-color: var(--text-2);
238
+ }
239
+
240
+ /* multi-mode chips — TextIt design system pills. Shape lives
241
+ here; color variants come from pillVariants (.pill-{type}).
242
+ overflow: hidden is needed so the pill bg clips to the rounded
243
+ radius — but it's only set in multi mode, otherwise it would
244
+ clip glyph descenders on single-mode selected text.
245
+ border-color is owned by .pill-{type}; only width/style live
246
+ here so the variant isn't outranked on specificity. */
200
247
  .multi .selected .selected-item {
201
248
  vertical-align: middle;
202
- background: #fff;
203
- border: 1px solid rgba(100, 100, 100, 0.3);
204
- user-select: none;
205
- border-radius: 2px;
206
- align-items: stretch;
249
+ align-items: center;
207
250
  flex-direction: row;
208
251
  flex-wrap: nowrap;
209
- margin: 2px 2px;
252
+ /* X is on the left now; keep it snug to the chip edge (small
253
+ left padding) and give the name side more breathing room
254
+ (larger right padding). */
255
+ gap: 5px;
256
+ height: 20px;
257
+ padding: 0 9px 0 2px;
258
+ margin: 0;
259
+ border-radius: 999px;
260
+ overflow: hidden;
261
+ font-size: 11.5px;
262
+ font-weight: var(--w-regular);
263
+ border-width: 1px;
264
+ border-style: solid;
265
+ user-select: none;
266
+ white-space: nowrap;
267
+ max-width: 240px;
268
+ }
269
+
270
+ .option-name {
271
+ flex: 1 1 auto;
272
+ align-self: center;
273
+ white-space: nowrap;
274
+ overflow: hidden;
275
+ text-overflow: ellipsis;
276
+ padding: var(--temba-select-option-padding, 2px 8px);
277
+ display: flex;
278
+ align-items: center;
279
+ gap: 6px;
280
+ }
281
+
282
+ /* Keep the type icon perfectly round even when the chip's name
283
+ is long enough to push the chip against its max-width. Same
284
+ reason as the flex-shrink:0 on .multi .remove-item — the span
285
+ next to the icon is what should clip with ellipsis. */
286
+ .option-name > temba-icon {
287
+ flex-shrink: 0;
210
288
  }
211
289
 
212
290
  .option-name > span {
213
291
  text-align: left;
214
292
  }
215
293
 
294
+ /* Single-mode selected: drop the option-name's overflow:hidden
295
+ (and its companion text-overflow:ellipsis) so glyph descenders
296
+ on "y", "p", "g" aren't clipped. Long names still clip at
297
+ .left-side, which has overflow:hidden of its own. Multi-mode
298
+ chips re-establish overflow:hidden via the .multi rule below. */
216
299
  .selected-item .option-name {
217
300
  padding: 0px;
218
301
  font-size: var(--temba-select-selected-font-size);
219
302
  align-self: center;
303
+ overflow: visible;
220
304
  }
221
305
 
222
306
  .multi .selected-item .option-name {
223
- flex: 1 1 auto;
224
- align-self: center;
225
- white-space: nowrap;
307
+ flex: 0 1 auto;
308
+ font-size: inherit;
309
+ padding: 0;
310
+ /* Let the option-name shrink inside the chip and clip its
311
+ contents with an ellipsis when the chip hits its 240px
312
+ max-width. Without min-width:0 the flex item refuses to
313
+ shrink below its content size, defeating the overflow. */
314
+ min-width: 0;
315
+ overflow: hidden;
316
+ }
317
+
318
+ /* The renderOptionDefault wraps the name in a <span>. That's the
319
+ actual text node that needs the ellipsis chrome — the parent
320
+ option-name is a flex row (icon + name) so the ellipsis lives
321
+ on the name span only, leaving the icon untouched. */
322
+ .multi .selected-item .option-name > span {
323
+ min-width: 0;
226
324
  overflow: hidden;
227
325
  text-overflow: ellipsis;
228
- font-size: 12px;
229
- padding: 2px 8px;
326
+ white-space: nowrap;
230
327
  }
231
328
 
232
329
  .multi .selected .selected-item.focused {
233
- background: rgba(100, 100, 100, 0.3);
330
+ background: var(--accent-600);
331
+ color: white;
332
+ border-color: var(--accent-600);
333
+ --icon-color: white;
234
334
  }
235
335
 
236
336
  .multi .selected-item.sortable {
@@ -246,8 +346,42 @@ export class Select<T extends SelectOption> extends FieldElement {
246
346
  flex-grow: 1;
247
347
  }
248
348
 
349
+ /* chip remove button — DS pill-x. Lives on the LEFT of the chip
350
+ so successive deletions can be done with the cursor parked at
351
+ the same screen position (each removal pulls the next chip
352
+ leftward into the same spot). Has its own tinted background
353
+ (currentColor-mixed so it follows the pill variant) so the X
354
+ reads as a distinct hit target and the rest of the chip is
355
+ visually balanced. */
356
+ .multi .remove-item {
357
+ cursor: pointer;
358
+ display: inline-flex;
359
+ align-items: center;
360
+ justify-content: center;
361
+ width: 16px;
362
+ height: 16px;
363
+ /* Pin the X to a perfect 16×16 circle even when the chip's
364
+ content is wide enough to bump the chip against its
365
+ max-width (240px) — without flex-shrink:0 the row's flex
366
+ layout squeezes the X horizontally before clipping the
367
+ name. */
368
+ flex-shrink: 0;
369
+ padding: 0;
370
+ margin: 0;
371
+ border: 0;
372
+ border-radius: 999px;
373
+ background: color-mix(in oklab, currentColor 25%, transparent);
374
+ color: inherit;
375
+ opacity: 0.8;
376
+ --icon-color: currentColor;
377
+ }
378
+ .multi .remove-item:hover {
379
+ opacity: 1;
380
+ background: color-mix(in oklab, currentColor 45%, transparent);
381
+ }
382
+
249
383
  input {
250
- font-size: 13px;
384
+ font-size: var(--temba-select-selected-font-size);
251
385
  width: 0px;
252
386
  cursor: pointer;
253
387
  background: none;
@@ -261,7 +395,6 @@ export class Select<T extends SelectOption> extends FieldElement {
261
395
  box-shadow: none !important;
262
396
  font-family: var(--font-family);
263
397
  caret-color: var(--input-caret);
264
- border: 0px solid purple !important;
265
398
  }
266
399
 
267
400
  .input-wrapper:focus-within {
@@ -271,17 +404,11 @@ export class Select<T extends SelectOption> extends FieldElement {
271
404
 
272
405
  .input-wrapper {
273
406
  min-width: 1px;
274
- margin-left: 6px;
275
- margin-right: -6px;
276
407
  display: none;
277
408
  pointer-events: none;
278
409
  }
279
410
 
280
411
  .multi .input-wrapper {
281
- margin-left: 6px !important;
282
- margin-right: 2px !important;
283
- margin-top: 2px;
284
- margin-bottom: 2px;
285
412
  min-width: 50px;
286
413
  flex: 1 0 auto;
287
414
  align-self: center;
@@ -350,10 +477,10 @@ export class Select<T extends SelectOption> extends FieldElement {
350
477
 
351
478
  .placeholder {
352
479
  font-size: var(--temba-select-selected-font-size);
480
+ font-weight: var(--w-regular);
353
481
  color: var(--color-placeholder);
354
482
  display: none;
355
483
  line-height: var(--temba-select-selected-line-height);
356
- margin-left: 6px;
357
484
  pointer-events: none;
358
485
  }
359
486
 
@@ -363,8 +490,6 @@ export class Select<T extends SelectOption> extends FieldElement {
363
490
 
364
491
  .multi .placeholder {
365
492
  display: block;
366
- margin: 2px 2px;
367
- padding: 2px 8px;
368
493
  align-self: center;
369
494
  }
370
495
 
@@ -378,15 +503,25 @@ export class Select<T extends SelectOption> extends FieldElement {
378
503
  }
379
504
 
380
505
  .small {
381
- --temba-select-selected-padding: 6px;
506
+ /* Match TextInput's small flavor (6px 8px) so a select sitting
507
+ next to a text field in the same form has its value text
508
+ horizontally aligned with the field's text. */
509
+ --temba-select-selected-padding: 6px 8px;
382
510
  --temba-select-selected-line-height: 12px;
383
511
  --temba-select-selected-font-size: 14px;
384
512
  --temba-select-min-height: 2.28em;
385
513
  }
386
514
 
387
515
  .xsmall {
388
- --temba-select-selected-padding: 4px 6px;
389
- --temba-select-selected-line-height: 13px;
516
+ /* Match RichEditor's xsmall (6px 8px padding, 13px font,
517
+ line-height: normal, no min-height floor) so a rule editor
518
+ row of [operator select | rich-edit argument | category
519
+ textinput] renders all three widgets at the same height.
520
+ The rich-edit argument has no 34px --input-h floor, so the
521
+ select drops that floor here too — TextInput's xsmall makes
522
+ the same opt-out via --temba-textinput-min-height: 0. */
523
+ --temba-select-selected-padding: 6px 8px;
524
+ --temba-select-selected-line-height: 1.2;
390
525
  --temba-select-selected-font-size: 13px;
391
526
  --temba-select-min-height: 0em;
392
527
  }
@@ -546,6 +681,15 @@ export class Select<T extends SelectOption> extends FieldElement {
546
681
  @property({ type: String })
547
682
  flavor = 'default';
548
683
 
684
+ /**
685
+ * Explicit pill variant for selected-value chips (one of: neutral,
686
+ * flow, group, field, label, keyword, contact). When unset, the
687
+ * variant is resolved automatically — see `getPillType`. Set this
688
+ * only to override the auto-resolution.
689
+ */
690
+ @property({ type: String, attribute: 'pill_type' })
691
+ pillType?: string;
692
+
549
693
  @property({ type: String, attribute: 'info_text' })
550
694
  infoText = '';
551
695
 
@@ -761,6 +905,18 @@ export class Select<T extends SelectOption> extends FieldElement {
761
905
  'slotchange',
762
906
  this.handleSlotChange.bind(this)
763
907
  );
908
+ // Capture-phase pointerdown anywhere within the options popup so
909
+ // we can mark "user is interacting" before any focus shift fires.
910
+ // Used by handleBlur to keep the dropdown open while the user
911
+ // drags the native scrollbar.
912
+ const optionsEl = this.shadowRoot.querySelector('temba-options');
913
+ if (optionsEl) {
914
+ optionsEl.addEventListener(
915
+ 'pointerdown',
916
+ this.handleOptionsPointerDown,
917
+ true
918
+ );
919
+ }
764
920
  }
765
921
 
766
922
  public willUpdate(changes: PropertyValues) {
@@ -863,6 +1019,7 @@ export class Select<T extends SelectOption> extends FieldElement {
863
1019
  disconnectedCallback() {
864
1020
  super.disconnectedCallback();
865
1021
  this.removeHintRepositionListeners();
1022
+ this.detachPointerReleaseHandler();
866
1023
  }
867
1024
 
868
1025
  private updateEnterHintPosition() {
@@ -1237,6 +1394,42 @@ export class Select<T extends SelectOption> extends FieldElement {
1237
1394
  );
1238
1395
  }
1239
1396
 
1397
+ /**
1398
+ * Resolves the design-system pill variant for a chip.
1399
+ *
1400
+ * Resolution order, most-specific to least:
1401
+ * 1. option.type — set by the data (Omnibox-style mixed types)
1402
+ * 2. this.pillType — explicit override from the consumer
1403
+ * 3. tags mode — keyword (mono) chips
1404
+ * 4. endpoint inference — see ENDPOINT_PILL_TYPES
1405
+ * 5. fallback — neutral
1406
+ *
1407
+ * Add new endpoint mappings to ENDPOINT_PILL_TYPES rather than
1408
+ * branching here, so the rule stays declarative.
1409
+ */
1410
+ protected getPillType(option: any): string {
1411
+ // option.type wins, but only if it names a pill variant. Domain
1412
+ // codes (Flow.flow_type, etc.) reuse the same field name and must
1413
+ // fall through to endpoint inference rather than become a class.
1414
+ if (option && option.type && PILL_TYPES.has(option.type)) {
1415
+ return option.type;
1416
+ }
1417
+ if (this.pillType) {
1418
+ return this.pillType;
1419
+ }
1420
+ if (this.tags) {
1421
+ return 'keyword';
1422
+ }
1423
+ if (this.endpoint) {
1424
+ for (const m of ENDPOINT_PILL_TYPES) {
1425
+ if (m.pattern.test(this.endpoint)) {
1426
+ return m.type;
1427
+ }
1428
+ }
1429
+ }
1430
+ return 'neutral';
1431
+ }
1432
+
1240
1433
  /**
1241
1434
  * Whether the current input text would be accepted as a value on Enter.
1242
1435
  * Used for both grey text styling and Enter key gating.
@@ -1654,10 +1847,62 @@ export class Select<T extends SelectOption> extends FieldElement {
1654
1847
  }
1655
1848
  }
1656
1849
 
1850
+ // Set true while a pointer is held down inside our temba-options.
1851
+ // Native scrollbar drags shift focus off the search input, firing
1852
+ // blur, which would otherwise tear down visibleOptions. While this
1853
+ // flag is on we keep the dropdown open and re-focus the input.
1854
+ private pointerInsideOptions = false;
1855
+
1856
+ // The active pointerup/pointercancel release handler, if any. We
1857
+ // stash it on the instance so disconnectedCallback can detach it as
1858
+ // a safety net — without that, a component removed mid-drag (or
1859
+ // with the pointer dragged into an ancestor iframe so the release
1860
+ // events never reach `window`) would leak the listener and pin
1861
+ // `this` for the lifetime of the page.
1862
+ private pointerReleaseHandler: ((e: Event) => void) | null = null;
1863
+
1864
+ private detachPointerReleaseHandler() {
1865
+ if (!this.pointerReleaseHandler) return;
1866
+ window.removeEventListener('pointerup', this.pointerReleaseHandler, true);
1867
+ window.removeEventListener(
1868
+ 'pointercancel',
1869
+ this.pointerReleaseHandler,
1870
+ true
1871
+ );
1872
+ this.pointerReleaseHandler = null;
1873
+ }
1874
+
1875
+ private handleOptionsPointerDown = () => {
1876
+ this.pointerInsideOptions = true;
1877
+ // Replace any in-flight handler so we never accumulate listeners.
1878
+ this.detachPointerReleaseHandler();
1879
+ const release = () => {
1880
+ // small delay so a focus-shift triggered by the click lands
1881
+ // before we re-allow blur-driven closure.
1882
+ setTimeout(() => {
1883
+ this.pointerInsideOptions = false;
1884
+ }, 0);
1885
+ this.detachPointerReleaseHandler();
1886
+ };
1887
+ this.pointerReleaseHandler = release;
1888
+ window.addEventListener('pointerup', release, true);
1889
+ window.addEventListener('pointercancel', release, true);
1890
+ };
1891
+
1657
1892
  private handleBlur() {
1658
1893
  // defer to avoid scheduling an update during the current cycle;
1659
1894
  // blur can fire synchronously during the lit-html render when DOM changes
1660
1895
  setTimeout(() => {
1896
+ if (this.pointerInsideOptions) {
1897
+ // user is interacting with the options popup (e.g. dragging
1898
+ // its scrollbar) — keep the dropdown open and re-focus the input.
1899
+ const input = this.shadowRoot?.querySelector(
1900
+ '.searchbox'
1901
+ ) as HTMLElement | null;
1902
+ input?.focus();
1903
+ return;
1904
+ }
1905
+
1661
1906
  this.focused = false;
1662
1907
  this.attemptedOpen = false;
1663
1908
  if (this.visibleOptions.length > 0) {
@@ -1935,24 +2180,32 @@ export class Select<T extends SelectOption> extends FieldElement {
1935
2180
  return null;
1936
2181
  }
1937
2182
 
1938
- // special case for icons on any option type
1939
- const icon = (option as any).icon;
2183
+ // Icon resolution, most-specific to least:
2184
+ // 1. option.icon explicit per-row override
2185
+ // 2. option.type's default icon — when the data layer marks the
2186
+ // row's pill variant (Omnibox-style mixed types)
2187
+ // 3. the widget's resolved pill type — endpoint inference for
2188
+ // sources that return plain rows (e.g. /api/v2/groups.json)
2189
+ // The fall-through to (3) is what lets `Add to Group` / `Remove
2190
+ // from Group` chips show the group icon even though the API rows
2191
+ // don't carry a `type` field.
2192
+ const optType = (option as any).type;
2193
+ const icon =
2194
+ (option as any).icon ||
2195
+ (optType && PILL_TYPE_ICONS[optType]) ||
2196
+ PILL_TYPE_ICONS[this.getPillType(option)] ||
2197
+ undefined;
2198
+ // Inline flex on the wrapper itself so layout works in both shadow
2199
+ // roots — Select's (chip rendering) and temba-options' (dropdown
2200
+ // rendering). Otherwise the dropdown would fall back to inline flow
2201
+ // and stack the icon above the name.
1940
2202
  return html`
1941
2203
  <div
1942
2204
  class="option-name"
1943
- style="flex: 1 1 auto;
1944
- align-self: center;
1945
- white-space: nowrap;
1946
- overflow: hidden;
1947
- text-overflow: ellipsis;
1948
- padding: var(--temba-select-option-padding, 2px 8px);
1949
- display: flex;"
2205
+ style="display:flex; align-items:center; gap:6px;"
1950
2206
  >
1951
2207
  ${icon
1952
- ? html`<temba-icon
1953
- name="${icon}"
1954
- style="margin-right:0.5em;"
1955
- ></temba-icon>`
2208
+ ? html`<temba-icon name="${icon}"></temba-icon>`
1956
2209
  : null}<span>${this.renderHighlightedName(option)}</span>
1957
2210
  </div>
1958
2211
  `;
@@ -2185,6 +2438,32 @@ export class Select<T extends SelectOption> extends FieldElement {
2185
2438
  `
2186
2439
  : null;
2187
2440
 
2441
+ const renderRemove = (selected: any) => html`
2442
+ <div
2443
+ class="remove-item"
2444
+ @click=${(evt: MouseEvent) => {
2445
+ evt.preventDefault();
2446
+ evt.stopPropagation();
2447
+ this.handleRemoveSelection(selected);
2448
+ }}
2449
+ >
2450
+ <temba-icon name="${Icon.delete_small}" size="1"></temba-icon>
2451
+ </div>
2452
+ `;
2453
+
2454
+ // pill-{type} colors are only applied in multi mode (chip rendering).
2455
+ // In single mode the selected value sits inside the input box and
2456
+ // shouldn't have a fill — otherwise the variant bg bleeds across
2457
+ // the whole input row.
2458
+ const chipClass = (option: any, index: number, sortable: boolean) => {
2459
+ const variant = this.isMultiMode
2460
+ ? `pill-${this.getPillType(option)}`
2461
+ : '';
2462
+ return `${sortable ? 'sortable ' : ''}selected-item ${variant} ${
2463
+ index === this.selectedIndex ? 'focused' : ''
2464
+ } ${this.draggingId === `selected-${index}` ? 'dragging' : ''}`;
2465
+ };
2466
+
2188
2467
  const multiItems =
2189
2468
  this.isMultiMode &&
2190
2469
  (this.emails || this.tags || this.values.length > 1) &&
@@ -2192,62 +2471,27 @@ export class Select<T extends SelectOption> extends FieldElement {
2192
2471
  ? html`
2193
2472
  <temba-sortable-list
2194
2473
  horizontal
2474
+ gap="4px"
2475
+ .prepareGhost=${(ghost: HTMLElement) => {
2476
+ // The chip rendered width can be a few px wider than
2477
+ // its actual content (subpixel rounding, max-width
2478
+ // cap, etc.). When SortableList pins the ghost root
2479
+ // to `rect.width`, that extra shows up as a stray
2480
+ // gap on the right of the dragged chip. Size the
2481
+ // ghost to its content instead so it looks identical
2482
+ // to the source chip the user grabbed.
2483
+ ghost.style.width = 'max-content';
2484
+ ghost.style.minWidth = '0';
2485
+ }}
2195
2486
  @temba-order-changed=${this.handleOrderChanged}
2196
2487
  >
2197
2488
  ${this.values.map(
2198
2489
  (selected: any, index: number) => html`
2199
2490
  <div
2200
- class="sortable selected-item ${index === this.selectedIndex
2201
- ? 'focused'
2202
- : ''} ${this.draggingId === `selected-${index}`
2203
- ? 'dragging'
2204
- : ''}"
2491
+ class=${chipClass(selected, index, true)}
2205
2492
  id="selected-${index}"
2206
- style="
2207
- vertical-align: middle;
2208
- background: #fff;
2209
- border: 1px solid rgba(100,100,100,0.3);
2210
- user-select: none;
2211
- border-radius: 2px;
2212
- align-items: center;
2213
- flex-direction: row;
2214
- flex-wrap: nowrap;
2215
- margin: 2px 2px;
2216
- display: flex;
2217
- overflow: hidden;
2218
- color: var(--color-widget-text);
2219
- line-height: var(--temba-select-selected-line-height);
2220
- --icon-color: var(--color-text-dark);
2221
- ${index === this.selectedIndex
2222
- ? 'background: rgba(100,100,100,0.3);'
2223
- : ''}
2224
- ${this.draggingId === `selected-${index}`
2225
- ? 'opacity: 0.5;'
2226
- : ''}
2227
- "
2228
2493
  >
2229
- <div
2230
- class="remove-item"
2231
- style="
2232
- cursor: pointer;
2233
- display: inline-block;
2234
- padding: 3px 6px;
2235
- border-right: 1px solid rgba(100,100,100,0.2);
2236
- margin: 0;
2237
- background: rgba(100,100,100,0.05);
2238
- margin-top:1px;
2239
- "
2240
- @click=${(evt: MouseEvent) => {
2241
- evt.preventDefault();
2242
- evt.stopPropagation();
2243
- this.handleRemoveSelection(selected);
2244
- }}
2245
- >
2246
- <temba-icon
2247
- name="${Icon.delete_small}"
2248
- size="1"
2249
- ></temba-icon>
2250
- </div>
2494
+ ${renderRemove(selected)}
2251
2495
  ${this.renderSelectedItem(selected)}
2252
2496
  </div>
2253
2497
  `
@@ -2260,44 +2504,8 @@ export class Select<T extends SelectOption> extends FieldElement {
2260
2504
  const singleItems = !multiItems
2261
2505
  ? html`${this.values.map(
2262
2506
  (selected: any, index: number) => html`
2263
- <div
2264
- class="selected-item ${index === this.selectedIndex
2265
- ? 'focused'
2266
- : ''}"
2267
- style="
2268
- display: flex;
2269
- overflow: hidden;
2270
- color: var(--color-widget-text);
2271
- line-height: var(--temba-select-selected-line-height);
2272
- --icon-color: var(--color-text-dark);
2273
- ${this.isMultiMode
2274
- ? 'vertical-align: middle; background: #fff; border: 1px solid rgba(100,100,100,0.3); user-select: none; border-radius: 2px; align-items: center; flex-direction: row; flex-wrap: nowrap; margin: 2px 2px;'
2275
- : 'flex: 1; min-width: 0;'}
2276
- ${index === this.selectedIndex
2277
- ? 'background: rgba(100,100,100,0.3);'
2278
- : ''}
2279
- "
2280
- >
2281
- ${this.isMultiMode
2282
- ? html`
2283
- <div
2284
- class="remove-item"
2285
- style="cursor: pointer; display: inline-block; padding: 3px 6px;
2286
- border-right: 1px solid rgba(100,100,100,0.2);
2287
- margin: 0; background: rgba(100,100,100,0.05); margin-top:1px;"
2288
- @click=${(evt: MouseEvent) => {
2289
- evt.preventDefault();
2290
- evt.stopPropagation();
2291
- this.handleRemoveSelection(selected);
2292
- }}
2293
- >
2294
- <temba-icon
2295
- name="${Icon.delete_small}"
2296
- size="1"
2297
- ></temba-icon>
2298
- </div>
2299
- `
2300
- : null}
2507
+ <div class=${chipClass(selected, index, false)}>
2508
+ ${this.isMultiMode ? renderRemove(selected) : null}
2301
2509
  ${!this.input || this.isMultiMode
2302
2510
  ? this.renderSelectedItem(selected)
2303
2511
  : null}