@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.
- package/CHANGELOG.md +17 -0
- package/dist/temba-components.js +2119 -1617
- package/dist/temba-components.js.map +1 -1
- package/package.json +1 -1
- package/src/display/Button.ts +102 -121
- package/src/display/Chat.ts +74 -9
- package/src/display/Dropdown.ts +11 -0
- package/src/display/Label.ts +154 -2
- package/src/display/LeafletMap.ts +4 -3
- package/src/display/Options.ts +71 -16
- package/src/display/TembaUser.ts +32 -8
- package/src/events/eventRenderers.ts +243 -95
- package/src/excellent/caret-utils.ts +0 -1
- package/src/flow/AutoTranslate.ts +2 -2
- package/src/flow/Editor.ts +4 -4
- package/src/flow/NodeEditor.ts +2 -2
- package/src/flow/NodeTypeSelector.ts +0 -5
- package/src/flow/RevisionsWindow.ts +1 -3
- package/src/flow/actions/set_contact_language.ts +5 -4
- package/src/flow/nodes/shared.ts +14 -0
- package/src/flow/nodes/split_by_llm_categorize.ts +28 -8
- package/src/flow/utils.ts +39 -60
- package/src/form/ArrayEditor.ts +9 -11
- package/src/form/Checkbox.ts +2 -2
- package/src/form/ColorPicker.ts +5 -3
- 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 +21 -20
- package/src/form/select/Select.ts +382 -173
- package/src/form/select/WorkspaceSelect.ts +7 -1
- package/src/interfaces.ts +1 -0
- package/src/languages.ts +56 -0
- package/src/layout/Accordion.ts +2 -2
- package/src/layout/Dialog.ts +1 -3
- package/src/layout/Modax.ts +1 -1
- package/src/list/ContentMenu.ts +1 -2
- package/src/list/SortableList.ts +156 -0
- package/src/list/TembaMenu.ts +159 -113
- package/src/live/ContactBadges.ts +2 -1
- package/src/live/ContactChat.ts +62 -45
- package/src/live/ContactDetails.ts +3 -1
- package/src/live/ContactFieldEditor.ts +36 -31
- package/src/live/FieldManager.ts +4 -4
- package/src/store/AppState.ts +3 -21
- package/src/store/Store.ts +0 -29
- package/src/styles/designTokens.ts +158 -0
- package/src/styles/pillVariants.ts +147 -0
- package/static/css/temba-components.css +141 -36
- package/web-dev-server.config.mjs +0 -1
- 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
|
-
/*
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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,
|
|
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:
|
|
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,
|
|
150
|
+
min-height: var(--temba-select-min-height, 34px);
|
|
136
151
|
max-height: var(--temba-select-max-height, none);
|
|
137
|
-
overflow
|
|
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(
|
|
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:
|
|
205
|
+
align-items: center;
|
|
175
206
|
user-select: none;
|
|
176
|
-
padding: var(--temba-select-selected-padding,
|
|
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
|
-
|
|
217
|
+
gap: 4px;
|
|
218
|
+
padding: 4px var(--pad);
|
|
186
219
|
}
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
229
|
-
padding: 2px 8px;
|
|
327
|
+
white-space: nowrap;
|
|
230
328
|
}
|
|
231
329
|
|
|
232
330
|
.multi .selected .selected-item.focused {
|
|
233
|
-
background:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
389
|
-
|
|
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
|
-
//
|
|
1939
|
-
|
|
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:
|
|
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
|
-
|
|
1953
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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}
|