@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
@@ -1,13 +1,27 @@
1
1
  import { LitElement, TemplateResult, html, css } from 'lit';
2
2
  import { property } from 'lit/decorators.js';
3
+ import { msg } from '@lit/localize';
3
4
  import { getClasses } from '../utils';
4
5
  import { styleMap } from 'lit-html/directives/style-map.js';
6
+ import { designTokens } from '../styles/designTokens';
7
+ import {
8
+ pillVariants,
9
+ PILL_TYPES,
10
+ PILL_TYPE_ICONS
11
+ } from '../styles/pillVariants';
5
12
 
6
13
  export default class Label extends LitElement {
7
14
  static get styles() {
8
15
  return css`
16
+ ${designTokens}
17
+
9
18
  :host {
10
19
  display: inline-block;
20
+ /* Cap at parent width so a pill sitting in a constrained
21
+ container (e.g. a flow canvas node body) shrinks to fit
22
+ rather than overflowing. The slot/mask below have the
23
+ min-width:0 needed to let the ellipsis engage. */
24
+ max-width: 100%;
11
25
  }
12
26
 
13
27
  slot {
@@ -15,12 +29,19 @@ export default class Label extends LitElement {
15
29
  overflow-x: hidden;
16
30
  text-overflow: ellipsis;
17
31
  display: block;
32
+ /* Without min-width:0 the slot — as a flex item inside .mask —
33
+ refuses to shrink below its content size, defeating the
34
+ overflow/ellipsis. */
35
+ min-width: 0;
18
36
  }
19
37
 
20
38
  .mask {
21
39
  padding: 3px 8px;
22
40
  border-radius: 12px;
23
41
  display: flex;
42
+ /* Same reason as slot — let the mask shrink below its content
43
+ size so the inner slot can ellipsize. */
44
+ min-width: 0;
24
45
  }
25
46
 
26
47
  temba-icon {
@@ -43,6 +64,72 @@ export default class Label extends LitElement {
43
64
  text-shadow: none;
44
65
  }
45
66
 
67
+ /* DS pill mode — engaged when the consumer sets [type]. Overrides
68
+ the legacy chip chrome (shadow) and shape (12px radius) with
69
+ the design-system pill (flat, type-colored via .pill-{type},
70
+ 1px border, 999px radius). Background/foreground/icon-color
71
+ are owned by pillVariants — we only set non-color chrome here
72
+ so we don't outrank the variant. */
73
+ .label[class*='pill-'] {
74
+ font-size: 11.5px;
75
+ font-weight: var(--w-regular);
76
+ /* border color is owned by .pill-{type} in pillVariants — only
77
+ set width/style here so we don't outrank the variant. */
78
+ border-width: 1px;
79
+ border-style: solid;
80
+ border-radius: 999px;
81
+ box-shadow: none;
82
+ }
83
+ .label[class*='pill-'] .mask {
84
+ padding: 0 7px;
85
+ height: 20px;
86
+ align-items: center;
87
+ gap: 4px;
88
+ border-radius: 999px;
89
+ }
90
+ /* Hover tint pulled from the pill's own foreground (which is the
91
+ dark variant shade), so flow stays bluish, group stays purplish,
92
+ etc. — no grey wash. */
93
+ .label[class*='pill-'].clickable .mask:hover {
94
+ background: color-mix(in oklab, currentColor 10%, transparent);
95
+ }
96
+ .label[class*='pill-'] temba-icon {
97
+ margin-right: 0;
98
+ padding-bottom: 0;
99
+ }
100
+
101
+ /* Chip-style X button — matches the multi-select chip's
102
+ .remove-item. currentColor-tinted bg so it picks up the
103
+ pill variant's hue. Sits on the left, ahead of the icon. */
104
+ .label[class*='pill-'] .remove {
105
+ cursor: pointer;
106
+ display: inline-flex;
107
+ align-items: center;
108
+ justify-content: center;
109
+ width: 16px;
110
+ height: 16px;
111
+ padding: 0;
112
+ margin: 0;
113
+ border: 0;
114
+ border-radius: 999px;
115
+ background: color-mix(in oklab, currentColor 25%, transparent);
116
+ color: inherit;
117
+ opacity: 0.8;
118
+ --icon-color: currentColor;
119
+ }
120
+ .label[class*='pill-'] .remove:hover {
121
+ opacity: 1;
122
+ background: color-mix(in oklab, currentColor 45%, transparent);
123
+ }
124
+
125
+ /* When a removable X is present, tighten the mask's left padding
126
+ so the X sits snug against the pill edge (matches the
127
+ multi-select chip's 4px left padding). Right padding stays so
128
+ the trailing icon/name keep their breathing room. */
129
+ .label[class*='pill-']:has(.remove) .mask {
130
+ padding-left: 4px;
131
+ }
132
+
46
133
  .danger {
47
134
  background: tomato;
48
135
  color: #fff;
@@ -79,6 +166,11 @@ export default class Label extends LitElement {
79
166
  .shadow {
80
167
  box-shadow: 1px 1px 2px 1px rgba(0, 0, 0, 0.1);
81
168
  }
169
+
170
+ /* DS pill variants come last so .pill-{type} wins on source
171
+ order against equal-specificity legacy rules above (.label,
172
+ .danger, .primary, etc.). */
173
+ ${pillVariants}
82
174
  `;
83
175
  }
84
176
 
@@ -106,12 +198,47 @@ export default class Label extends LitElement {
106
198
  @property({ type: String })
107
199
  icon: string;
108
200
 
201
+ /**
202
+ * Design-system pill variant — `flow`, `group`, `contact`, `field`,
203
+ * `keyword`, `label`, or `neutral`. When set, switches the chrome to
204
+ * a flat DS pill (rounded 999px, type-colored). Stays the legacy
205
+ * shadowed label when unset, so existing consumers are unaffected.
206
+ */
207
+ @property({ type: String })
208
+ type: string;
209
+
210
+ /**
211
+ * Render a chip-style X button on the left of the pill. Clicking it
212
+ * fires a `temba-remove` event; the rest of the pill stays clickable
213
+ * for navigation as usual.
214
+ */
215
+ @property({ type: Boolean })
216
+ removable: boolean;
217
+
218
+ /**
219
+ * Accessible label for the remove button. Defaults to a localized
220
+ * "Remove", but consumers whose action verb differs (e.g.
221
+ * "Interrupt flow") should pass their own — the X button is the
222
+ * affordance for whatever action `temba-remove` triggers, so the
223
+ * accessible name should match.
224
+ */
225
+ @property({ type: String })
226
+ removeLabel: string;
227
+
109
228
  @property()
110
229
  backgroundColor: string;
111
230
 
112
231
  @property()
113
232
  textColor: string;
114
233
 
234
+ private handleRemove(e: MouseEvent) {
235
+ e.stopPropagation();
236
+ e.preventDefault();
237
+ this.dispatchEvent(
238
+ new CustomEvent('temba-remove', { bubbles: true, composed: true })
239
+ );
240
+ }
241
+
115
242
  public render(): TemplateResult {
116
243
  const labelStyle = {};
117
244
 
@@ -124,6 +251,22 @@ export default class Label extends LitElement {
124
251
  labelStyle['--icon-color'] = this.textColor;
125
252
  }
126
253
 
254
+ // Only emit `pill-${this.type}` if it's a recognized variant.
255
+ // An unknown value (or one containing whitespace, e.g.
256
+ // `"flow danger"` from a malformed template) would otherwise split
257
+ // into multiple classes and collide with internal modifiers
258
+ // (`.danger`, `.shadow`, `.clickable`, etc.) defined on `.label`.
259
+ const validType = this.type && PILL_TYPES.has(this.type);
260
+ const variantClass = validType ? `pill-${this.type}` : '';
261
+ // When the consumer sets a recognized `type` (group / flow / etc.)
262
+ // but doesn't supply an explicit `icon`, fall back to the type's
263
+ // default icon from PILL_TYPE_ICONS. Call sites then only need
264
+ // `type="group"` instead of `type="group" icon="group"`.
265
+ const resolvedIcon =
266
+ this.icon || (validType ? PILL_TYPE_ICONS[this.type] : undefined);
267
+
268
+ const removeAriaLabel = this.removeLabel || msg('Remove');
269
+
127
270
  return html`
128
271
  <div
129
272
  class="label ${getClasses({
@@ -134,11 +277,20 @@ export default class Label extends LitElement {
134
277
  shadow: this.shadow,
135
278
  danger: this.danger,
136
279
  dark: this.dark
137
- })}"
280
+ })} ${variantClass}"
138
281
  style=${styleMap(labelStyle)}
139
282
  >
140
283
  <div class="mask">
141
- ${this.icon ? html`<temba-icon name=${this.icon} />` : null}
284
+ ${this.removable
285
+ ? html`<button
286
+ class="remove"
287
+ @click=${this.handleRemove}
288
+ aria-label=${removeAriaLabel}
289
+ >
290
+ <temba-icon name="x" size="0.85"></temba-icon>
291
+ </button>`
292
+ : null}
293
+ ${resolvedIcon ? html`<temba-icon name=${resolvedIcon} />` : null}
142
294
  <slot></slot>
143
295
  </div>
144
296
  </div>
@@ -1,13 +1,14 @@
1
1
  import { Feature, Geometry } from 'geojson';
2
- import {
2
+ import type {
3
3
  GeoJSON,
4
- geoJSON,
5
4
  LeafletEvent,
6
5
  LeafletMouseEvent,
7
6
  Map as RenderedMap,
8
- map as createMap,
9
7
  Path
10
8
  } from 'leaflet';
9
+ import * as L from 'leaflet';
10
+
11
+ const { geoJSON, map: createMap } = L;
11
12
  import { css, html, LitElement } from 'lit';
12
13
  import { property } from 'lit/decorators.js';
13
14
 
@@ -5,10 +5,13 @@ import { RapidElement, EventHandler } from '../RapidElement';
5
5
  import { styleMap } from 'lit-html/directives/style-map.js';
6
6
  import { getClasses, getScrollParent, throttle } from '../utils';
7
7
  import { msg } from '@lit/localize';
8
+ import { designTokens } from '../styles/designTokens';
8
9
 
9
10
  export class Options extends RapidElement {
10
11
  static get styles() {
11
12
  return css`
13
+ ${designTokens}
14
+
12
15
  :host {
13
16
  --transition-speed: 0;
14
17
  }
@@ -41,6 +44,10 @@ export class Options extends RapidElement {
41
44
  flex-grow: 1;
42
45
  height: 100%;
43
46
  border: none;
47
+ /* Block-mode is inline (ticket sidebar, etc.) — drop the
48
+ z-index so floating popups (selects, dropdowns) opened over
49
+ the block list always stack above its options. */
50
+ z-index: auto;
44
51
  }
45
52
 
46
53
  :host([block]) .options-scroll {
@@ -96,25 +103,75 @@ export class Options extends RapidElement {
96
103
  border: none;
97
104
  }
98
105
 
106
+ /* When shown, the popup is opaque and clickable. Keep the
107
+ high z-index from .options-container so the popup stacks
108
+ above neighboring widgets — e.g. embedded prefix labels of
109
+ later form fields, which sit absolutely positioned at the
110
+ top edge of their host element. */
99
111
  .show {
100
- border: 1px solid var(--color-widget-border);
101
112
  opacity: 1;
102
- z-index: 1;
103
113
  pointer-events: auto;
104
114
  margin-top: var(--options-margin-top);
105
115
  }
106
116
 
117
+ /* Floating popup border uses --focus-muted (not --color-focus)
118
+ so a parent field's error state — which overrides
119
+ --color-focus to red via .has-error — doesn't turn the
120
+ popup red. The popup stays blue regardless. Single source
121
+ of truth: --focus in designTokens. No halo here — the
122
+ dropdown is an attached panel, not its own focus indicator
123
+ (the parent select keeps its own halo). Block-mode renders
124
+ inline and skips this rule entirely. */
125
+ :host(:not([block])) .show {
126
+ border: 1px solid var(--temba-options-focus-border, var(--focus-muted));
127
+ }
128
+
129
+ /* Each option is a DS-style list row: flat, fixed height,
130
+ tight padding, no inter-item margin. Full-bleed background on
131
+ hover/focus → no border-radius (rounded corners would leave
132
+ visible gaps at the dropdown edges). */
107
133
  .option {
108
- font-size: var(--temba-options-font-size);
109
- padding: var(--temba-options-option-padding, 5px 10px);
110
- border-radius: var(--temba-options-option-radius, 4px);
111
- margin: var(--temba-options-option-margin, 0.3em);
134
+ font-size: var(--temba-options-font-size, 13.5px);
135
+ padding: var(--temba-options-option-padding, 0 var(--pad));
136
+ border-radius: var(--temba-options-option-radius, 0);
137
+ margin: var(--temba-options-option-margin, 0);
138
+ min-height: var(--temba-options-option-min-height, 32px);
139
+ display: flex;
140
+ align-items: center;
112
141
  cursor: pointer;
113
- color: var(--color-text-dark);
142
+ color: var(--text-1);
114
143
  scroll-margin: 5px 0px;
115
144
  text-align: left;
116
145
  }
117
146
 
147
+ /* A single wrapping renderOption child stretches to fill the row
148
+ so custom templates (e.g. ones using justify-content:
149
+ space-between to right-align trailing badges) get the full
150
+ width to lay out in. Scoped to :only-child so a renderOption
151
+ that emits multiple top-level siblings keeps its natural
152
+ layout instead of getting an equal-width flex partition. */
153
+ .option > :only-child {
154
+ flex: 1;
155
+ min-width: 0;
156
+ }
157
+
158
+ /* Block-mode (inline, always-visible lists like the ticket
159
+ sidebar): inset every option uniformly from the container
160
+ and from each other (padding + flex gap), and re-add the
161
+ radius so the focused/active wash reads as a rounded pill
162
+ instead of a stripe. Rich custom renderOption content
163
+ (multi-line ticket cards, etc.) also wants more vertical
164
+ padding than the dropdown's compact rows. */
165
+ :host([block]) .options-scroll {
166
+ padding: 4px;
167
+ gap: 4px;
168
+ }
169
+ :host([block]) .option {
170
+ margin: 0;
171
+ padding: 8px var(--pad);
172
+ border-radius: var(--r-sm);
173
+ }
174
+
118
175
  .option * {
119
176
  user-select: none;
120
177
  -webkit-user-select: none;
@@ -169,20 +226,18 @@ export class Options extends RapidElement {
169
226
  max-height: 1.1em;
170
227
  }
171
228
 
172
- .option:hover {
173
- background: var(
174
- --temba-options-option-hover-bg,
175
- var(--option-hover-bg)
176
- );
177
- color: var(--temba-options-option-hover-text, var(--option-hover-text));
178
- }
179
-
229
+ /* The component syncs cursorIndex to pointer position on
230
+ mousemove, so .focused already follows the mouse. A
231
+ separate :hover rule would just create a second highlight
232
+ that flickers between rows on transition — keep only the
233
+ single focused/active state. */
180
234
  .option.focused {
181
235
  background: var(
182
236
  --temba-options-option-focus-bg,
183
237
  var(--color-selection)
184
238
  );
185
- color: var(--temba-options-option-focus-text, var(--color-text-dark));
239
+ color: var(--temba-options-option-focus-text, var(--accent-700));
240
+ --icon-color: var(--accent-700);
186
241
  }
187
242
 
188
243
  .option.no-options {
@@ -11,14 +11,16 @@ export const getFullName = (user: {
11
11
  first_name?: string;
12
12
  last_name?: string;
13
13
  }) => {
14
- return user.name || [user.first_name, user.last_name].join(' ');
14
+ if (user.first_name || user.last_name) {
15
+ return [user.first_name, user.last_name].filter(Boolean).join(' ');
16
+ }
17
+ return user.name || '';
15
18
  };
16
19
 
17
20
  export class TembaUser extends RapidElement {
18
21
  public static styles = css`
19
22
  :host {
20
23
  display: flex;
21
- transform: scale(var(--temba-scale, 1));
22
24
  box-sizing: border-box;
23
25
  }
24
26
 
@@ -29,6 +31,10 @@ export class TembaUser extends RapidElement {
29
31
  flex-grow: 1;
30
32
  }
31
33
 
34
+ .avatar-circle {
35
+ transform-origin: left center;
36
+ }
37
+
32
38
  .name {
33
39
  flex-grow: 1;
34
40
  display: -webkit-box;
@@ -59,6 +65,12 @@ export class TembaUser extends RapidElement {
59
65
  @property({ type: String })
60
66
  name: string;
61
67
 
68
+ @property({ type: String })
69
+ first_name: string;
70
+
71
+ @property({ type: String })
72
+ last_name: string;
73
+
62
74
  @property({ type: String })
63
75
  email: string;
64
76
 
@@ -75,10 +87,19 @@ export class TembaUser extends RapidElement {
75
87
  this.bgimage = `url('${DEFAULT_AVATAR}') center / contain no-repeat`;
76
88
  }
77
89
 
78
- if (changed.has('name')) {
79
- if (this.name) {
80
- this.bgcolor = colorHash.hex(this.name);
81
- this.initials = extractInitials(this.name);
90
+ if (
91
+ changed.has('name') ||
92
+ changed.has('first_name') ||
93
+ changed.has('last_name')
94
+ ) {
95
+ const fullName = getFullName({
96
+ name: this.name,
97
+ first_name: this.first_name,
98
+ last_name: this.last_name
99
+ });
100
+ if (fullName) {
101
+ this.bgcolor = colorHash.hex(fullName);
102
+ this.initials = extractInitials(fullName);
82
103
  } else {
83
104
  this.bgcolor = '#e6e6e6';
84
105
  this.initials = '';
@@ -97,7 +118,7 @@ export class TembaUser extends RapidElement {
97
118
  <div
98
119
  class="avatar-circle"
99
120
  style="
100
- transform:scale(${this.scale || 1});
121
+ transform:scale(calc(var(--temba-scale, 1) * ${this.scale || 1}));
101
122
  display: flex;
102
123
  min-height: 26px;
103
124
  min-width: 26px;
@@ -108,7 +129,10 @@ export class TembaUser extends RapidElement {
108
129
  font-weight: 400;
109
130
  overflow: hidden;
110
131
  font-size: 0.8em;
111
- margin-right: 0.75em;
132
+ margin-right: max(
133
+ 0px,
134
+ calc(0.75em - (1 - var(--temba-scale, 1)) * 26px)
135
+ );
112
136
  box-shadow: inset 0 0 0 3px rgba(0, 0, 0, 0.1);
113
137
  background:${this.bgimage || this.bgcolor};"
114
138
  >