@nyaruka/temba-components 0.171.0 → 0.173.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 +30 -1
- package/dist/static/svg/index.svg +1 -1
- package/dist/temba-components.js +1292 -758
- package/dist/temba-components.js.map +1 -1
- package/package.json +1 -1
- package/src/Icons.ts +4 -1
- package/src/RapidElement.ts +83 -41
- package/src/display/Button.ts +14 -2
- package/src/display/Chat.ts +41 -22
- package/src/display/Label.ts +18 -3
- package/src/events/eventRenderers.ts +4 -0
- package/src/flow/FlowSearch.ts +12 -392
- package/src/flow/actions/send_msg.ts +2 -1
- package/src/flow/actions/set_contact_field.ts +1 -0
- package/src/flow/actions/set_contact_name.ts +1 -0
- package/src/flow/nodes/split_by_ticket.ts +1 -0
- package/src/flow/nodes/wait_for_dial.ts +1 -0
- package/src/form/RangePicker.ts +10 -2
- package/src/form/select/Select.ts +15 -2
- package/src/interfaces.ts +2 -0
- package/src/layout/HeaderBar.ts +28 -2
- package/src/layout/PageHeader.ts +14 -7
- package/src/layout/SearchModal.ts +564 -0
- package/src/list/BroadcastList.ts +17 -6
- package/src/list/ContentList.ts +29 -13
- package/src/list/FieldList.ts +8 -1
- package/src/list/ShortcutContentList.ts +347 -0
- package/src/list/TembaMenu.ts +71 -13
- package/src/live/CampaignEvents.ts +31 -11
- package/src/live/ContactChat.ts +276 -76
- package/src/live/ContactTimeline.ts +46 -24
- package/src/live/ContactWatch.ts +166 -63
- package/src/live/Realtime.ts +137 -8
- package/src/live/SocketService.ts +115 -10
- package/src/live/TicketSearch.ts +290 -0
- package/src/live/Watchers.ts +93 -0
- package/src/simulator/Simulator.ts +59 -36
- package/src/store/Store.ts +17 -34
- package/src/styles/designTokens.ts +68 -17
- package/src/styles/pillVariants.ts +42 -10
- package/static/css/design-system.css +47 -12
- package/static/css/temba-components.css +31 -9
- package/static/svg/index.svg +1 -1
- package/static/svg/work/traced/book-open-01.svg +1 -0
- package/static/svg/work/traced/layout-alt-03.svg +1 -0
- package/static/svg/work/used/book-open-01.svg +3 -0
- package/static/svg/work/used/layout-alt-03.svg +3 -0
- package/temba-modules.ts +4 -0
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
import { html, css, CSSResultGroup, TemplateResult } from 'lit';
|
|
2
|
+
import { property, state, query } from 'lit/decorators.js';
|
|
3
|
+
import { styleMap } from 'lit/directives/style-map.js';
|
|
4
|
+
import { Icon } from '../Icons';
|
|
5
|
+
import { RapidElement } from '../RapidElement';
|
|
6
|
+
|
|
7
|
+
export interface ModalSearchResult {
|
|
8
|
+
// The label shown in the result's badge (e.g. "Send Message", a contact name)
|
|
9
|
+
typeName: string;
|
|
10
|
+
// Color for the badge background
|
|
11
|
+
color: string;
|
|
12
|
+
// Optional border color for the badge
|
|
13
|
+
borderColor?: string;
|
|
14
|
+
// Optional text color override for the badge
|
|
15
|
+
textColor?: string;
|
|
16
|
+
// The full text that was searched
|
|
17
|
+
fullText: string;
|
|
18
|
+
// The index of the match start in fullText
|
|
19
|
+
matchStart: number;
|
|
20
|
+
// The length of the match (0 renders the text as an unhighlighted preview)
|
|
21
|
+
matchLength: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Base class for modal search windows — a centered overlay with a search
|
|
26
|
+
* input, keyboard-navigable result list and match highlighting. Subclasses
|
|
27
|
+
* implement performSearch() to supply results, either synchronously as the
|
|
28
|
+
* user types (the default) or asynchronously when the user hits Enter (set
|
|
29
|
+
* searchOnEnter for searches that hit an endpoint).
|
|
30
|
+
*/
|
|
31
|
+
export abstract class SearchModal<
|
|
32
|
+
T extends ModalSearchResult = ModalSearchResult
|
|
33
|
+
> extends RapidElement {
|
|
34
|
+
static styles: CSSResultGroup = css`
|
|
35
|
+
:host {
|
|
36
|
+
position: fixed;
|
|
37
|
+
top: 0;
|
|
38
|
+
left: 0;
|
|
39
|
+
right: 0;
|
|
40
|
+
bottom: 0;
|
|
41
|
+
z-index: 100000;
|
|
42
|
+
display: none;
|
|
43
|
+
font-family: var(--font-family, sans-serif);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
:host([open]) {
|
|
47
|
+
display: flex;
|
|
48
|
+
align-items: flex-start;
|
|
49
|
+
justify-content: center;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.backdrop {
|
|
53
|
+
position: absolute;
|
|
54
|
+
top: 0;
|
|
55
|
+
left: 0;
|
|
56
|
+
right: 0;
|
|
57
|
+
bottom: 0;
|
|
58
|
+
background: rgba(0, 0, 0, 0.3);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.search-container {
|
|
62
|
+
position: relative;
|
|
63
|
+
margin-top: 80px;
|
|
64
|
+
width: 560px;
|
|
65
|
+
max-height: calc(100vh - 160px);
|
|
66
|
+
background: white;
|
|
67
|
+
border-radius: 14px;
|
|
68
|
+
box-shadow:
|
|
69
|
+
0 24px 80px rgba(0, 0, 0, 0.2),
|
|
70
|
+
0 0 0 1px rgba(0, 0, 0, 0.05);
|
|
71
|
+
display: flex;
|
|
72
|
+
flex-direction: column;
|
|
73
|
+
overflow: hidden;
|
|
74
|
+
animation: searchSlideIn 0.15s ease-out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
@keyframes searchSlideIn {
|
|
78
|
+
from {
|
|
79
|
+
opacity: 0;
|
|
80
|
+
transform: scale(0.98) translateY(-8px);
|
|
81
|
+
}
|
|
82
|
+
to {
|
|
83
|
+
opacity: 1;
|
|
84
|
+
transform: scale(1) translateY(0);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.search-input-row {
|
|
89
|
+
display: flex;
|
|
90
|
+
align-items: center;
|
|
91
|
+
padding: 14px 18px;
|
|
92
|
+
border-bottom: 1px solid #e5e7eb;
|
|
93
|
+
gap: 10px;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.search-icon {
|
|
97
|
+
color: #9ca3af;
|
|
98
|
+
flex-shrink: 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.search-icon temba-icon {
|
|
102
|
+
--icon-color: #9ca3af;
|
|
103
|
+
display: block;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
input {
|
|
107
|
+
flex: 1;
|
|
108
|
+
border: none;
|
|
109
|
+
outline: none;
|
|
110
|
+
font-size: 16px;
|
|
111
|
+
background: transparent;
|
|
112
|
+
color: #111;
|
|
113
|
+
font-family: inherit;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
input::placeholder {
|
|
117
|
+
color: #9ca3af;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.results {
|
|
121
|
+
overflow-y: auto;
|
|
122
|
+
max-height: 400px;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.result-item {
|
|
126
|
+
display: flex;
|
|
127
|
+
align-items: center;
|
|
128
|
+
padding: 10px 18px;
|
|
129
|
+
gap: 12px;
|
|
130
|
+
cursor: pointer;
|
|
131
|
+
border-bottom: 1px solid #f3f4f6;
|
|
132
|
+
transition: background 0.08s;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.result-item:last-child {
|
|
136
|
+
border-bottom: none;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.result-item:hover,
|
|
140
|
+
.result-item.highlighted {
|
|
141
|
+
background: #f0f4ff;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.result-type-badge {
|
|
145
|
+
flex-shrink: 0;
|
|
146
|
+
font-size: 11px;
|
|
147
|
+
font-weight: 600;
|
|
148
|
+
color: white;
|
|
149
|
+
padding: 2px 10px;
|
|
150
|
+
border-radius: 8px;
|
|
151
|
+
border: 2px solid transparent;
|
|
152
|
+
white-space: nowrap;
|
|
153
|
+
text-align: center;
|
|
154
|
+
box-sizing: border-box;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.result-text {
|
|
158
|
+
min-width: 0;
|
|
159
|
+
font-size: 14px;
|
|
160
|
+
color: #374151;
|
|
161
|
+
line-height: 1.4;
|
|
162
|
+
white-space: nowrap;
|
|
163
|
+
overflow: hidden;
|
|
164
|
+
text-overflow: ellipsis;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.result-text mark {
|
|
168
|
+
background: #fef08a;
|
|
169
|
+
color: inherit;
|
|
170
|
+
border-radius: 2px;
|
|
171
|
+
padding: 0 1px;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.no-results {
|
|
175
|
+
padding: 24px 18px;
|
|
176
|
+
text-align: center;
|
|
177
|
+
color: #9ca3af;
|
|
178
|
+
font-size: 14px;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.loading {
|
|
182
|
+
display: flex;
|
|
183
|
+
justify-content: center;
|
|
184
|
+
padding: 24px 18px;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.result-count {
|
|
188
|
+
padding: 8px 18px;
|
|
189
|
+
font-size: 12px;
|
|
190
|
+
color: #9ca3af;
|
|
191
|
+
border-top: 1px solid #e5e7eb;
|
|
192
|
+
text-align: right;
|
|
193
|
+
flex-shrink: 0;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.hint {
|
|
197
|
+
padding: 10px 18px;
|
|
198
|
+
font-size: 12px;
|
|
199
|
+
color: #9ca3af;
|
|
200
|
+
text-align: center;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
kbd {
|
|
204
|
+
background: #f3f4f6;
|
|
205
|
+
border: 1px solid #d1d5db;
|
|
206
|
+
border-radius: 4px;
|
|
207
|
+
padding: 1px 5px;
|
|
208
|
+
font-size: 11px;
|
|
209
|
+
font-family: inherit;
|
|
210
|
+
}
|
|
211
|
+
`;
|
|
212
|
+
|
|
213
|
+
@property({ type: Boolean, reflect: true })
|
|
214
|
+
open = false;
|
|
215
|
+
|
|
216
|
+
// When set, searching waits for Enter instead of running as the user types.
|
|
217
|
+
// Use for searches that hit an endpoint and shouldn't fire on every keystroke.
|
|
218
|
+
@property({ type: Boolean })
|
|
219
|
+
searchOnEnter = false;
|
|
220
|
+
|
|
221
|
+
@state()
|
|
222
|
+
protected searchQuery = '';
|
|
223
|
+
|
|
224
|
+
@state()
|
|
225
|
+
protected results: T[] = [];
|
|
226
|
+
|
|
227
|
+
@state()
|
|
228
|
+
protected highlightedIndex = 0;
|
|
229
|
+
|
|
230
|
+
@state()
|
|
231
|
+
protected loading = false;
|
|
232
|
+
|
|
233
|
+
// In searchOnEnter mode, whether the query has changed since the last search
|
|
234
|
+
@state()
|
|
235
|
+
protected dirty = false;
|
|
236
|
+
|
|
237
|
+
// Set when an async performSearch rejects, so a failed lookup reads as a
|
|
238
|
+
// failure rather than as an empty result set
|
|
239
|
+
@state()
|
|
240
|
+
protected error = false;
|
|
241
|
+
|
|
242
|
+
// Bumped on each search (and on query edits in searchOnEnter mode) so
|
|
243
|
+
// in-flight async results that are no longer wanted get dropped
|
|
244
|
+
private searchGeneration = 0;
|
|
245
|
+
|
|
246
|
+
@query('input')
|
|
247
|
+
private inputEl!: HTMLInputElement;
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Produce results for the given query. Synchronous implementations run on
|
|
251
|
+
* every keystroke; async implementations should be paired with searchOnEnter.
|
|
252
|
+
*/
|
|
253
|
+
protected abstract performSearch(query: string): T[] | Promise<T[]>;
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The label used for the dialog, placeholder and aria attributes.
|
|
257
|
+
*/
|
|
258
|
+
protected abstract getSearchLabel(): string;
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Called when a search is abandoned - the modal closed, or the query
|
|
262
|
+
* edited out from under it. Subclasses with an in-flight request should
|
|
263
|
+
* cancel it here.
|
|
264
|
+
*/
|
|
265
|
+
protected cancelSearch(): void {
|
|
266
|
+
// nothing to do for synchronous searches
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
public show(): void {
|
|
270
|
+
this.open = true;
|
|
271
|
+
this.searchQuery = '';
|
|
272
|
+
this.results = [];
|
|
273
|
+
this.highlightedIndex = 0;
|
|
274
|
+
this.loading = false;
|
|
275
|
+
this.dirty = false;
|
|
276
|
+
this.error = false;
|
|
277
|
+
this.searchGeneration++;
|
|
278
|
+
this.updateComplete.then(() => {
|
|
279
|
+
this.inputEl?.focus();
|
|
280
|
+
this.inputEl?.select();
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
public hide(): void {
|
|
285
|
+
this.cancelSearch();
|
|
286
|
+
this.open = false;
|
|
287
|
+
this.searchQuery = '';
|
|
288
|
+
this.results = [];
|
|
289
|
+
this.loading = false;
|
|
290
|
+
this.dirty = false;
|
|
291
|
+
this.error = false;
|
|
292
|
+
this.searchGeneration++;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private handleInput(e: InputEvent): void {
|
|
296
|
+
const input = e.target as HTMLInputElement;
|
|
297
|
+
this.searchQuery = input.value;
|
|
298
|
+
this.highlightedIndex = 0;
|
|
299
|
+
this.error = false;
|
|
300
|
+
// whatever was in flight was for the old query
|
|
301
|
+
this.cancelSearch();
|
|
302
|
+
if (this.searchOnEnter) {
|
|
303
|
+
// invalidate any in-flight search and wait for Enter
|
|
304
|
+
this.searchGeneration++;
|
|
305
|
+
this.results = [];
|
|
306
|
+
this.loading = false;
|
|
307
|
+
this.dirty = true;
|
|
308
|
+
} else {
|
|
309
|
+
this.runSearch();
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private handleKeyDown(e: KeyboardEvent): void {
|
|
314
|
+
if (e.key === 'Escape') {
|
|
315
|
+
e.preventDefault();
|
|
316
|
+
this.hide();
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (e.key === 'ArrowDown' || (e.ctrlKey && e.key === 'n')) {
|
|
320
|
+
e.preventDefault();
|
|
321
|
+
if (this.results.length > 0) {
|
|
322
|
+
this.highlightedIndex =
|
|
323
|
+
(this.highlightedIndex + 1) % this.results.length;
|
|
324
|
+
}
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (e.key === 'ArrowUp' || (e.ctrlKey && e.key === 'p')) {
|
|
328
|
+
e.preventDefault();
|
|
329
|
+
if (this.results.length > 0) {
|
|
330
|
+
this.highlightedIndex =
|
|
331
|
+
(this.highlightedIndex - 1 + this.results.length) %
|
|
332
|
+
this.results.length;
|
|
333
|
+
}
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (e.key === 'Enter') {
|
|
337
|
+
e.preventDefault();
|
|
338
|
+
if (this.searchOnEnter && this.dirty) {
|
|
339
|
+
this.runSearch();
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (this.results.length > 0) {
|
|
343
|
+
this.selectResult(this.results[this.highlightedIndex]);
|
|
344
|
+
}
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private runSearch(): void {
|
|
350
|
+
const generation = ++this.searchGeneration;
|
|
351
|
+
this.dirty = false;
|
|
352
|
+
this.highlightedIndex = 0;
|
|
353
|
+
this.error = false;
|
|
354
|
+
|
|
355
|
+
if (!this.searchQuery.trim()) {
|
|
356
|
+
this.results = [];
|
|
357
|
+
this.loading = false;
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const outcome = this.performSearch(this.searchQuery);
|
|
362
|
+
if (outcome instanceof Promise) {
|
|
363
|
+
this.results = [];
|
|
364
|
+
this.loading = true;
|
|
365
|
+
outcome
|
|
366
|
+
.then((results) => {
|
|
367
|
+
if (generation === this.searchGeneration) {
|
|
368
|
+
this.results = results;
|
|
369
|
+
this.loading = false;
|
|
370
|
+
}
|
|
371
|
+
})
|
|
372
|
+
.catch((error) => {
|
|
373
|
+
// a superseded search has already been replaced by whatever
|
|
374
|
+
// superseded it - that includes searches we aborted ourselves,
|
|
375
|
+
// so those never surface as failures
|
|
376
|
+
if (generation === this.searchGeneration) {
|
|
377
|
+
console.error(
|
|
378
|
+
'search failed',
|
|
379
|
+
error instanceof Response
|
|
380
|
+
? `${error.status} ${error.url || ''}`.trim()
|
|
381
|
+
: error
|
|
382
|
+
);
|
|
383
|
+
this.results = [];
|
|
384
|
+
this.loading = false;
|
|
385
|
+
this.error = true;
|
|
386
|
+
// the query is still the one that failed, so leaving it dirty
|
|
387
|
+
// lets Enter run it again
|
|
388
|
+
this.dirty = this.searchOnEnter;
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
} else {
|
|
392
|
+
this.results = outcome;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
private handleBackdropClick(): void {
|
|
397
|
+
this.hide();
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
protected selectResult(result: T): void {
|
|
401
|
+
this.hide();
|
|
402
|
+
this.dispatchEvent(
|
|
403
|
+
new CustomEvent('temba-search-result-selected', {
|
|
404
|
+
detail: result,
|
|
405
|
+
bubbles: true,
|
|
406
|
+
composed: true
|
|
407
|
+
})
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
protected renderMatchText(result: T): TemplateResult {
|
|
412
|
+
const { matchStart, matchLength } = result;
|
|
413
|
+
const matchEnd = matchStart + matchLength;
|
|
414
|
+
|
|
415
|
+
// Replace newlines with spaces for single-line display
|
|
416
|
+
const text = result.fullText.replace(/\n/g, ' ');
|
|
417
|
+
|
|
418
|
+
// Zero-length matches show an unhighlighted content preview
|
|
419
|
+
if (matchLength === 0) {
|
|
420
|
+
return html`${text}`;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Show leading context before the match, then the match, then trailing.
|
|
424
|
+
// CSS text-overflow:ellipsis clips the trailing text, so we keep the match
|
|
425
|
+
// near the start. When the match is near the end of the text, show more
|
|
426
|
+
// leading context since there's less trailing text to display.
|
|
427
|
+
const afterMatch = text.length - matchEnd;
|
|
428
|
+
const contextBefore = afterMatch < 30 ? 50 : 20;
|
|
429
|
+
const start = Math.max(0, matchStart - contextBefore);
|
|
430
|
+
const prefix = start > 0 ? '…' : '';
|
|
431
|
+
|
|
432
|
+
const before = text.slice(start, matchStart);
|
|
433
|
+
const match = text.slice(matchStart, matchEnd);
|
|
434
|
+
const after = text.slice(matchEnd);
|
|
435
|
+
|
|
436
|
+
return html`${prefix}${before}<mark>${match}</mark>${after}`;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* The content of a single result row. Subclasses can override to customize.
|
|
441
|
+
*/
|
|
442
|
+
protected renderResultContent(result: T): TemplateResult {
|
|
443
|
+
const badgeStyles: { [key: string]: string } = {
|
|
444
|
+
background: result.color,
|
|
445
|
+
borderColor: result.borderColor || result.color
|
|
446
|
+
};
|
|
447
|
+
if (result.textColor) {
|
|
448
|
+
badgeStyles.color = result.textColor;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return html`
|
|
452
|
+
<div class="result-type-badge" style=${styleMap(badgeStyles)}>
|
|
453
|
+
${result.typeName}
|
|
454
|
+
</div>
|
|
455
|
+
<div class="result-text">${this.renderMatchText(result)}</div>
|
|
456
|
+
`;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* The message shown when a completed search produced no results. Subclasses
|
|
461
|
+
* can override to explain why the result set came back empty.
|
|
462
|
+
*/
|
|
463
|
+
protected getNoResultsMessage(): string {
|
|
464
|
+
return 'No matches found';
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* The hint shown before any query has been entered.
|
|
469
|
+
*/
|
|
470
|
+
protected renderHint(): TemplateResult {
|
|
471
|
+
return html`<div class="hint">
|
|
472
|
+
<kbd>↑</kbd> <kbd>↓</kbd> to navigate <kbd>Enter</kbd> to open
|
|
473
|
+
<kbd>Esc</kbd> to close
|
|
474
|
+
</div>`;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
private renderBody(): TemplateResult {
|
|
478
|
+
if (!this.searchQuery.trim()) {
|
|
479
|
+
return this.renderHint();
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// a failed search leaves the query dirty so Enter retries it, so the
|
|
483
|
+
// failure has to be reported ahead of the "Enter to search" hint
|
|
484
|
+
if (this.error) {
|
|
485
|
+
return html`<div class="no-results">Search failed - try again</div>`;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (this.searchOnEnter && this.dirty) {
|
|
489
|
+
return html`<div class="hint"><kbd>Enter</kbd> to search</div>`;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (this.loading) {
|
|
493
|
+
return html`<div class="loading">
|
|
494
|
+
<temba-loading units="6" size="8"></temba-loading>
|
|
495
|
+
</div>`;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (this.results.length === 0) {
|
|
499
|
+
return html`<div class="no-results">${this.getNoResultsMessage()}</div>`;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return html`
|
|
503
|
+
<div class="results">
|
|
504
|
+
${this.results.map(
|
|
505
|
+
(result, index) => html`
|
|
506
|
+
<div
|
|
507
|
+
class="result-item ${index === this.highlightedIndex
|
|
508
|
+
? 'highlighted'
|
|
509
|
+
: ''}"
|
|
510
|
+
@click=${() => this.selectResult(result)}
|
|
511
|
+
@mouseenter=${() => {
|
|
512
|
+
this.highlightedIndex = index;
|
|
513
|
+
}}
|
|
514
|
+
>
|
|
515
|
+
${this.renderResultContent(result)}
|
|
516
|
+
</div>
|
|
517
|
+
`
|
|
518
|
+
)}
|
|
519
|
+
</div>
|
|
520
|
+
<div class="result-count">
|
|
521
|
+
${this.results.length} result${this.results.length !== 1 ? 's' : ''}
|
|
522
|
+
</div>
|
|
523
|
+
`;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
updated(changedProps: Map<string, unknown>): void {
|
|
527
|
+
if (changedProps.has('highlightedIndex')) {
|
|
528
|
+
const highlighted = this.shadowRoot?.querySelector(
|
|
529
|
+
'.result-item.highlighted'
|
|
530
|
+
);
|
|
531
|
+
highlighted?.scrollIntoView({ block: 'nearest' });
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
render(): TemplateResult {
|
|
536
|
+
const searchLabel = this.getSearchLabel();
|
|
537
|
+
|
|
538
|
+
return html`
|
|
539
|
+
<div class="backdrop" @click=${this.handleBackdropClick}></div>
|
|
540
|
+
<div
|
|
541
|
+
class="search-container"
|
|
542
|
+
role="dialog"
|
|
543
|
+
aria-modal="true"
|
|
544
|
+
aria-label=${searchLabel}
|
|
545
|
+
@click=${(e: Event) => e.stopPropagation()}
|
|
546
|
+
>
|
|
547
|
+
<div class="search-input-row">
|
|
548
|
+
<div class="search-icon">
|
|
549
|
+
<temba-icon name=${Icon.search} size="1.5"></temba-icon>
|
|
550
|
+
</div>
|
|
551
|
+
<input
|
|
552
|
+
type="text"
|
|
553
|
+
placeholder="${searchLabel}..."
|
|
554
|
+
aria-label=${searchLabel}
|
|
555
|
+
.value=${this.searchQuery}
|
|
556
|
+
@input=${this.handleInput}
|
|
557
|
+
@keydown=${this.handleKeyDown}
|
|
558
|
+
/>
|
|
559
|
+
</div>
|
|
560
|
+
${this.renderBody()}
|
|
561
|
+
</div>
|
|
562
|
+
`;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
@@ -198,7 +198,8 @@ export class BroadcastList extends ContentList<Broadcast> {
|
|
|
198
198
|
}
|
|
199
199
|
.menu-button.destructive:hover {
|
|
200
200
|
border-color: #dc2626;
|
|
201
|
-
|
|
201
|
+
/* pre-mixed fallback for browsers without color-mix() */
|
|
202
|
+
background: #fdf2f2;
|
|
202
203
|
}
|
|
203
204
|
.detail-body {
|
|
204
205
|
display: flex;
|
|
@@ -243,15 +244,25 @@ export class BroadcastList extends ContentList<Broadcast> {
|
|
|
243
244
|
align-items: flex-start;
|
|
244
245
|
gap: 0.6em;
|
|
245
246
|
padding: 0.9em 1em 1em;
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
var(--sunken, #f1f3f5) 45%,
|
|
249
|
-
var(--surface, #fff)
|
|
250
|
-
);
|
|
247
|
+
/* pre-mixed fallback for browsers without color-mix() */
|
|
248
|
+
background: #f9fafa;
|
|
251
249
|
border: 1px solid var(--border, #e4e7ec);
|
|
252
250
|
border-radius: var(--r);
|
|
253
251
|
line-height: 1.5;
|
|
254
252
|
}
|
|
253
|
+
|
|
254
|
+
@supports (color: color-mix(in srgb, red, red)) {
|
|
255
|
+
.menu-button.destructive:hover {
|
|
256
|
+
background: color-mix(in srgb, #dc2626 6%, var(--surface, #fff));
|
|
257
|
+
}
|
|
258
|
+
.detail-message {
|
|
259
|
+
background: color-mix(
|
|
260
|
+
in srgb,
|
|
261
|
+
var(--sunken, #f1f3f5) 45%,
|
|
262
|
+
var(--surface, #fff)
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
255
266
|
.detail-message temba-expression-highlight {
|
|
256
267
|
align-self: stretch;
|
|
257
268
|
white-space: pre-wrap;
|
package/src/list/ContentList.ts
CHANGED
|
@@ -154,24 +154,33 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
154
154
|
--sort-gutter: 16px;
|
|
155
155
|
/* Selected-row wash — accent-50 on its own reads grey, so a
|
|
156
156
|
touch of the accent-400 rail colour is mixed in to give
|
|
157
|
-
the selection a faint accent tint.
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
var(--accent-50)
|
|
162
|
-
);
|
|
157
|
+
the selection a faint accent tint. Statics pre-mixed from
|
|
158
|
+
the default accent for browsers without color-mix(); the
|
|
159
|
+
@supports block below re-derives them from the tokens. */
|
|
160
|
+
--cl-selected: #e5ecf8;
|
|
163
161
|
/* The dividers bracketing a selected row — the plain grey
|
|
164
162
|
--border reads as a seam against the wash, so this is the
|
|
165
163
|
same accent pushed a little further. */
|
|
166
|
-
--cl-selected-border:
|
|
167
|
-
in oklab,
|
|
168
|
-
var(--accent-400) 24%,
|
|
169
|
-
var(--accent-50)
|
|
170
|
-
);
|
|
164
|
+
--cl-selected-border: #cbdaf1;
|
|
171
165
|
/* Tint shared by the frozen (pinned) columns and the header
|
|
172
166
|
row, so the header reads as the same quiet sub-panel as the
|
|
173
167
|
pinned section. */
|
|
174
|
-
--cl-pin-bg:
|
|
168
|
+
--cl-pin-bg: #fafbfc;
|
|
169
|
+
}
|
|
170
|
+
@supports (color: color-mix(in srgb, red, red)) {
|
|
171
|
+
:host {
|
|
172
|
+
--cl-selected: color-mix(
|
|
173
|
+
in oklab,
|
|
174
|
+
var(--accent-400) 9%,
|
|
175
|
+
var(--accent-50)
|
|
176
|
+
);
|
|
177
|
+
--cl-selected-border: color-mix(
|
|
178
|
+
in oklab,
|
|
179
|
+
var(--accent-400) 24%,
|
|
180
|
+
var(--accent-50)
|
|
181
|
+
);
|
|
182
|
+
--cl-pin-bg: color-mix(in oklab, var(--sunken) 35%, var(--surface));
|
|
183
|
+
}
|
|
175
184
|
}
|
|
176
185
|
/* fillWindow — take the slack of a height-bounded flex-column
|
|
177
186
|
parent so the table scrolls internally; min-height: 0 (set
|
|
@@ -296,7 +305,14 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
296
305
|
color: var(--danger);
|
|
297
306
|
}
|
|
298
307
|
.bulk-action.destructive:hover {
|
|
299
|
-
|
|
308
|
+
/* pre-mixed fallback for browsers without color-mix() */
|
|
309
|
+
background: #f6d9d9;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
@supports (color: color-mix(in srgb, red, red)) {
|
|
313
|
+
.bulk-action.destructive:hover {
|
|
314
|
+
background: color-mix(in oklab, var(--danger) 20%, white);
|
|
315
|
+
}
|
|
300
316
|
}
|
|
301
317
|
.bulk-action temba-icon {
|
|
302
318
|
--icon-color: currentColor;
|
package/src/list/FieldList.ts
CHANGED
|
@@ -377,7 +377,14 @@ export class FieldList extends EndpointMonitorElement {
|
|
|
377
377
|
|
|
378
378
|
.menu-button.destructive:hover {
|
|
379
379
|
border-color: #dc2626;
|
|
380
|
-
|
|
380
|
+
/* pre-mixed fallback for browsers without color-mix() */
|
|
381
|
+
background: #fdf2f2;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
@supports (color: color-mix(in srgb, red, red)) {
|
|
385
|
+
.menu-button.destructive:hover {
|
|
386
|
+
background: color-mix(in srgb, #dc2626 6%, var(--surface, #fff));
|
|
387
|
+
}
|
|
381
388
|
}
|
|
382
389
|
|
|
383
390
|
.detail-body {
|