@nyaruka/temba-components 0.167.0 → 0.168.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/locales/es.js +3 -1
  3. package/dist/locales/es.js.map +1 -1
  4. package/dist/locales/fr.js +3 -1
  5. package/dist/locales/fr.js.map +1 -1
  6. package/dist/locales/pt.js +3 -1
  7. package/dist/locales/pt.js.map +1 -1
  8. package/dist/temba-components.js +2791 -1668
  9. package/dist/temba-components.js.map +1 -1
  10. package/package.json +1 -1
  11. package/src/Icons.ts +2 -0
  12. package/src/display/Button.ts +8 -6
  13. package/src/display/Chat.ts +541 -191
  14. package/src/display/Dropdown.ts +40 -17
  15. package/src/display/Label.ts +25 -4
  16. package/src/display/TembaDate.ts +11 -2
  17. package/src/display/Tip.ts +157 -18
  18. package/src/events/eventRenderers.ts +678 -268
  19. package/src/events.ts +16 -1
  20. package/src/flow/NodeEditor.ts +82 -9
  21. package/src/flow/actions/add_contact_urn.ts +1 -0
  22. package/src/flow/actions/play_audio.ts +1 -0
  23. package/src/flow/actions/send_broadcast.ts +17 -1
  24. package/src/flow/actions/send_msg.ts +12 -1
  25. package/src/flow/actions/set_run_result.ts +1 -0
  26. package/src/flow/actions/start_session.ts +10 -1
  27. package/src/flow/nodes/shared-rules.ts +2 -0
  28. package/src/flow/nodes/split_by_airtime.ts +6 -0
  29. package/src/flow/nodes/split_by_expression.ts +1 -0
  30. package/src/flow/nodes/split_by_llm.ts +2 -0
  31. package/src/flow/nodes/split_by_llm_categorize.ts +3 -1
  32. package/src/flow/nodes/split_by_random.ts +2 -1
  33. package/src/flow/nodes/split_by_resthook.ts +4 -1
  34. package/src/flow/nodes/split_by_webhook.ts +9 -1
  35. package/src/flow/types.ts +3 -0
  36. package/src/flow/utils.ts +49 -0
  37. package/src/form/DatePicker.ts +2 -1
  38. package/src/layout/Card.ts +9 -0
  39. package/src/layout/CardLayout.ts +426 -21
  40. package/src/layout/CardStack.ts +4 -1
  41. package/src/list/ContactList.ts +22 -17
  42. package/src/list/ContentList.ts +632 -29
  43. package/src/list/MsgList.ts +4 -2
  44. package/src/list/SortableList.ts +5 -1
  45. package/src/list/TembaMenu.ts +67 -6
  46. package/src/live/ContactChat.ts +655 -336
  47. package/src/live/ContactDetails.ts +961 -88
  48. package/src/live/ContactFieldEditor.ts +46 -11
  49. package/src/live/ContactFields.ts +57 -16
  50. package/src/live/ContactStoreElement.ts +15 -8
  51. package/src/locales/es.ts +3 -2
  52. package/src/locales/fr.ts +3 -2
  53. package/src/locales/pt.ts +3 -2
  54. package/src/simulator/Simulator.ts +28 -0
  55. package/src/styles/pillVariants.ts +13 -1
  56. package/web-test-runner.config.mjs +2 -0
  57. package/xliff/es.xlf +6 -3
  58. package/xliff/fr.xlf +6 -3
  59. package/xliff/pt.xlf +6 -3
@@ -70,6 +70,10 @@ export class Dropdown extends RapidElement {
70
70
  transform: translateY(0.5em) scale(1);
71
71
  }
72
72
 
73
+ .open .dropdown.up {
74
+ transform: translateY(-0.5em) scale(1);
75
+ }
76
+
73
77
  .mask {
74
78
  position: absolute;
75
79
  left: 0;
@@ -109,10 +113,22 @@ export class Dropdown extends RapidElement {
109
113
  @property({ type: Boolean })
110
114
  mask = false;
111
115
 
116
+ // prefer opening above the toggle, falling back to below if there's no room
117
+ @property({ type: Boolean })
118
+ up = false;
119
+
120
+ // optional selector for the element inside the toggle slot to position
121
+ // against, e.g. the visible button rather than its padded wrapper
122
+ @property({ type: String })
123
+ anchor = '';
124
+
112
125
  dropdownStyle: Record<string, string> = {};
113
126
 
114
127
  arrowStyle: Record<string, string> = {};
115
128
 
129
+ // whether the last position calculation opened us above the toggle
130
+ private openedUp = false;
131
+
116
132
  constructor() {
117
133
  super();
118
134
  this.calculatePosition = this.calculatePosition.bind(this);
@@ -150,6 +166,7 @@ export class Dropdown extends RapidElement {
150
166
  // reset position so calculatePosition() sees the natural bounds
151
167
  this.dropdownStyle = {};
152
168
  this.arrowStyle = {};
169
+ this.openedUp = this.up;
153
170
 
154
171
  this.open = true;
155
172
  this.dormant = false;
@@ -175,6 +192,12 @@ export class Dropdown extends RapidElement {
175
192
  this.blur();
176
193
  }
177
194
 
195
+ public close() {
196
+ if (this.open) {
197
+ this.closeDropdown();
198
+ }
199
+ }
200
+
178
201
  public updated(changedProperties: Map<string, any>) {
179
202
  super.updated(changedProperties);
180
203
 
@@ -191,19 +214,14 @@ export class Dropdown extends RapidElement {
191
214
  ) as HTMLDivElement;
192
215
  const toggle = this.querySelector('*[slot="toggle"]');
193
216
 
194
- const arrow = this.shadowRoot.querySelector('.arrow') as HTMLDivElement;
195
-
196
217
  let bumpedUp = false;
197
218
  let bumpedLeft = false;
198
219
 
199
220
  if (dropdown && toggle) {
221
+ const anchor =
222
+ (this.anchor && toggle.querySelector(this.anchor)) || toggle;
200
223
  const dropdownBounds = dropdown.getBoundingClientRect();
201
- const toggleBounds = toggle.getBoundingClientRect();
202
- const arrowBounds = arrow.getBoundingClientRect();
203
-
204
- if (!toggle) {
205
- return;
206
- }
224
+ const toggleBounds = anchor.getBoundingClientRect();
207
225
 
208
226
  // Anchor the dropdown to the toggle's viewport coordinates.
209
227
  // The dropdown is `position: fixed`, so viewport coords are
@@ -223,20 +241,24 @@ export class Dropdown extends RapidElement {
223
241
  if (toggleBounds.left + dropdownBounds.width > window.innerWidth) {
224
242
  dropdownStyle['left'] =
225
243
  toggleBounds.right - dropdownBounds.width + 'px';
226
- delete dropdownStyle['right'];
227
244
  bumpedLeft = true;
228
245
  }
229
246
 
230
- // if off to the bottom, bump it up
231
- if (toggleBounds.bottom + dropdownBounds.height > window.innerHeight) {
232
- dropdownStyle['top'] = toggleBounds.top - dropdownBounds.height + 'px';
233
- dropdownStyle['marginTop'] = '-0.5em';
247
+ // if preferred up and there's room, or we'd fall off the bottom, bump
248
+ // it up, leaving room for the arrow to point at the top of the toggle
249
+ if (
250
+ (this.up &&
251
+ toggleBounds.top > dropdownBounds.height + this.arrowSize) ||
252
+ toggleBounds.bottom + dropdownBounds.height > window.innerHeight
253
+ ) {
254
+ dropdownStyle['top'] =
255
+ toggleBounds.top - dropdownBounds.height - this.arrowSize + 'px';
234
256
  bumpedUp = true;
235
257
  }
236
258
 
237
259
  // if our arrow is aligned with the left of the dropdown, scootch
238
260
  // the dropdown left a pinch so our arrow still overlaps properly
239
- let arrowLeft = toggleBounds.width / 2 - arrowBounds.width / 2;
261
+ let arrowLeft = toggleBounds.width / 2 - this.arrowSize;
240
262
  if (arrowLeft <= 0) {
241
263
  dropdownStyle['marginLeft'] = '-10px';
242
264
  arrowLeft = 10;
@@ -272,13 +294,13 @@ export class Dropdown extends RapidElement {
272
294
  }
273
295
 
274
296
  if (bumpedLeft) {
275
- arrowStyle['right'] =
276
- toggleBounds.width / 2 - arrowBounds.width / 2 + 'px';
297
+ arrowStyle['right'] = toggleBounds.width / 2 - this.arrowSize + 'px';
277
298
  delete arrowStyle['left'];
278
299
  }
279
300
 
280
301
  this.arrowStyle = arrowStyle;
281
302
  this.dropdownStyle = dropdownStyle;
303
+ this.openedUp = bumpedUp;
282
304
  }
283
305
  this.requestUpdate();
284
306
  }
@@ -310,7 +332,8 @@ export class Dropdown extends RapidElement {
310
332
  <div
311
333
  class="${getClasses({
312
334
  dropdown: true,
313
- dormant: this.dormant
335
+ dormant: this.dormant,
336
+ up: this.openedUp
314
337
  })}"
315
338
  style=${styleMap(this.dropdownStyle)}
316
339
  tabindex="0"
@@ -22,6 +22,12 @@ export default class Label extends LitElement {
22
22
  rather than overflowing. The slot/mask below have the
23
23
  min-width:0 needed to let the ellipsis engage. */
24
24
  max-width: 100%;
25
+ /* As a flex item the host's automatic minimum size is its
26
+ full nowrap text width, which refuses to shrink and
27
+ stretches content-sized ancestors (e.g. the Next Up card)
28
+ instead of ellipsizing. min-width:0 lets the pill shrink
29
+ to its container so the slot ellipsis can engage. */
30
+ min-width: 0;
25
31
  }
26
32
 
27
33
  slot {
@@ -29,10 +35,13 @@ export default class Label extends LitElement {
29
35
  overflow-x: hidden;
30
36
  text-overflow: ellipsis;
31
37
  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;
38
+ /* min-width:0 lets the slot — as a flex item inside .mask —
39
+ shrink below its content size so the overflow/ellipsis can
40
+ engage. The default is 0 so every label can collapse fully;
41
+ a consumer that wants a squeezed pill to keep a couple of
42
+ characters visible (rather than collapsing to bare chrome)
43
+ opts in by setting --label-min-width (e.g. 2em). */
44
+ min-width: var(--label-min-width, 0);
36
45
  }
37
46
 
38
47
  .mask {
@@ -198,6 +207,15 @@ export default class Label extends LitElement {
198
207
  @property({ type: String })
199
208
  icon: string;
200
209
 
210
+ /**
211
+ * An additional icon rendered ahead of the main icon — e.g. a +/−
212
+ * qualifier on a group pill ("added to" vs "removed from"). Unlike
213
+ * `icon` it never falls back to the type's default, so it only
214
+ * renders when explicitly set.
215
+ */
216
+ @property({ type: String, attribute: 'prefix-icon' })
217
+ prefixIcon: string;
218
+
201
219
  /**
202
220
  * Design-system pill variant — `flow`, `group`, `contact`, `field`,
203
221
  * `keyword`, `label`, or `neutral`. When set, switches the chrome to
@@ -290,6 +308,9 @@ export default class Label extends LitElement {
290
308
  <temba-icon name="x" size="0.85"></temba-icon>
291
309
  </button>`
292
310
  : null}
311
+ ${this.prefixIcon
312
+ ? html`<temba-icon name=${this.prefixIcon} />`
313
+ : null}
293
314
  ${resolvedIcon ? html`<temba-icon name=${resolvedIcon} />` : null}
294
315
  <slot></slot>
295
316
  </div>
@@ -92,8 +92,17 @@ export class TembaDate extends RapidElement {
92
92
  formatted = this.store.getShortDuration(this.datetime);
93
93
  } else if (this.display === Display.countdown) {
94
94
  formatted = this.store.getCountdown(this.datetime);
95
- } else if (this.display === Display.day) {
96
- formatted = this.datetime.toLocaleString(Display.day);
95
+ } else if (this.display === 'day') {
96
+ // the display attribute carries the key ('day'), and Display.day
97
+ // is a luxon format string ('LLL d') rather than an Intl options
98
+ // object, so it must go through toFormat — matching how the
99
+ // timedate branch renders the same format above. The short
100
+ // month-day form is only unambiguous for dates in the current
101
+ // year; anything else falls back to the locale date with year.
102
+ formatted =
103
+ this.datetime.year === DateTime.now().year
104
+ ? this.datetime.toFormat(Display.day)
105
+ : this.datetime.toLocaleString(Display.date);
97
106
  } else {
98
107
  formatted = this.datetime.toLocaleString(Display[this.display]);
99
108
  }
@@ -8,7 +8,9 @@ export class Tip extends RapidElement {
8
8
  static get styles() {
9
9
  return css`
10
10
  .tip {
11
- transition: opacity 120ms cubic-bezier(0.2, 0, 0, 1);
11
+ transition:
12
+ opacity 120ms cubic-bezier(0.2, 0, 0, 1),
13
+ transform 120ms cubic-bezier(0.2, 0, 0, 1);
12
14
  margin: 0px;
13
15
  position: fixed;
14
16
  opacity: 0;
@@ -44,11 +46,40 @@ export class Tip extends RapidElement {
44
46
  transition: none;
45
47
  }
46
48
 
49
+ /* hidden tips sit a few px toward their anchor so showing slides
50
+ them into position — up, down, or over based on the side */
51
+ .tip,
52
+ .tip.side-top {
53
+ transform: translateY(6px);
54
+ }
55
+
56
+ .tip.side-bottom {
57
+ transform: translateY(-6px);
58
+ }
59
+
60
+ .tip.side-left {
61
+ transform: translateX(6px);
62
+ }
63
+
64
+ .tip.side-right {
65
+ transform: translateX(-6px);
66
+ }
67
+
47
68
  .show {
48
69
  opacity: 1;
49
70
  z-index: 2147483647;
50
71
  }
51
72
 
73
+ .tip.show {
74
+ transform: translate(0, 0);
75
+ }
76
+
77
+ /* interactive tips accept the mouse while shown so their
78
+ contents can be selected or clicked */
79
+ .tip.interactive.show {
80
+ pointer-events: auto;
81
+ }
82
+
52
83
  .slot {
53
84
  display: flex;
54
85
  flex-direction: column;
@@ -61,20 +92,27 @@ export class Tip extends RapidElement {
61
92
  line-height: 0px;
62
93
  }
63
94
 
95
+ /* squash the glyphs along their pointing axis (and widen the
96
+ base a touch) so the arrows read fat and stubby rather than
97
+ long and pointy */
64
98
  .◀ {
65
99
  text-shadow: -1px 2px 2px rgba(0, 0, 0, 0.1);
100
+ transform: scale(0.6, 1.15);
66
101
  }
67
102
 
68
103
  .▶ {
69
104
  text-shadow: 1px 2px 2px rgba(0, 0, 0, 0.1);
105
+ transform: scale(0.6, 1.15);
70
106
  }
71
107
 
72
108
  .▼ {
73
109
  text-shadow: 0px 3px 3px rgba(0, 0, 0, 0.1);
110
+ transform: scale(1.15, 0.6);
74
111
  }
75
112
 
76
113
  .▲ {
77
114
  text-shadow: 0px -1px 1px rgba(0, 0, 0, 0.1);
115
+ transform: scale(1.15, 0.6);
78
116
  }
79
117
  `;
80
118
  }
@@ -91,6 +129,20 @@ export class Tip extends RapidElement {
91
129
  @property({ type: String })
92
130
  position = 'auto';
93
131
 
132
+ // gap in px between the anchor and the tip edge; when unset, each
133
+ // side keeps its historical default (10-12px)
134
+ @property({ type: Number })
135
+ distance: number = null;
136
+
137
+ // font-size of the arrow glyph; offsets scale with it
138
+ @property({ type: Number, attribute: 'arrow-size' })
139
+ arrowSize = 10;
140
+
141
+ // interactive tips stay open while the mouse is over them so their
142
+ // contents can be selected or clicked
143
+ @property({ type: Boolean })
144
+ interactive = false;
145
+
94
146
  @property({ type: Boolean })
95
147
  hideOnChange: boolean;
96
148
 
@@ -114,6 +166,10 @@ export class Tip extends RapidElement {
114
166
  arrowLeft: number;
115
167
  arrowDirection: string;
116
168
 
169
+ // the side the tip last popped on, driving which direction it
170
+ // slides in from
171
+ side: string;
172
+
117
173
  showTimer = 0;
118
174
  hideTimer = 0;
119
175
 
@@ -122,6 +178,50 @@ export class Tip extends RapidElement {
122
178
  if (changed.has('text') && this.hideOnChange) {
123
179
  this.visible = false;
124
180
  }
181
+
182
+ // stamp the placement side before the show transition starts so
183
+ // the very first pop already slides in from the correct direction
184
+ if (changed.has('visible') && this.visible) {
185
+ this.prepareSide();
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Measures where the tip will pop and applies the matching side-*
191
+ * class while the tip is still hidden, flushing styles so the slide
192
+ * animation starts from that side's offset rather than the default.
193
+ */
194
+ private prepareSide() {
195
+ const tipEl = this.getDiv('.tip') as HTMLElement;
196
+ const anchorEl = this.getDiv('.slot') as HTMLElement;
197
+ if (!tipEl || !anchorEl) {
198
+ return;
199
+ }
200
+
201
+ const side = this.chooseSide(
202
+ this.position,
203
+ anchorEl.getBoundingClientRect(),
204
+ tipEl.getBoundingClientRect(),
205
+ 8
206
+ );
207
+
208
+ if (side !== this.side) {
209
+ this.side = side;
210
+ // snap (not animate) the hidden tip to the new side's offset so
211
+ // the show transition starts from there — without suppressing
212
+ // the transition, the class swap itself animates and the show
213
+ // retargets from the old offset mid-flight
214
+ tipEl.style.transition = 'none';
215
+ tipEl.classList.remove(
216
+ 'side-top',
217
+ 'side-bottom',
218
+ 'side-left',
219
+ 'side-right'
220
+ );
221
+ tipEl.classList.add(`side-${side}`);
222
+ void tipEl.offsetWidth;
223
+ tipEl.style.transition = '';
224
+ }
125
225
  }
126
226
 
127
227
  public updated(changed: Map<string, any>) {
@@ -144,38 +244,46 @@ export class Tip extends RapidElement {
144
244
  tipBounds,
145
245
  viewportPadding
146
246
  );
247
+ this.side = side;
147
248
 
148
249
  this.arrowLeft = 0;
149
250
  this.arrowTop = 0;
150
251
 
252
+ // arrow offsets scale with the glyph size; the constants
253
+ // reproduce the historical values at the default size of 10
254
+ const arrow = this.arrowSize;
255
+
151
256
  if (side === 'left') {
152
- this.left = anchorBounds.left - tipBounds.width - 10;
257
+ this.left = anchorBounds.left - tipBounds.width - (this.distance ?? 10);
153
258
  this.top = getMiddle(anchorBounds, tipBounds);
154
259
 
155
- // position our arrow
260
+ // center the glyph box on the tip edge so the arrow always
261
+ // touches the tip regardless of glyph metrics
156
262
  this.arrowTop = tipBounds.height / 2;
157
- this.arrowLeft = tipBounds.width + 2;
263
+ this.arrowLeft = tipBounds.width - arrow * 0.5;
158
264
  this.arrow = '▶';
159
265
  } else if (side === 'right') {
160
- this.left = anchorBounds.right + 12;
266
+ this.left = anchorBounds.right + (this.distance ?? 12);
161
267
  this.top = getMiddle(anchorBounds, tipBounds);
162
268
 
163
269
  this.arrowTop = tipBounds.height / 2;
164
- this.arrowLeft = -8;
270
+ this.arrowLeft = -arrow * 0.5;
165
271
  this.arrow = '◀';
166
272
  } else if (side === 'top') {
167
- this.top = anchorBounds.top - tipBounds.height - 12;
273
+ this.top = anchorBounds.top - tipBounds.height - (this.distance ?? 12);
168
274
  this.left = getCenter(anchorBounds, tipBounds);
169
275
 
170
- this.arrowTop = tipBounds.height + 2;
171
- this.arrowLeft = tipBounds.width / 2 - 4;
276
+ // tucked in slightly so the squashed glyph's base stays
277
+ // buried under the tip edge
278
+ this.arrowTop = tipBounds.height + arrow * 0.1;
279
+ this.arrowLeft = tipBounds.width / 2 - arrow * 0.4;
172
280
  this.arrow = '▼';
173
281
  } else if (side === 'bottom') {
174
- this.top = anchorBounds.bottom + 10;
282
+ this.top = anchorBounds.bottom + (this.distance ?? 10);
175
283
  this.left = getCenter(anchorBounds, tipBounds);
176
284
 
177
- this.arrowTop = -2;
178
- this.arrowLeft = tipBounds.width / 2 - 3;
285
+ this.arrowTop = -arrow * 0.1;
286
+ this.arrowLeft = tipBounds.width / 2 - arrow * 0.3;
179
287
  this.arrow = '▲';
180
288
  }
181
289
 
@@ -194,7 +302,11 @@ export class Tip extends RapidElement {
194
302
 
195
303
  if (side === 'top' || side === 'bottom') {
196
304
  this.arrowLeft -= leftDelta;
197
- this.arrowLeft = this.clamp(this.arrowLeft, 8, tipBounds.width - 14);
305
+ this.arrowLeft = this.clamp(
306
+ this.arrowLeft,
307
+ 8,
308
+ tipBounds.width - (arrow + 4)
309
+ );
198
310
  } else {
199
311
  this.arrowTop -= topDelta;
200
312
  this.arrowTop = this.clamp(this.arrowTop, 8, tipBounds.height - 8);
@@ -207,6 +319,13 @@ export class Tip extends RapidElement {
207
319
  this.arrowLeft = Math.round(this.arrowLeft);
208
320
 
209
321
  // directly update DOM to avoid scheduling another update
322
+ tipEl.classList.remove(
323
+ 'side-top',
324
+ 'side-bottom',
325
+ 'side-left',
326
+ 'side-right'
327
+ );
328
+ tipEl.classList.add(`side-${side}`);
210
329
  tipEl.style.top = `${this.top}px`;
211
330
  tipEl.style.left = `${this.left}px`;
212
331
  arrowEl.style.top = `${this.arrowTop}px`;
@@ -291,15 +410,27 @@ export class Tip extends RapidElement {
291
410
  private handleMouseLeave() {
292
411
  window.clearTimeout(this.showTimer);
293
412
  window.clearTimeout(this.hideTimer);
294
- if (this.hideDelay > 0) {
413
+
414
+ // interactive tips linger long enough for the mouse to travel from
415
+ // the anchor into the tip itself
416
+ const hideDelay = this.interactive
417
+ ? Math.max(this.hideDelay, 300)
418
+ : this.hideDelay;
419
+
420
+ if (hideDelay > 0) {
295
421
  this.hideTimer = window.setTimeout(() => {
296
422
  this.visible = false;
297
- }, this.hideDelay);
423
+ }, hideDelay);
298
424
  return;
299
425
  }
300
426
  this.visible = false;
301
427
  }
302
428
 
429
+ private handleTipMouseEnter() {
430
+ // the mouse made it into the tip — cancel any pending hide
431
+ window.clearTimeout(this.hideTimer);
432
+ }
433
+
303
434
  public render(): TemplateResult {
304
435
  const tipStyle: any = {
305
436
  top: this.top ? `${this.top}px` : '0px',
@@ -308,7 +439,8 @@ export class Tip extends RapidElement {
308
439
 
309
440
  const arrowStyle: any = {
310
441
  top: this.arrowTop ? `${this.arrowTop}px` : '0px',
311
- left: this.arrowLeft ? `${this.arrowLeft}px` : '0px'
442
+ left: this.arrowLeft ? `${this.arrowLeft}px` : '0px',
443
+ fontSize: `${this.arrowSize}px`
312
444
  };
313
445
 
314
446
  if (this.width) {
@@ -319,7 +451,9 @@ export class Tip extends RapidElement {
319
451
  tip: true,
320
452
  show: this.visible,
321
453
  top: this.poppedTop,
322
- 'hide-on-change': this.hideOnChange
454
+ interactive: this.interactive,
455
+ 'hide-on-change': this.hideOnChange,
456
+ [`side-${this.side}`]: !!this.side
323
457
  });
324
458
 
325
459
  return html`
@@ -331,7 +465,12 @@ export class Tip extends RapidElement {
331
465
  >
332
466
  <slot></slot>
333
467
  </div>
334
- <div class="${classes}" style=${styleMap(tipStyle)}>
468
+ <div
469
+ class="${classes}"
470
+ style=${styleMap(tipStyle)}
471
+ @mouseenter=${this.handleTipMouseEnter}
472
+ @mouseleave=${this.handleMouseLeave}
473
+ >
335
474
  ${this.content ?? this.text}
336
475
  <div class="arrow" style=${styleMap(arrowStyle)}></div>
337
476
  </div>