@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.
- package/CHANGELOG.md +17 -0
- package/dist/temba-components.js +1189 -767
- package/dist/temba-components.js.map +1 -1
- package/package.json +1 -1
- package/src/display/Chat.ts +14 -0
- package/src/display/Label.ts +156 -2
- package/src/display/Options.ts +71 -16
- package/src/display/TembaUser.ts +23 -5
- package/src/events/eventRenderers.ts +104 -41
- package/src/excellent/caret-utils.ts +0 -1
- package/src/flow/RevisionsWindow.ts +53 -9
- package/src/flow/nodes/shared.ts +14 -0
- package/src/flow/nodes/split_by_llm_categorize.ts +33 -8
- package/src/flow/revision-summary.ts +25 -0
- package/src/flow/utils.ts +38 -40
- package/src/form/ArrayEditor.ts +9 -11
- package/src/form/Checkbox.ts +2 -2
- package/src/form/Compose.ts +1 -1
- package/src/form/FieldElement.ts +8 -8
- package/src/form/KeyValueEditor.ts +4 -4
- package/src/form/MessageEditor.ts +2 -3
- package/src/form/RangePicker.ts +17 -17
- package/src/form/TembaSlider.ts +10 -10
- package/src/form/TemplateEditor.ts +4 -4
- package/src/form/TextInput.ts +19 -1
- package/src/form/select/Omnibox.ts +22 -19
- package/src/form/select/Select.ts +379 -171
- package/src/form/select/WorkspaceSelect.ts +7 -1
- package/src/layout/Accordion.ts +2 -2
- package/src/layout/Modax.ts +1 -1
- package/src/list/SortableList.ts +159 -0
- package/src/live/ContactChat.ts +46 -44
- package/src/live/ContactDetails.ts +1 -0
- package/src/live/ContactFieldEditor.ts +38 -31
- package/src/live/FieldManager.ts +4 -4
- package/src/styles/designTokens.ts +145 -0
- package/src/styles/pillVariants.ts +136 -0
- package/static/css/temba-components.css +106 -28
- 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
|
-
/*
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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,
|
|
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:
|
|
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,
|
|
149
|
+
min-height: var(--temba-select-min-height, 34px);
|
|
136
150
|
max-height: var(--temba-select-max-height, none);
|
|
137
|
-
overflow
|
|
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(
|
|
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:
|
|
204
|
+
align-items: center;
|
|
175
205
|
user-select: none;
|
|
176
|
-
padding: var(--temba-select-selected-padding,
|
|
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
|
-
|
|
216
|
+
gap: 4px;
|
|
217
|
+
padding: 4px var(--pad);
|
|
186
218
|
}
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
229
|
-
padding: 2px 8px;
|
|
326
|
+
white-space: nowrap;
|
|
230
327
|
}
|
|
231
328
|
|
|
232
329
|
.multi .selected .selected-item.focused {
|
|
233
|
-
background:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
389
|
-
|
|
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
|
-
//
|
|
1939
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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}
|