@nyaruka/temba-components 0.156.18 → 0.157.1

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