@nyaruka/temba-components 0.159.2 → 0.159.4
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 -2
- package/dist/temba-components.js +1495 -1223
- package/dist/temba-components.js.map +1 -1
- package/package.json +1 -1
- package/rollup.components.mjs +1 -0
- package/src/display/Chat.ts +44 -6
- package/src/display/TembaUser.ts +21 -9
- package/src/flow/NodeEditor.ts +33 -13
- package/src/flow/actions/add_input_labels.ts +4 -1
- package/src/flow/actions/send_msg.ts +1 -0
- package/src/flow/actions/set_run_result.ts +1 -0
- package/src/flow/nodes/split_by_ticket.ts +1 -0
- package/src/form/Checkbox.ts +37 -11
- package/src/layout/PageHeader.ts +35 -13
- package/src/list/ContactList.ts +69 -19
- package/src/list/ContentList.ts +711 -271
- package/src/list/MsgList.ts +39 -14
- package/src/live/ContactChat.ts +3 -0
- package/src/live/ContactTimeline.ts +1 -1
- package/src/store/Store.ts +41 -1
- package/web-dev-server.config.mjs +11 -0
- package/web-test-runner.config.mjs +1 -0
package/src/list/ContentList.ts
CHANGED
|
@@ -5,7 +5,6 @@ import { Icon } from '../Icons';
|
|
|
5
5
|
import { CustomEventType } from '../interfaces';
|
|
6
6
|
import { getUrl, postUrl } from '../utils';
|
|
7
7
|
import { designTokens } from '../styles/designTokens';
|
|
8
|
-
import { Dropdown } from '../display/Dropdown';
|
|
9
8
|
|
|
10
9
|
/** A single column in the list. Subclasses typically define a static
|
|
11
10
|
* set via {@link ContentList.columns}; consumers may also set it as
|
|
@@ -69,6 +68,17 @@ export interface ContentListBulkAction {
|
|
|
69
68
|
* Setting this turns the action into a label-toggle dropdown
|
|
70
69
|
* instead of a fire-and-forget bulk-action event. */
|
|
71
70
|
labelsEndpoint?: string;
|
|
71
|
+
/** The item field holding each row's current memberships (`[{uuid}]`)
|
|
72
|
+
* used to pre-check / partially-check the dropdown against the
|
|
73
|
+
* selected rows. Defaults to `labels` (messages); the contact list
|
|
74
|
+
* sets it to `groups`. */
|
|
75
|
+
labelsKey?: string;
|
|
76
|
+
/** When true, the component does not POST the action to
|
|
77
|
+
* `actionEndpoint` — it only fires the `temba-bulk-action` event
|
|
78
|
+
* with the selected ids and leaves the work to the host. Used for
|
|
79
|
+
* actions that open a modal (e.g. send / start-flow) rather than
|
|
80
|
+
* mutating rows server-side. */
|
|
81
|
+
clientOnly?: boolean;
|
|
72
82
|
}
|
|
73
83
|
|
|
74
84
|
interface FetchResponse<T = any> {
|
|
@@ -76,6 +86,16 @@ interface FetchResponse<T = any> {
|
|
|
76
86
|
count?: number;
|
|
77
87
|
next?: string;
|
|
78
88
|
previous?: string;
|
|
89
|
+
/** Server-adjusted/normalized form of the search that produced these
|
|
90
|
+
* results (rapidpro's contact search echoes the parsed `query`).
|
|
91
|
+
* When present after a search, it's adopted as the basis of the
|
|
92
|
+
* results and mirrored back into the search input. */
|
|
93
|
+
query?: string;
|
|
94
|
+
/** A query-validation error message (e.g. an unparseable search like
|
|
95
|
+
* `age >`). The server still returns a list-shaped, empty response;
|
|
96
|
+
* this message is surfaced over the empty table instead of the plain
|
|
97
|
+
* "nothing to show" copy. */
|
|
98
|
+
error?: string;
|
|
79
99
|
}
|
|
80
100
|
|
|
81
101
|
/**
|
|
@@ -127,6 +147,10 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
127
147
|
var(--accent-400) 24%,
|
|
128
148
|
var(--accent-50)
|
|
129
149
|
);
|
|
150
|
+
/* Tint shared by the frozen (pinned) columns and the header
|
|
151
|
+
row, so the header reads as the same quiet sub-panel as the
|
|
152
|
+
pinned section. */
|
|
153
|
+
--cl-pin-bg: color-mix(in oklab, var(--sunken) 35%, var(--surface));
|
|
130
154
|
}
|
|
131
155
|
/* fillWindow — take the slack of a height-bounded flex-column
|
|
132
156
|
parent so the table scrolls internally; min-height: 0 (set
|
|
@@ -148,17 +172,21 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
148
172
|
font-size: 13px;
|
|
149
173
|
}
|
|
150
174
|
|
|
151
|
-
/* Built-in action button (Search).
|
|
152
|
-
|
|
153
|
-
bring their own. */
|
|
175
|
+
/* Built-in action button (Search). Bordered text + icon on a
|
|
176
|
+
transparent ground; host's slotted buttons can match this
|
|
177
|
+
style or bring their own. */
|
|
154
178
|
.action {
|
|
155
179
|
display: inline-flex;
|
|
156
180
|
align-items: center;
|
|
157
181
|
gap: 6px;
|
|
158
182
|
cursor: pointer;
|
|
159
183
|
user-select: none;
|
|
160
|
-
|
|
184
|
+
height: 26px;
|
|
185
|
+
box-sizing: border-box;
|
|
186
|
+
padding: 0 10px;
|
|
187
|
+
border: 1px solid var(--border-strong);
|
|
161
188
|
border-radius: var(--r-sm);
|
|
189
|
+
background: transparent;
|
|
162
190
|
color: var(--text-2);
|
|
163
191
|
}
|
|
164
192
|
.action:hover {
|
|
@@ -169,17 +197,73 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
169
197
|
--icon-color: currentColor;
|
|
170
198
|
}
|
|
171
199
|
|
|
200
|
+
/* When rows are selected, the bulk actions overlay the column-
|
|
201
|
+
header row (covering the column labels) starting just right of
|
|
202
|
+
the select-all checkbox — rather than replacing the page header.
|
|
203
|
+
Positioned in the table-frame (not the scrolling table) so it
|
|
204
|
+
stays put horizontally, with an opaque header-tint background to
|
|
205
|
+
hide the labels underneath, above the sticky header (z 2/3). */
|
|
206
|
+
.bulk-bar {
|
|
207
|
+
position: absolute;
|
|
208
|
+
top: 0;
|
|
209
|
+
/* The first chip's left edge sits at the row's leading content
|
|
210
|
+
(--cl-firstcol-left) — the icon on icon lists, the text on
|
|
211
|
+
text lists — so the bulk bar starts at the same point as the
|
|
212
|
+
row content on every list, matching the message list. */
|
|
213
|
+
left: var(--cl-firstcol-left, 44px);
|
|
214
|
+
right: var(--cl-scrollbar-w, 0px);
|
|
215
|
+
height: var(--cl-header-height, 36px);
|
|
216
|
+
z-index: 4;
|
|
217
|
+
display: flex;
|
|
218
|
+
align-items: center;
|
|
219
|
+
gap: 4px;
|
|
220
|
+
padding: 0 8px 0 0;
|
|
221
|
+
background: var(--cl-pin-bg);
|
|
222
|
+
/* keep the header's bottom rule visible — the bar sits on top of
|
|
223
|
+
it, so carry the same inset border the header th uses */
|
|
224
|
+
box-shadow: inset 0 -1px 0 0 var(--border);
|
|
225
|
+
}
|
|
172
226
|
.bulk-action {
|
|
173
227
|
display: inline-flex;
|
|
174
228
|
align-items: center;
|
|
175
|
-
|
|
176
|
-
|
|
229
|
+
/* Compact chips — the bar is an overlay centered in the table's
|
|
230
|
+
header row, so the chips size to themselves (they no longer
|
|
231
|
+
need to match the page-header button height). Kept small and
|
|
232
|
+
lightly padded so a full set fits the header strip. */
|
|
233
|
+
height: 22px;
|
|
234
|
+
box-sizing: border-box;
|
|
235
|
+
padding: 0 6px;
|
|
177
236
|
border-radius: var(--r-sm);
|
|
178
237
|
background: var(--accent-100);
|
|
179
238
|
color: var(--accent-800);
|
|
180
|
-
font-size:
|
|
239
|
+
font-size: 12px;
|
|
181
240
|
cursor: pointer;
|
|
182
241
|
user-select: none;
|
|
242
|
+
/* labels never wrap; when the bar runs out of room they collapse
|
|
243
|
+
to icon-only (see .bulk-bar.collapsed .bulk-label) */
|
|
244
|
+
white-space: nowrap;
|
|
245
|
+
}
|
|
246
|
+
/* The label sits a gap to the right of the icon. Both the width and
|
|
247
|
+
that gap collapse to 0 when the bar is too narrow, animating the
|
|
248
|
+
chips down to icon-only — the same max-width trick the tabs use. */
|
|
249
|
+
.bulk-label {
|
|
250
|
+
display: inline-block;
|
|
251
|
+
overflow: hidden;
|
|
252
|
+
white-space: nowrap;
|
|
253
|
+
max-width: 160px;
|
|
254
|
+
margin-left: 4px;
|
|
255
|
+
transition:
|
|
256
|
+
max-width 220ms ease,
|
|
257
|
+
margin-left 220ms ease;
|
|
258
|
+
}
|
|
259
|
+
.bulk-bar.collapsed .bulk-label {
|
|
260
|
+
max-width: 0;
|
|
261
|
+
margin-left: 0;
|
|
262
|
+
}
|
|
263
|
+
/* While measuring the expanded width, suppress the label animation
|
|
264
|
+
so scrollWidth reflects the final (not mid-transition) size. */
|
|
265
|
+
.bulk-bar.measuring .bulk-label {
|
|
266
|
+
transition: none;
|
|
183
267
|
}
|
|
184
268
|
.bulk-action:hover {
|
|
185
269
|
background: var(--accent-200);
|
|
@@ -194,10 +278,18 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
194
278
|
.bulk-action temba-icon {
|
|
195
279
|
--icon-color: currentColor;
|
|
196
280
|
}
|
|
281
|
+
/* Quiet, muted selection tally — right-aligned (margin-left: auto)
|
|
282
|
+
so the action chips stay fixed against the checkbox regardless
|
|
283
|
+
of the count's width. */
|
|
197
284
|
.bulk-count {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
margin-
|
|
285
|
+
color: var(--text-3);
|
|
286
|
+
font-size: 12.5px;
|
|
287
|
+
margin-left: auto;
|
|
288
|
+
padding-left: 12px;
|
|
289
|
+
/* keep the tally on one line — it must never wrap even when the
|
|
290
|
+
bar is tight (the chips collapse to make room instead) */
|
|
291
|
+
white-space: nowrap;
|
|
292
|
+
flex-shrink: 0;
|
|
201
293
|
}
|
|
202
294
|
|
|
203
295
|
/* Label-toggle dropdown — temba-dropdown wraps the bulk-
|
|
@@ -247,19 +339,19 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
247
339
|
text-overflow: ellipsis;
|
|
248
340
|
white-space: nowrap;
|
|
249
341
|
}
|
|
250
|
-
.lbl-menu temba-loading {
|
|
251
|
-
flex: 0 0 auto;
|
|
252
|
-
}
|
|
253
342
|
|
|
254
343
|
/* Inline search bar — slides below the title row inside the
|
|
255
|
-
panel when the search trigger is active.
|
|
256
|
-
|
|
344
|
+
panel when the search trigger is active. Bare single-line
|
|
345
|
+
input, no border, --sunken background. */
|
|
257
346
|
.searchbar {
|
|
258
347
|
display: flex;
|
|
259
348
|
align-items: center;
|
|
260
|
-
gap:
|
|
349
|
+
gap: 6px;
|
|
261
350
|
padding: 6px 12px;
|
|
262
|
-
|
|
351
|
+
/* Pull up under the header — the header carries its own bottom
|
|
352
|
+
padding, so without this the searchbar sits a full gap below
|
|
353
|
+
the title row. The negative top margin tightens that gap. */
|
|
354
|
+
margin: -6px 0 12px 0;
|
|
263
355
|
background: var(--sunken);
|
|
264
356
|
border-radius: var(--r-sm);
|
|
265
357
|
color: var(--text-3);
|
|
@@ -276,27 +368,40 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
276
368
|
.searchbar input::placeholder {
|
|
277
369
|
color: var(--text-3);
|
|
278
370
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
}
|
|
287
|
-
.searchbar .clear:hover,
|
|
288
|
-
.searchbar .submit:hover {
|
|
289
|
-
color: var(--text-1);
|
|
290
|
-
}
|
|
291
|
-
/* Result tally for the active search — quiet, trailing the
|
|
292
|
-
input, so the user can confirm how many rows matched without
|
|
293
|
-
the count competing with the query itself. */
|
|
294
|
-
.searchbar .result-count {
|
|
371
|
+
/* Trailing controls — a run-search icon and a close (✕), both
|
|
372
|
+
always present while the bar is open so the bar's height stays
|
|
373
|
+
put. The close is the way out (the header's Search button hides
|
|
374
|
+
itself while the bar is open). The "↵ to search" hint sits just
|
|
375
|
+
left of the run icon and fades in only when there's a pending
|
|
376
|
+
draft to apply — the same trigger that lights the icon. */
|
|
377
|
+
.searchbar .search-hint {
|
|
295
378
|
flex: 0 0 auto;
|
|
379
|
+
font-size: 0.75em;
|
|
296
380
|
color: var(--text-3);
|
|
297
|
-
font-size: 12px;
|
|
298
381
|
white-space: nowrap;
|
|
299
|
-
|
|
382
|
+
user-select: none;
|
|
383
|
+
}
|
|
384
|
+
.searchbar .search-hint .enter-key {
|
|
385
|
+
position: relative;
|
|
386
|
+
top: 2px;
|
|
387
|
+
}
|
|
388
|
+
/* Both are our standard clickable icons — bare glyph at rest, a
|
|
389
|
+
circular wash only on hover. The left margin opens a bit of
|
|
390
|
+
breathing room between the icons (and the hint). The default
|
|
391
|
+
hover wash is near-white, which washes out against the sunken
|
|
392
|
+
(grey) box, so each carries a more saturated one. */
|
|
393
|
+
.searchbar .search-go,
|
|
394
|
+
.searchbar .search-cancel {
|
|
395
|
+
flex: 0 0 auto;
|
|
396
|
+
margin-left: 5px;
|
|
397
|
+
--icon-color: var(--text-3);
|
|
398
|
+
--icon-color-circle-hover: rgba(15, 22, 36, 0.1);
|
|
399
|
+
}
|
|
400
|
+
/* Run search only renders once a draft is pending (alongside the
|
|
401
|
+
"↵ to search" hint), so it's always the primary action — its
|
|
402
|
+
hover wash warms to accent. */
|
|
403
|
+
.searchbar .search-go {
|
|
404
|
+
--icon-color-circle-hover: var(--accent-200);
|
|
300
405
|
}
|
|
301
406
|
|
|
302
407
|
/* Card panel — surface white wrapping the header and table.
|
|
@@ -319,10 +424,22 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
319
424
|
}
|
|
320
425
|
/* A window-filling list isn't a floating card — it fills its
|
|
321
426
|
container flush, so it drops the card radius + shadow that
|
|
322
|
-
would otherwise reveal the page background at its corners.
|
|
427
|
+
would otherwise reveal the page background at its corners. The
|
|
428
|
+
panel keeps a horizontal inset (so the header and search bar
|
|
429
|
+
stay padded, not full-bleed) but tightens it to 12px — the
|
|
430
|
+
check-cell's lead padding — so the title text lines up with the
|
|
431
|
+
row checkboxes below it. */
|
|
323
432
|
:host([fill-window]) .panel {
|
|
324
433
|
border-radius: 0;
|
|
325
434
|
box-shadow: none;
|
|
435
|
+
padding: 0 12px;
|
|
436
|
+
}
|
|
437
|
+
/* The rule and the table go full-bleed — escape the panel's 12px
|
|
438
|
+
inset on both sides so the rows use the full width of the
|
|
439
|
+
parent while the header/search bar above them stay padded. */
|
|
440
|
+
:host([fill-window]) .header-rule,
|
|
441
|
+
:host([fill-window]) .table-frame {
|
|
442
|
+
margin: 0 -12px;
|
|
326
443
|
}
|
|
327
444
|
/* The header holds its size; the table frame takes the slack. */
|
|
328
445
|
temba-page-header,
|
|
@@ -375,6 +492,12 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
375
492
|
overflow: auto;
|
|
376
493
|
height: 100%;
|
|
377
494
|
}
|
|
495
|
+
/* With no rows to show (empty / loading / search-error state) the
|
|
496
|
+
body is just the centered state message, so a wide column set
|
|
497
|
+
shouldn't arm a horizontal scrollbar under it. */
|
|
498
|
+
.table-scroll.no-rows {
|
|
499
|
+
overflow-x: hidden;
|
|
500
|
+
}
|
|
378
501
|
|
|
379
502
|
/* auto layout lets the contact-field columns size to the
|
|
380
503
|
content shown in them; system columns are instead held to a
|
|
@@ -402,9 +525,6 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
402
525
|
table.table.fixed th.check-cell {
|
|
403
526
|
width: 16px;
|
|
404
527
|
}
|
|
405
|
-
table.table.fixed th.icon-cell {
|
|
406
|
-
width: 32px;
|
|
407
|
-
}
|
|
408
528
|
|
|
409
529
|
/* The header row sticks to the top of the scroll frame so the
|
|
410
530
|
column labels stay put while the rows scroll under them.
|
|
@@ -420,8 +540,13 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
420
540
|
text-transform: uppercase;
|
|
421
541
|
letter-spacing: 0.06em;
|
|
422
542
|
white-space: nowrap;
|
|
423
|
-
background: var(--
|
|
424
|
-
|
|
543
|
+
background: var(--cl-pin-bg);
|
|
544
|
+
/* The header's bottom border is an inset shadow, not a real
|
|
545
|
+
border: the header is position: sticky and border-collapse
|
|
546
|
+
drops a sticky cell's actual border, so a real border would
|
|
547
|
+
scroll away as rows pass under it. The shadow stays put, so
|
|
548
|
+
the header always keeps a bottom rule to scroll under. */
|
|
549
|
+
box-shadow: inset 0 -1px 0 0 var(--border);
|
|
425
550
|
position: sticky;
|
|
426
551
|
top: 0;
|
|
427
552
|
z-index: 2;
|
|
@@ -431,14 +556,11 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
431
556
|
}
|
|
432
557
|
|
|
433
558
|
tr.row td {
|
|
434
|
-
height:
|
|
559
|
+
height: 38px;
|
|
435
560
|
vertical-align: middle;
|
|
436
561
|
color: var(--text-1);
|
|
437
562
|
border-bottom: 1px solid var(--border);
|
|
438
563
|
}
|
|
439
|
-
tbody tr.row:last-child td {
|
|
440
|
-
border-bottom: none;
|
|
441
|
-
}
|
|
442
564
|
tr.row:hover {
|
|
443
565
|
background: var(--accent-50);
|
|
444
566
|
}
|
|
@@ -560,7 +682,7 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
560
682
|
cell backgrounds so the tint actually lands. */
|
|
561
683
|
.table-frame.overflowing tr.header th.pinned,
|
|
562
684
|
.table-frame.overflowing tr.row td.pinned {
|
|
563
|
-
background:
|
|
685
|
+
background: var(--cl-pin-bg);
|
|
564
686
|
}
|
|
565
687
|
/* The hover/selected wash still wins over the tint so a
|
|
566
688
|
hovered/selected row reads as one continuous strip. */
|
|
@@ -577,27 +699,71 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
577
699
|
.table-frame.overflowing td.pin-last {
|
|
578
700
|
box-shadow: inset -1px 0 0 0 var(--border);
|
|
579
701
|
}
|
|
580
|
-
/* Once scrolled, a soft drop shadow joins the rule to lift the
|
|
581
|
-
frozen edge above the content sliding under it. */
|
|
582
|
-
.table-frame.scrolled th.pin-last,
|
|
583
|
-
.table-frame.scrolled td.pin-last {
|
|
584
|
-
box-shadow:
|
|
585
|
-
inset -1px 0 0 0 var(--border),
|
|
586
|
-
8px 0 9px -9px rgba(15, 23, 42, 0.45);
|
|
587
|
-
}
|
|
588
702
|
/* Mirror of the rule for the right-pinned group — the divider
|
|
589
703
|
sits on the inboard (left) edge of its first cell. */
|
|
590
704
|
.table-frame.overflowing th.pin-first,
|
|
591
705
|
.table-frame.overflowing td.pin-first {
|
|
592
706
|
box-shadow: inset 1px 0 0 0 var(--border);
|
|
593
707
|
}
|
|
594
|
-
/*
|
|
595
|
-
the
|
|
596
|
-
.table-frame.
|
|
597
|
-
|
|
708
|
+
/* A pinned header cell keeps the header's bottom-border shadow
|
|
709
|
+
alongside the pin-edge rule. */
|
|
710
|
+
.table-frame.overflowing tr.header th.pin-last {
|
|
711
|
+
box-shadow:
|
|
712
|
+
inset -1px 0 0 0 var(--border),
|
|
713
|
+
inset 0 -1px 0 0 var(--border);
|
|
714
|
+
}
|
|
715
|
+
.table-frame.overflowing tr.header th.pin-first {
|
|
598
716
|
box-shadow:
|
|
599
717
|
inset 1px 0 0 0 var(--border),
|
|
600
|
-
|
|
718
|
+
inset 0 -1px 0 0 var(--border);
|
|
719
|
+
}
|
|
720
|
+
/* Once scrolled, the frozen edge casts the same soft scroll
|
|
721
|
+
shadow as the sticky header — a gradient (matching th::after
|
|
722
|
+
and .scroll-shadow) drawn just outside the pinned edge, over
|
|
723
|
+
the content sliding under it. It's a ::before so it composes
|
|
724
|
+
with the header's own ::after scroll shadow on a pinned header
|
|
725
|
+
cell. */
|
|
726
|
+
.table-frame th.pin-last::before,
|
|
727
|
+
.table-frame td.pin-last::before {
|
|
728
|
+
content: '';
|
|
729
|
+
position: absolute;
|
|
730
|
+
top: 0;
|
|
731
|
+
bottom: 0;
|
|
732
|
+
left: 100%;
|
|
733
|
+
width: 8px;
|
|
734
|
+
pointer-events: none;
|
|
735
|
+
background: linear-gradient(
|
|
736
|
+
to right,
|
|
737
|
+
rgba(15, 23, 42, 0.12),
|
|
738
|
+
rgba(15, 23, 42, 0)
|
|
739
|
+
);
|
|
740
|
+
opacity: 0;
|
|
741
|
+
transition: opacity 0.15s ease;
|
|
742
|
+
}
|
|
743
|
+
.table-frame.scrolled th.pin-last::before,
|
|
744
|
+
.table-frame.scrolled td.pin-last::before {
|
|
745
|
+
opacity: 1;
|
|
746
|
+
}
|
|
747
|
+
.table-frame th.pin-first::before,
|
|
748
|
+
.table-frame td.pin-first::before {
|
|
749
|
+
content: '';
|
|
750
|
+
position: absolute;
|
|
751
|
+
top: 0;
|
|
752
|
+
bottom: 0;
|
|
753
|
+
right: 100%;
|
|
754
|
+
width: 8px;
|
|
755
|
+
pointer-events: none;
|
|
756
|
+
background: linear-gradient(
|
|
757
|
+
to left,
|
|
758
|
+
rgba(15, 23, 42, 0.12),
|
|
759
|
+
rgba(15, 23, 42, 0)
|
|
760
|
+
);
|
|
761
|
+
opacity: 0;
|
|
762
|
+
transition: opacity 0.15s ease;
|
|
763
|
+
}
|
|
764
|
+
.table-frame.can-scroll-right th.pin-first::before,
|
|
765
|
+
.table-frame.can-scroll-right td.pin-first::before {
|
|
766
|
+
opacity: 1;
|
|
601
767
|
}
|
|
602
768
|
|
|
603
769
|
/* Slack-absorbing column between the pinned and scrolling
|
|
@@ -618,34 +784,77 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
618
784
|
td.grow {
|
|
619
785
|
width: 100%;
|
|
620
786
|
}
|
|
787
|
+
/* Under auto layout a grow column also claims zero intrinsic
|
|
788
|
+
width (max-width: 0) so its content can't widen the table:
|
|
789
|
+
the other columns size to their content first, the grow column
|
|
790
|
+
takes only the leftover, and a long value ellipsis-truncates
|
|
791
|
+
against it (via .cell-inner's overflow). This gives a long
|
|
792
|
+
free-text column — e.g. the message list's body — truncation
|
|
793
|
+
without forcing the whole table to table-layout: fixed (which
|
|
794
|
+
would make every column a declared width). Fixed-layout lists
|
|
795
|
+
keep the spacer-style width:100% behaviour, so this is scoped
|
|
796
|
+
to the auto path. */
|
|
797
|
+
table.table:not(.fixed) td.grow {
|
|
798
|
+
max-width: 0;
|
|
799
|
+
overflow: hidden;
|
|
800
|
+
}
|
|
621
801
|
|
|
622
|
-
/*
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
--cl-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
the
|
|
629
|
-
|
|
802
|
+
/* Right-edge horizontal scroll cue — fades in while there is more
|
|
803
|
+
table to the right. It sits inboard of the right-pinned group
|
|
804
|
+
(--cl-rpin-total, 0 when nothing is right-pinned) and the
|
|
805
|
+
vertical scrollbar (--cl-scrollbar-w) so it fades the scrolling
|
|
806
|
+
content rather than the frozen columns or the scrollbar track,
|
|
807
|
+
and is sized to the rows' height (--cl-rows-height) so it stops
|
|
808
|
+
at the bottom of the table instead of running down the empty
|
|
809
|
+
space below a short list. Its width and intensity match the
|
|
810
|
+
sticky header's scroll shadow (.header-shadow) so the two read
|
|
811
|
+
as the same affordance on different axes; pointer-events: none
|
|
812
|
+
keeps it from eating row clicks. */
|
|
630
813
|
.scroll-shadow {
|
|
631
814
|
position: absolute;
|
|
632
815
|
top: 0;
|
|
633
|
-
|
|
634
|
-
right: var(--cl-rpin-total, 0px);
|
|
635
|
-
width:
|
|
816
|
+
height: var(--cl-rows-height, 100%);
|
|
817
|
+
right: calc(var(--cl-rpin-total, 0px) + var(--cl-scrollbar-w, 0px));
|
|
818
|
+
width: 8px;
|
|
636
819
|
pointer-events: none;
|
|
637
820
|
opacity: 0;
|
|
638
821
|
transition: opacity 0.15s ease;
|
|
639
822
|
background: linear-gradient(
|
|
640
823
|
to right,
|
|
641
|
-
|
|
642
|
-
|
|
824
|
+
rgba(15, 23, 42, 0),
|
|
825
|
+
rgba(15, 23, 42, 0.12)
|
|
643
826
|
);
|
|
644
827
|
}
|
|
645
828
|
.table-frame.can-scroll-right .scroll-shadow {
|
|
646
829
|
opacity: 1;
|
|
647
830
|
}
|
|
648
831
|
|
|
832
|
+
/* Soft drop shadow under the sticky header once the body scrolls
|
|
833
|
+
beneath it — the horizontal counterpart of the .scroll-shadow.
|
|
834
|
+
A single full-width element (rather than a per-cell shadow) so
|
|
835
|
+
it reads as one consistent shadow left-to-right and never
|
|
836
|
+
doubles up where a pinned header cell overlaps a scrolling one.
|
|
837
|
+
Sits just below the header (--cl-header-height) and stops short
|
|
838
|
+
of the vertical scrollbar (--cl-scrollbar-w). */
|
|
839
|
+
.header-shadow {
|
|
840
|
+
position: absolute;
|
|
841
|
+
top: var(--cl-header-height, 36px);
|
|
842
|
+
left: 0;
|
|
843
|
+
right: var(--cl-scrollbar-w, 0px);
|
|
844
|
+
height: 8px;
|
|
845
|
+
pointer-events: none;
|
|
846
|
+
opacity: 0;
|
|
847
|
+
transition: opacity 0.15s ease;
|
|
848
|
+
background: linear-gradient(
|
|
849
|
+
to bottom,
|
|
850
|
+
rgba(15, 23, 42, 0.12),
|
|
851
|
+
rgba(15, 23, 42, 0)
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
.table-frame.scrolled-down .header-shadow {
|
|
855
|
+
opacity: 1;
|
|
856
|
+
}
|
|
857
|
+
|
|
649
858
|
/* Checkbox column — shrink-to-fit (width: 1% is the table-
|
|
650
859
|
cell trick for that). The cell-level @click owns selection;
|
|
651
860
|
the inner checkbox is display-only (pointer-events: none)
|
|
@@ -655,7 +864,10 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
655
864
|
.check-cell {
|
|
656
865
|
width: 1%;
|
|
657
866
|
white-space: nowrap;
|
|
658
|
-
|
|
867
|
+
/* 12px lead from the card edge to the checkbox, then a tight 4px
|
|
868
|
+
trail to the row's leading content — so every list (icon or
|
|
869
|
+
text) starts its content at the same point past the checkbox. */
|
|
870
|
+
padding: 0 4px 0 12px;
|
|
659
871
|
cursor: pointer;
|
|
660
872
|
--icon-color: var(--text-3);
|
|
661
873
|
}
|
|
@@ -706,51 +918,50 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
706
918
|
color: var(--accent-700);
|
|
707
919
|
}
|
|
708
920
|
|
|
709
|
-
/* Leading entity-type icon
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
921
|
+
/* Leading entity-type icon — a small icon shared by every row
|
|
922
|
+
(contact silhouette, flow type icon, etc.). It rides inside the
|
|
923
|
+
first column's cell rather than in its own column, so the column
|
|
924
|
+
header aligns with the icon (the row's leading content) rather
|
|
925
|
+
than the value beside it — and the alignment reads the same
|
|
926
|
+
whether or not the list has a leading icon. Subclasses override
|
|
927
|
+
{@link getRowIcon}; when it returns null for every row no space
|
|
928
|
+
is reserved (see {@link reservesIcon}). */
|
|
929
|
+
.lead-wrap {
|
|
930
|
+
display: flex;
|
|
931
|
+
align-items: center;
|
|
932
|
+
min-width: 0;
|
|
933
|
+
}
|
|
934
|
+
.lead-wrap .cell-inner {
|
|
935
|
+
min-width: 0;
|
|
718
936
|
}
|
|
719
|
-
/* Reserve the icon's footprint
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
the
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
.icon-inner {
|
|
937
|
+
/* Reserve the icon's 1em footprint plus a snug 5px gap to the
|
|
938
|
+
value whether or not this row has an icon, so values stay
|
|
939
|
+
aligned down the column. The fixed box also keeps the column's
|
|
940
|
+
intrinsic width stable while <temba-icon> upgrades — without it
|
|
941
|
+
the column briefly measures narrow and downstream pinned columns
|
|
942
|
+
jump, which races with whatever moment we snapshot. */
|
|
943
|
+
.lead-icon {
|
|
944
|
+
flex: 0 0 auto;
|
|
728
945
|
display: flex;
|
|
729
946
|
align-items: center;
|
|
730
947
|
justify-content: center;
|
|
731
948
|
width: 1em;
|
|
732
949
|
height: 1em;
|
|
950
|
+
margin-right: 5px;
|
|
951
|
+
--icon-color: var(--text-3);
|
|
733
952
|
}
|
|
734
|
-
tr.row.selected .icon
|
|
953
|
+
tr.row.selected .lead-icon {
|
|
735
954
|
--icon-color: var(--accent-700);
|
|
736
955
|
}
|
|
737
956
|
|
|
738
|
-
/* The
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
.icon-cell + .head-cell,
|
|
747
|
-
.icon-cell + .cell {
|
|
748
|
-
padding-left: 4px;
|
|
749
|
-
}
|
|
750
|
-
/* With no icon column the first data cell follows the
|
|
751
|
-
checkbox directly; it drops its left padding entirely so
|
|
752
|
-
the value isn't marooned past a gap meant to clear an
|
|
753
|
-
icon — just the checkbox cell's 12px trailing padding. */
|
|
957
|
+
/* The first column follows the checkbox directly and drops its
|
|
958
|
+
left padding, so its content starts right at the checkbox
|
|
959
|
+
cell's 4px trailing gap: the leading icon on icon lists, the
|
|
960
|
+
value otherwise — and the header label lines up at that same
|
|
961
|
+
point. The table-frame sits inside the panel's 20px padding, so
|
|
962
|
+
that edge already aligns with the page-header content; the last
|
|
963
|
+
cell trims its padding-right (below) so content doesn't crowd
|
|
964
|
+
the card chrome. */
|
|
754
965
|
.check-cell + .head-cell,
|
|
755
966
|
.check-cell + .cell {
|
|
756
967
|
padding-left: 0;
|
|
@@ -760,11 +971,41 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
760
971
|
padding-right: 20px;
|
|
761
972
|
}
|
|
762
973
|
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
974
|
+
/* Empty / loading / searching message — rendered after the table
|
|
975
|
+
(not as a colspan row inside it) and positioned over the body
|
|
976
|
+
area so it stays centered in the visible container rather than
|
|
977
|
+
the full, possibly-overflowing table width. Sits below the
|
|
978
|
+
header (--cl-header-height) and inboard of the vertical
|
|
979
|
+
scrollbar (--cl-scrollbar-w). */
|
|
980
|
+
.list-state {
|
|
981
|
+
position: absolute;
|
|
982
|
+
top: var(--cl-header-height, 36px);
|
|
983
|
+
left: 0;
|
|
984
|
+
right: var(--cl-scrollbar-w, 0px);
|
|
985
|
+
bottom: 0;
|
|
986
|
+
display: flex;
|
|
987
|
+
align-items: flex-start;
|
|
988
|
+
justify-content: center;
|
|
989
|
+
padding-top: 40px;
|
|
767
990
|
color: var(--text-3);
|
|
991
|
+
pointer-events: none;
|
|
992
|
+
}
|
|
993
|
+
/* Query-validation error — the same centered empty-table slot,
|
|
994
|
+
but a danger-tinted pill (icon + message) so a bad search reads
|
|
995
|
+
as something to fix rather than an empty result set. */
|
|
996
|
+
.list-state.error {
|
|
997
|
+
color: var(--danger);
|
|
998
|
+
}
|
|
999
|
+
.list-state .state-error {
|
|
1000
|
+
display: inline-flex;
|
|
1001
|
+
align-items: center;
|
|
1002
|
+
gap: 7px;
|
|
1003
|
+
max-width: min(560px, 80%);
|
|
1004
|
+
padding: 8px 14px;
|
|
1005
|
+
border: 1px solid var(--danger-border);
|
|
1006
|
+
border-radius: var(--r);
|
|
1007
|
+
background: var(--danger-bg);
|
|
1008
|
+
--icon-color: var(--danger);
|
|
768
1009
|
}
|
|
769
1010
|
|
|
770
1011
|
/* Pager — a compact "‹ 1–N of Total ›" stepper that lives in
|
|
@@ -871,6 +1112,26 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
871
1112
|
@property({ type: String, attribute: 'content-menu-endpoint' })
|
|
872
1113
|
contentMenuEndpoint = '';
|
|
873
1114
|
|
|
1115
|
+
/** The content-menu endpoint with the current committed search folded
|
|
1116
|
+
* in, so the server's build_context_menu sees the same query the list
|
|
1117
|
+
* is showing. This is what surfaces search-dependent menu items — e.g.
|
|
1118
|
+
* the contact list's "Create Smart Group" button, which only appears
|
|
1119
|
+
* when the active query is saveable as a group. Binding the
|
|
1120
|
+
* page-header attribute to this (rather than the raw endpoint) makes
|
|
1121
|
+
* the menu re-fetch whenever the committed search changes. */
|
|
1122
|
+
private contentMenuEndpointWithSearch(): string {
|
|
1123
|
+
if (!this.contentMenuEndpoint || !this.search) {
|
|
1124
|
+
return this.contentMenuEndpoint;
|
|
1125
|
+
}
|
|
1126
|
+
try {
|
|
1127
|
+
const url = new URL(this.contentMenuEndpoint, window.location.origin);
|
|
1128
|
+
url.searchParams.set('search', this.search);
|
|
1129
|
+
return url.pathname + url.search;
|
|
1130
|
+
} catch {
|
|
1131
|
+
return this.contentMenuEndpoint;
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
|
|
874
1135
|
/** Column definitions. Subclasses set this in the constructor;
|
|
875
1136
|
* consumers may also override at the element level. */
|
|
876
1137
|
@property({ type: Array, attribute: false })
|
|
@@ -1055,6 +1316,12 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
1055
1316
|
@state()
|
|
1056
1317
|
protected searching = false;
|
|
1057
1318
|
|
|
1319
|
+
/** Query-validation error from the last search (e.g. `age >`). Shown
|
|
1320
|
+
* over the empty table in place of the "nothing to show" copy, and
|
|
1321
|
+
* cleared on the next fetch. */
|
|
1322
|
+
@state()
|
|
1323
|
+
protected searchError = '';
|
|
1324
|
+
|
|
1058
1325
|
@state()
|
|
1059
1326
|
protected selectedIds: Set<string> = new Set();
|
|
1060
1327
|
|
|
@@ -1084,6 +1351,16 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
1084
1351
|
@state()
|
|
1085
1352
|
protected pendingLabel: string | null = null;
|
|
1086
1353
|
|
|
1354
|
+
/** When the bulk-action bar's chips would overflow the available
|
|
1355
|
+
* width, their labels collapse to icon-only (animated). Measured in
|
|
1356
|
+
* {@link updateBulkCollapse}. */
|
|
1357
|
+
@state()
|
|
1358
|
+
private bulkCollapsed = false;
|
|
1359
|
+
|
|
1360
|
+
/** Pending rAF handle for the deferred collapse re-measure (0 when
|
|
1361
|
+
* none is scheduled). See {@link updateBulkCollapse}. */
|
|
1362
|
+
private bulkCollapseFrame = 0;
|
|
1363
|
+
|
|
1087
1364
|
private pending: AbortController = null;
|
|
1088
1365
|
private popstateHandler: () => void;
|
|
1089
1366
|
private resizeHandler: () => void;
|
|
@@ -1096,7 +1373,6 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
1096
1373
|
* `right`. Recomputed each render. */
|
|
1097
1374
|
private rightPinIndexByColumn = new Map<ContentListColumn, number>();
|
|
1098
1375
|
private checkPinIndex = -1;
|
|
1099
|
-
private iconPinIndex = -1;
|
|
1100
1376
|
private lastPinIndex = -1;
|
|
1101
1377
|
/** Right-pin index of the leftmost right-pinned column — the one
|
|
1102
1378
|
* that carries the divider against the scrolling section. */
|
|
@@ -1105,7 +1381,8 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
1105
1381
|
* rendered (the last pinned column), or -1 when nothing is
|
|
1106
1382
|
* pinned. Extra table width pools in that spacer. */
|
|
1107
1383
|
private spacerAfterIndex = -1;
|
|
1108
|
-
/** Whether the current items reserve
|
|
1384
|
+
/** Whether the current items reserve leading-icon space in the first
|
|
1385
|
+
* column (true when a representative row carries an icon). */
|
|
1109
1386
|
private reservesIcon = false;
|
|
1110
1387
|
|
|
1111
1388
|
constructor() {
|
|
@@ -1145,7 +1422,10 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
1145
1422
|
}
|
|
1146
1423
|
// A viewport resize changes whether the table overflows, so the
|
|
1147
1424
|
// right-edge scroll affordance has to be re-evaluated.
|
|
1148
|
-
this.resizeHandler = () =>
|
|
1425
|
+
this.resizeHandler = () => {
|
|
1426
|
+
this.syncScrollAffordance();
|
|
1427
|
+
this.updateBulkCollapse();
|
|
1428
|
+
};
|
|
1149
1429
|
window.addEventListener('resize', this.resizeHandler);
|
|
1150
1430
|
// Pinned columns now size to their content, so a late web-font
|
|
1151
1431
|
// load shifts their widths — re-measure the sticky offsets once
|
|
@@ -1165,6 +1445,10 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
1165
1445
|
if (this.resizeHandler) {
|
|
1166
1446
|
window.removeEventListener('resize', this.resizeHandler);
|
|
1167
1447
|
}
|
|
1448
|
+
if (this.bulkCollapseFrame) {
|
|
1449
|
+
cancelAnimationFrame(this.bulkCollapseFrame);
|
|
1450
|
+
this.bulkCollapseFrame = 0;
|
|
1451
|
+
}
|
|
1168
1452
|
if (this.pending) {
|
|
1169
1453
|
// Null the pending pointer before aborting so fetchPage's
|
|
1170
1454
|
// finally block — which gates cleanup on `this.pending ===
|
|
@@ -1199,6 +1483,47 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
1199
1483
|
// on the freshly-laid-out DOM, so settle them after each render.
|
|
1200
1484
|
this.measurePinOffsets();
|
|
1201
1485
|
this.syncScrollAffordance();
|
|
1486
|
+
this.updateBulkCollapse();
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
/** Collapse the bulk-action labels to icon-only when the chips would
|
|
1490
|
+
* overflow the bar. Measures synchronously for immediate feedback,
|
|
1491
|
+
* then re-measures on the next frame: the chip `<temba-icon>`s are
|
|
1492
|
+
* child custom elements that render their SVG in a *later* update
|
|
1493
|
+
* cycle, so the moment the bar first appears they still have zero
|
|
1494
|
+
* width and the chips read as narrow — the bar looks like it fits and
|
|
1495
|
+
* doesn't collapse until a later nudge (a second selection or a
|
|
1496
|
+
* resize) re-runs the measurement. The deferred pass settles it on
|
|
1497
|
+
* first show, once the icons have laid out. */
|
|
1498
|
+
private updateBulkCollapse(): void {
|
|
1499
|
+
this.measureBulkCollapse();
|
|
1500
|
+
if (this.bulkCollapseFrame) cancelAnimationFrame(this.bulkCollapseFrame);
|
|
1501
|
+
this.bulkCollapseFrame = requestAnimationFrame(() => {
|
|
1502
|
+
this.bulkCollapseFrame = 0;
|
|
1503
|
+
this.measureBulkCollapse();
|
|
1504
|
+
});
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
/** One overflow measurement. The decision is made against the
|
|
1508
|
+
* fully-expanded width with transitions suppressed (the `measuring`
|
|
1509
|
+
* class) — reading scrollWidth mid-animation otherwise returns a width
|
|
1510
|
+
* between the collapsed and expanded states, which made the collapse
|
|
1511
|
+
* flip-flop and never settle. The expanded layout is forced and the
|
|
1512
|
+
* current state restored synchronously (no paint in between) so
|
|
1513
|
+
* there's no flash, then transitions are re-enabled so a real state
|
|
1514
|
+
* change animates. */
|
|
1515
|
+
private measureBulkCollapse(): void {
|
|
1516
|
+
const bar = this.shadowRoot?.querySelector(
|
|
1517
|
+
'.bulk-bar'
|
|
1518
|
+
) as HTMLElement | null;
|
|
1519
|
+
if (!bar) return;
|
|
1520
|
+
bar.classList.add('measuring');
|
|
1521
|
+
bar.classList.remove('collapsed');
|
|
1522
|
+
const overflows = bar.scrollWidth > bar.clientWidth + 1;
|
|
1523
|
+
bar.classList.toggle('collapsed', this.bulkCollapsed);
|
|
1524
|
+
void bar.offsetWidth;
|
|
1525
|
+
bar.classList.remove('measuring');
|
|
1526
|
+
if (overflows !== this.bulkCollapsed) this.bulkCollapsed = overflows;
|
|
1202
1527
|
}
|
|
1203
1528
|
|
|
1204
1529
|
/** Read sort/page/search from the URL on first load / popstate. */
|
|
@@ -1378,11 +1703,22 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
1378
1703
|
const controller = new AbortController();
|
|
1379
1704
|
this.pending = controller;
|
|
1380
1705
|
this.loading = true;
|
|
1706
|
+
// Drop any prior query error so it doesn't linger behind the new
|
|
1707
|
+
// request's loading/searching state.
|
|
1708
|
+
this.searchError = '';
|
|
1709
|
+
// Whether this fetch is a search commit (vs. paging/refresh) —
|
|
1710
|
+
// captured up front since `searching` is cleared in `finally`. Only
|
|
1711
|
+
// a search adopts a server-adjusted query and refocuses the input.
|
|
1712
|
+
const wasSearch = this.searching;
|
|
1381
1713
|
const requestUrl = url || this.buildRequestUrl();
|
|
1382
1714
|
this.currentUrl = requestUrl;
|
|
1383
1715
|
try {
|
|
1384
1716
|
const response = await getUrl(requestUrl, controller);
|
|
1385
1717
|
const data = (response.json || {}) as FetchResponse<T>;
|
|
1718
|
+
// A query-validation error comes back list-shaped (status 200,
|
|
1719
|
+
// empty results) with an `error` message — surface it over the
|
|
1720
|
+
// empty table rather than the plain empty-state copy.
|
|
1721
|
+
this.searchError = typeof data.error === 'string' ? data.error : '';
|
|
1386
1722
|
this.items = data.results || [];
|
|
1387
1723
|
this.nextCursor = data.next ? this.toRequestUrl(data.next) : '';
|
|
1388
1724
|
this.prevCursor = data.previous ? this.toRequestUrl(data.previous) : '';
|
|
@@ -1411,6 +1747,17 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
1411
1747
|
if (visible.has(id)) next.add(id);
|
|
1412
1748
|
});
|
|
1413
1749
|
this.selectedIds = next;
|
|
1750
|
+
// If the server echoed an adjusted/normalized query for this
|
|
1751
|
+
// search, adopt it as the basis of the results and mirror it back
|
|
1752
|
+
// into the input — so the box shows exactly what the results
|
|
1753
|
+
// reflect (and the Search button stays hidden until the user
|
|
1754
|
+
// edits away from it again). Keep the URL in step via a replacing
|
|
1755
|
+
// write so it carries the normalized form without a new entry.
|
|
1756
|
+
if (wasSearch && typeof data.query === 'string') {
|
|
1757
|
+
this.search = data.query;
|
|
1758
|
+
this.searchDraft = data.query;
|
|
1759
|
+
this.writeUrlState(true);
|
|
1760
|
+
}
|
|
1414
1761
|
} catch (err) {
|
|
1415
1762
|
// aborted or failed; leave items as-is and let the caller see
|
|
1416
1763
|
// the empty/error state via console — no toast to keep the
|
|
@@ -1424,11 +1771,30 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
1424
1771
|
this.pending = null;
|
|
1425
1772
|
this.loading = false;
|
|
1426
1773
|
this.searching = false;
|
|
1774
|
+
// The input was disabled while the search ran; once it
|
|
1775
|
+
// re-enables, restore focus and drop the cursor at the end of
|
|
1776
|
+
// the (possibly adjusted) query so the user can keep editing
|
|
1777
|
+
// without re-clicking into the box.
|
|
1778
|
+
if (wasSearch && this.searchOpen) {
|
|
1779
|
+
this.updateComplete.then(() => this.focusSearchEnd());
|
|
1780
|
+
}
|
|
1427
1781
|
this.fireCustomEvent(CustomEventType.FetchComplete);
|
|
1428
1782
|
}
|
|
1429
1783
|
}
|
|
1430
1784
|
}
|
|
1431
1785
|
|
|
1786
|
+
/** Focus the search input and place the caret at the end of its
|
|
1787
|
+
* value — used after a search settles and the box re-enables. */
|
|
1788
|
+
private focusSearchEnd(): void {
|
|
1789
|
+
const input = this.shadowRoot?.querySelector(
|
|
1790
|
+
'.searchbar input'
|
|
1791
|
+
) as HTMLInputElement | null;
|
|
1792
|
+
if (!input) return;
|
|
1793
|
+
input.focus();
|
|
1794
|
+
const end = input.value.length;
|
|
1795
|
+
input.setSelectionRange(end, end);
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1432
1798
|
/** Public API — programmatic refresh, mirrors `refreshKey` bump.
|
|
1433
1799
|
* Re-requests the current page (cursor lists included) rather than
|
|
1434
1800
|
* resetting to the first. */
|
|
@@ -1467,19 +1833,16 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
1467
1833
|
this.searchDraft = event.target.value || '';
|
|
1468
1834
|
}
|
|
1469
1835
|
|
|
1470
|
-
/** Commit on Enter; let other keys through. Escape
|
|
1471
|
-
*
|
|
1836
|
+
/** Commit on Enter; let other keys through. Escape closes the bar
|
|
1837
|
+
* outright (clearing any active search, mirroring toggleSearch's
|
|
1838
|
+
* close path) — the keyboard counterpart to the close (✕) button. */
|
|
1472
1839
|
private handleSearchKey(event: KeyboardEvent): void {
|
|
1473
1840
|
if (event.key === 'Enter') {
|
|
1474
1841
|
event.preventDefault();
|
|
1475
1842
|
this.commitSearch();
|
|
1476
1843
|
} else if (event.key === 'Escape') {
|
|
1477
1844
|
event.preventDefault();
|
|
1478
|
-
|
|
1479
|
-
// Discard the in-progress draft, leaving the committed
|
|
1480
|
-
// search alone — a quick way out without altering results.
|
|
1481
|
-
this.searchDraft = this.search;
|
|
1482
|
-
}
|
|
1845
|
+
this.toggleSearch();
|
|
1483
1846
|
}
|
|
1484
1847
|
}
|
|
1485
1848
|
|
|
@@ -1524,7 +1887,16 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
1524
1887
|
this.fireCustomEvent(CustomEventType.RowClick, { item });
|
|
1525
1888
|
const href = this.getRowHref(item);
|
|
1526
1889
|
if (href && this.isSafeHref(href)) {
|
|
1527
|
-
|
|
1890
|
+
// Meta/ctrl-click opens a new tab, matching ordinary links.
|
|
1891
|
+
if (event.metaKey || event.ctrlKey) {
|
|
1892
|
+
window.open(href, '_blank');
|
|
1893
|
+
return;
|
|
1894
|
+
}
|
|
1895
|
+
// Fire Redirected rather than assigning window.location so the
|
|
1896
|
+
// host SPA frame swaps content in place instead of doing a full
|
|
1897
|
+
// page reload — the frame listens for this event on document and
|
|
1898
|
+
// routes it through its in-app loader.
|
|
1899
|
+
this.fireCustomEvent(CustomEventType.Redirected, { url: href });
|
|
1528
1900
|
}
|
|
1529
1901
|
}
|
|
1530
1902
|
|
|
@@ -1583,7 +1955,7 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
1583
1955
|
|
|
1584
1956
|
const ids = Array.from(this.selectedIds);
|
|
1585
1957
|
|
|
1586
|
-
if (this.actionEndpoint) {
|
|
1958
|
+
if (this.actionEndpoint && !action.clientOnly) {
|
|
1587
1959
|
const params = new URLSearchParams();
|
|
1588
1960
|
params.append('action', action.key);
|
|
1589
1961
|
ids.forEach((id) => params.append('objects', id));
|
|
@@ -1644,82 +2016,98 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
1644
2016
|
}
|
|
1645
2017
|
|
|
1646
2018
|
private renderTitlebar(): TemplateResult {
|
|
1647
|
-
const selectionCount = this.selectedIds.size;
|
|
1648
|
-
const bulkVisible = selectionCount > 0 && this.bulkActions.length > 0;
|
|
1649
2019
|
const hasSubtitle =
|
|
1650
2020
|
this.subtitle || this.querySelector('[slot="subtitle"]');
|
|
1651
|
-
const resultCount = `${this.total} ${this.total === 1 ? 'result' : 'results'}`;
|
|
1652
2021
|
// The header — title + content menu — is temba-page-header. The
|
|
1653
|
-
// list forwards its own title/subtitle slots into it and slots
|
|
1654
|
-
//
|
|
1655
|
-
//
|
|
2022
|
+
// list forwards its own title/subtitle slots into it and slots its
|
|
2023
|
+
// pager / search into the header's actions area. The bulk actions
|
|
2024
|
+
// are NOT here — when rows are selected they overlay the column-
|
|
2025
|
+
// header row instead (see renderBulkBar), so the page header stays
|
|
2026
|
+
// put rather than swapping its contents.
|
|
1656
2027
|
return html`
|
|
1657
2028
|
<temba-page-header
|
|
1658
|
-
content-menu-endpoint=${this.
|
|
1659
|
-
?hide-menu=${bulkVisible}
|
|
2029
|
+
content-menu-endpoint=${this.contentMenuEndpointWithSearch()}
|
|
1660
2030
|
>
|
|
1661
2031
|
<slot name="title" slot="title">${this.listTitle}</slot>
|
|
1662
2032
|
${hasSubtitle
|
|
1663
2033
|
? html`<slot name="subtitle" slot="subtitle">${this.subtitle}</slot>`
|
|
1664
2034
|
: null}
|
|
1665
2035
|
<div slot="actions" class="header-actions">
|
|
1666
|
-
${
|
|
2036
|
+
${this.renderPager()}
|
|
2037
|
+
${this.searchable && !this.searchOpen
|
|
1667
2038
|
? html`
|
|
1668
|
-
<span class="
|
|
1669
|
-
|
|
2039
|
+
<span class="action" @click=${() => this.toggleSearch()}>
|
|
2040
|
+
<temba-icon name=${Icon.search} size="0.95"></temba-icon>
|
|
2041
|
+
Search
|
|
2042
|
+
</span>
|
|
1670
2043
|
`
|
|
1671
|
-
:
|
|
1672
|
-
|
|
1673
|
-
${this.searchable && !this.searchOpen
|
|
1674
|
-
? html`
|
|
1675
|
-
<span class="action" @click=${() => this.toggleSearch()}>
|
|
1676
|
-
<temba-icon
|
|
1677
|
-
name=${Icon.search}
|
|
1678
|
-
size="0.95"
|
|
1679
|
-
></temba-icon>
|
|
1680
|
-
Search
|
|
1681
|
-
</span>
|
|
1682
|
-
`
|
|
1683
|
-
: null}
|
|
1684
|
-
<slot name="actions"></slot>
|
|
1685
|
-
`}
|
|
2044
|
+
: null}
|
|
2045
|
+
<slot name="actions"></slot>
|
|
1686
2046
|
</div>
|
|
1687
2047
|
</temba-page-header>
|
|
1688
2048
|
${this.searchable && this.searchOpen
|
|
1689
2049
|
? html`
|
|
1690
2050
|
<div class="searchbar">
|
|
1691
|
-
<span
|
|
1692
|
-
class="submit"
|
|
1693
|
-
title="Search"
|
|
1694
|
-
aria-label="Search"
|
|
1695
|
-
@click=${() => this.commitSearch()}
|
|
1696
|
-
>
|
|
1697
|
-
<temba-icon name=${Icon.search} size="0.95"></temba-icon>
|
|
1698
|
-
</span>
|
|
1699
2051
|
<input
|
|
1700
2052
|
type="text"
|
|
1701
2053
|
placeholder=${this.searchPlaceholder}
|
|
1702
2054
|
.value=${this.searchDraft}
|
|
2055
|
+
?disabled=${this.searching}
|
|
1703
2056
|
@input=${this.handleSearchInput}
|
|
1704
2057
|
@keydown=${this.handleSearchKey}
|
|
1705
2058
|
/>
|
|
1706
|
-
${this.
|
|
1707
|
-
? html
|
|
2059
|
+
${!this.searching && this.searchDraft !== this.search
|
|
2060
|
+
? html`
|
|
2061
|
+
<span class="search-hint">
|
|
2062
|
+
<span class="enter-key">↵</span> to search
|
|
2063
|
+
</span>
|
|
2064
|
+
<temba-icon
|
|
2065
|
+
class="search-go"
|
|
2066
|
+
name=${Icon.search}
|
|
2067
|
+
size="1.1"
|
|
2068
|
+
clickable
|
|
2069
|
+
title="Search"
|
|
2070
|
+
aria-label="Run search"
|
|
2071
|
+
@click=${() => this.commitSearch()}
|
|
2072
|
+
></temba-icon>
|
|
2073
|
+
`
|
|
1708
2074
|
: null}
|
|
1709
|
-
<
|
|
1710
|
-
class="
|
|
1711
|
-
|
|
1712
|
-
|
|
2075
|
+
<temba-icon
|
|
2076
|
+
class="search-cancel"
|
|
2077
|
+
name=${Icon.close}
|
|
2078
|
+
size="1.1"
|
|
2079
|
+
clickable
|
|
2080
|
+
title="Cancel search"
|
|
2081
|
+
aria-label="Cancel search"
|
|
1713
2082
|
@click=${() => this.toggleSearch()}
|
|
1714
|
-
>
|
|
1715
|
-
<temba-icon name=${Icon.close} size="0.85"></temba-icon>
|
|
1716
|
-
</span>
|
|
2083
|
+
></temba-icon>
|
|
1717
2084
|
</div>
|
|
1718
2085
|
`
|
|
1719
2086
|
: null}
|
|
1720
2087
|
`;
|
|
1721
2088
|
}
|
|
1722
2089
|
|
|
2090
|
+
/** True when there's a selection and actions to run on it. */
|
|
2091
|
+
private get bulkVisible(): boolean {
|
|
2092
|
+
return this.selectedIds.size > 0 && this.bulkActions.length > 0;
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
/** The bulk-action bar — overlaid on the column-header row (just
|
|
2096
|
+
* right of the select-all checkbox) when rows are selected, rather
|
|
2097
|
+
* than replacing the page header. Positioned absolutely in the
|
|
2098
|
+
* table-frame so it doesn't scroll horizontally with the columns. */
|
|
2099
|
+
private renderBulkBar(): TemplateResult {
|
|
2100
|
+
// Actions lead (fixed against the checkbox) and the count trails,
|
|
2101
|
+
// right-aligned, so the buttons don't shift as the count's width
|
|
2102
|
+
// changes ("1 selected" vs "100 selected").
|
|
2103
|
+
return html`
|
|
2104
|
+
<div class="bulk-bar ${this.bulkCollapsed ? 'collapsed' : ''}">
|
|
2105
|
+
${this.bulkActions.map((a) => this.renderBulkAction(a))}
|
|
2106
|
+
<span class="bulk-count">${this.selectedIds.size} selected</span>
|
|
2107
|
+
</div>
|
|
2108
|
+
`;
|
|
2109
|
+
}
|
|
2110
|
+
|
|
1723
2111
|
private renderBulkAction(action: ContentListBulkAction): TemplateResult {
|
|
1724
2112
|
if (action.labelsEndpoint) {
|
|
1725
2113
|
return this.renderLabelDropdown(action);
|
|
@@ -1727,12 +2115,13 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
1727
2115
|
return html`
|
|
1728
2116
|
<span
|
|
1729
2117
|
class="bulk-action ${action.destructive ? 'destructive' : ''}"
|
|
2118
|
+
title=${action.label}
|
|
1730
2119
|
@click=${() => this.handleBulkAction(action)}
|
|
1731
2120
|
>
|
|
1732
2121
|
${action.icon
|
|
1733
2122
|
? html`<temba-icon name=${action.icon} size="0.9"></temba-icon>`
|
|
1734
2123
|
: null}
|
|
1735
|
-
|
|
2124
|
+
<span class="bulk-label">${action.label}</span>
|
|
1736
2125
|
</span>
|
|
1737
2126
|
`;
|
|
1738
2127
|
}
|
|
@@ -1742,17 +2131,17 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
1742
2131
|
return html`
|
|
1743
2132
|
<temba-dropdown
|
|
1744
2133
|
class="label-dropdown"
|
|
1745
|
-
data-action-key=${action.key}
|
|
1746
2134
|
@temba-opened=${() => this.handleLabelDropdownOpened(action)}
|
|
1747
2135
|
>
|
|
1748
2136
|
<span
|
|
1749
2137
|
slot="toggle"
|
|
1750
2138
|
class="bulk-action ${action.destructive ? 'destructive' : ''}"
|
|
2139
|
+
title=${action.label}
|
|
1751
2140
|
>
|
|
1752
2141
|
${action.icon
|
|
1753
2142
|
? html`<temba-icon name=${action.icon} size="0.9"></temba-icon>`
|
|
1754
2143
|
: null}
|
|
1755
|
-
|
|
2144
|
+
<span class="bulk-label">${action.label}</span>
|
|
1756
2145
|
</span>
|
|
1757
2146
|
<div slot="dropdown" class="label-menu">
|
|
1758
2147
|
${labels.length === 0
|
|
@@ -1767,7 +2156,7 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
1767
2156
|
label: any,
|
|
1768
2157
|
action: ContentListBulkAction
|
|
1769
2158
|
): TemplateResult {
|
|
1770
|
-
const state = this.computeLabelState(label.uuid);
|
|
2159
|
+
const state = this.computeLabelState(label.uuid, action.labelsKey);
|
|
1771
2160
|
const isPending = this.pendingLabel === label.uuid;
|
|
1772
2161
|
const isBlocked = this.pendingLabel !== null && !isPending;
|
|
1773
2162
|
return html`
|
|
@@ -1778,18 +2167,17 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
1778
2167
|
@click=${(e: MouseEvent) => {
|
|
1779
2168
|
e.stopPropagation();
|
|
1780
2169
|
if (this.pendingLabel !== null) return;
|
|
1781
|
-
this.toggleLabel(label, state
|
|
2170
|
+
this.toggleLabel(label, state);
|
|
1782
2171
|
}}
|
|
1783
2172
|
>
|
|
1784
2173
|
<temba-checkbox
|
|
1785
2174
|
size="1.1"
|
|
1786
2175
|
?checked=${state === 'all'}
|
|
1787
2176
|
?partial=${state === 'some'}
|
|
2177
|
+
?busy=${isPending}
|
|
2178
|
+
?disabled=${isBlocked}
|
|
1788
2179
|
></temba-checkbox>
|
|
1789
2180
|
<span class="lbl-name">${label.name}</span>
|
|
1790
|
-
${isPending
|
|
1791
|
-
? html`<temba-loading units="3" size="6"></temba-loading>`
|
|
1792
|
-
: null}
|
|
1793
2181
|
</div>
|
|
1794
2182
|
`;
|
|
1795
2183
|
}
|
|
@@ -1800,7 +2188,11 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
1800
2188
|
if (this.labelsByActionKey[action.key] || !action.labelsEndpoint) return;
|
|
1801
2189
|
try {
|
|
1802
2190
|
const response = await getUrl(action.labelsEndpoint);
|
|
1803
|
-
const labels = response.json?.results || []
|
|
2191
|
+
const labels = (response.json?.results || [])
|
|
2192
|
+
.slice()
|
|
2193
|
+
.sort((a: any, b: any) =>
|
|
2194
|
+
String(a.name || '').localeCompare(String(b.name || ''))
|
|
2195
|
+
);
|
|
1804
2196
|
this.labelsByActionKey = {
|
|
1805
2197
|
...this.labelsByActionKey,
|
|
1806
2198
|
[action.key]: labels
|
|
@@ -1812,15 +2204,20 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
1812
2204
|
}
|
|
1813
2205
|
|
|
1814
2206
|
/** Compute the tri-state across the selected rows for a given
|
|
1815
|
-
* label uuid: 'all' if every selected row has it, 'some' if
|
|
1816
|
-
* least one but not all do, 'none' otherwise.
|
|
1817
|
-
|
|
2207
|
+
* label/group uuid: 'all' if every selected row has it, 'some' if
|
|
2208
|
+
* at least one but not all do, 'none' otherwise. Membership is read
|
|
2209
|
+
* from the `labelsKey` item field (default 'labels'; contacts use
|
|
2210
|
+
* 'groups'). */
|
|
2211
|
+
private computeLabelState(
|
|
2212
|
+
labelUuid: string,
|
|
2213
|
+
labelsKey = 'labels'
|
|
2214
|
+
): 'none' | 'some' | 'all' {
|
|
1818
2215
|
const selected = this.items.filter((item) =>
|
|
1819
2216
|
this.selectedIds.has(this.rowId(item))
|
|
1820
2217
|
);
|
|
1821
2218
|
if (selected.length === 0) return 'none';
|
|
1822
2219
|
const withLabel = selected.filter((item) =>
|
|
1823
|
-
((item as any)
|
|
2220
|
+
((item as any)[labelsKey] || []).some((l: any) => l.uuid === labelUuid)
|
|
1824
2221
|
);
|
|
1825
2222
|
if (withLabel.length === 0) return 'none';
|
|
1826
2223
|
if (withLabel.length === selected.length) return 'all';
|
|
@@ -1838,27 +2235,15 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
1838
2235
|
* filtered result decide which rows stay. We POST first, then
|
|
1839
2236
|
* refresh once the server confirms. The `pendingLabel` state
|
|
1840
2237
|
* blocks further toggles until the round-trip completes. */
|
|
1841
|
-
private async toggleLabel(
|
|
1842
|
-
label: any,
|
|
1843
|
-
state: string,
|
|
1844
|
-
actionKey: string
|
|
1845
|
-
): Promise<void> {
|
|
2238
|
+
private async toggleLabel(label: any, state: string): Promise<void> {
|
|
1846
2239
|
if (this.pendingLabel !== null) return;
|
|
1847
2240
|
const add = state !== 'all';
|
|
1848
2241
|
const originalSelectedIds = Array.from(this.selectedIds);
|
|
1849
2242
|
this.pendingLabel = label.uuid;
|
|
1850
2243
|
try {
|
|
1851
|
-
//
|
|
1852
|
-
//
|
|
1853
|
-
//
|
|
1854
|
-
// `actionKey` is a consumer-supplied public-API field, so
|
|
1855
|
-
// CSS.escape() keeps a key containing `"` or `\` from throwing
|
|
1856
|
-
// SyntaxError (and leaving the dropdown stuck open).
|
|
1857
|
-
const dropdown = this.shadowRoot?.querySelector(
|
|
1858
|
-
`.label-dropdown[data-action-key="${CSS.escape(actionKey)}"]`
|
|
1859
|
-
) as Dropdown | null;
|
|
1860
|
-
if (dropdown) dropdown.open = false;
|
|
1861
|
-
|
|
2244
|
+
// The dropdown is left open so several labels/groups can be
|
|
2245
|
+
// toggled in one pass — each checkbox updates in place as the
|
|
2246
|
+
// list re-fetches.
|
|
1862
2247
|
if (this.actionEndpoint) {
|
|
1863
2248
|
// application/x-www-form-urlencoded matches what Django's
|
|
1864
2249
|
// smartmin `BulkActionMixin` reads from `request.POST`, and
|
|
@@ -2003,7 +2388,6 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
2003
2388
|
this.pinIndexByColumn = new Map();
|
|
2004
2389
|
this.rightPinIndexByColumn = new Map();
|
|
2005
2390
|
this.checkPinIndex = -1;
|
|
2006
|
-
this.iconPinIndex = -1;
|
|
2007
2391
|
this.lastPinIndex = -1;
|
|
2008
2392
|
this.firstRightPinIndex = -1;
|
|
2009
2393
|
this.spacerAfterIndex = -1;
|
|
@@ -2017,14 +2401,15 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
2017
2401
|
}
|
|
2018
2402
|
this.firstRightPinIndex = ridx - 1;
|
|
2019
2403
|
|
|
2020
|
-
// Left-pinned columns + the leading checkbox
|
|
2404
|
+
// Left-pinned columns + the leading checkbox cell. The leading icon
|
|
2405
|
+
// rides inside the first column's cell, so it adds no pin slot of
|
|
2406
|
+
// its own.
|
|
2021
2407
|
const leftPinnedCount = this.columns.filter((c) =>
|
|
2022
2408
|
this.isLeftPinned(c)
|
|
2023
2409
|
).length;
|
|
2024
2410
|
if (leftPinnedCount === 0) return;
|
|
2025
2411
|
let idx = 0;
|
|
2026
2412
|
if (this.hasCheckboxes) this.checkPinIndex = idx++;
|
|
2027
|
-
if (this.reservesIcon) this.iconPinIndex = idx++;
|
|
2028
2413
|
this.columns.forEach((c) => {
|
|
2029
2414
|
if (this.isLeftPinned(c)) this.pinIndexByColumn.set(c, idx++);
|
|
2030
2415
|
});
|
|
@@ -2097,17 +2482,6 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
2097
2482
|
return parts.join(' ');
|
|
2098
2483
|
}
|
|
2099
2484
|
|
|
2100
|
-
/** Column count for the empty/loading row's colspan — includes
|
|
2101
|
-
* the leading cells and the slack spacer when present. */
|
|
2102
|
-
private colSpan(): number {
|
|
2103
|
-
return (
|
|
2104
|
-
(this.hasCheckboxes ? 1 : 0) +
|
|
2105
|
-
(this.reservesIcon ? 1 : 0) +
|
|
2106
|
-
(this.spacerAfterIndex >= 0 ? 1 : 0) +
|
|
2107
|
-
this.columns.length
|
|
2108
|
-
);
|
|
2109
|
-
}
|
|
2110
|
-
|
|
2111
2485
|
/** Measure the header's pinned cells and publish a cumulative
|
|
2112
2486
|
* `left` offset per pin index as a CSS var on the host. Pinned
|
|
2113
2487
|
* cells (header + body) read these via {@link pinStyle}. Pinned
|
|
@@ -2165,13 +2539,56 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
2165
2539
|
'can-scroll-right',
|
|
2166
2540
|
scroller.scrollLeft < maxScroll - 1
|
|
2167
2541
|
);
|
|
2168
|
-
//
|
|
2169
|
-
//
|
|
2170
|
-
|
|
2542
|
+
// Vertical scroll lifts the sticky header above the rows passing
|
|
2543
|
+
// under it with the same soft drop shadow the pinned columns use.
|
|
2544
|
+
frame.classList.toggle('scrolled-down', scroller.scrollTop > 1);
|
|
2545
|
+
// The vertical scrollbar's width (0 for overlay scrollbars) pulls
|
|
2546
|
+
// the right-edge scroll gradient inboard so it never paints over
|
|
2547
|
+
// the scrollbar track.
|
|
2171
2548
|
this.style.setProperty(
|
|
2172
|
-
'--cl-scrollbar',
|
|
2173
|
-
`${scroller.
|
|
2549
|
+
'--cl-scrollbar-w',
|
|
2550
|
+
`${scroller.offsetWidth - scroller.clientWidth}px`
|
|
2174
2551
|
);
|
|
2552
|
+
// Height of the visible rows — the lesser of the table's own height
|
|
2553
|
+
// and the visible area (clientHeight already excludes the horizontal
|
|
2554
|
+
// scrollbar). The right-edge scroll gradient is sized to this so it
|
|
2555
|
+
// stops at the bottom of the rows rather than running down the empty
|
|
2556
|
+
// space below a short table.
|
|
2557
|
+
const table = scroller.querySelector('table') as HTMLElement | null;
|
|
2558
|
+
const rowsHeight = table
|
|
2559
|
+
? Math.min(scroller.clientHeight, table.offsetHeight)
|
|
2560
|
+
: scroller.clientHeight;
|
|
2561
|
+
this.style.setProperty('--cl-rows-height', `${rowsHeight}px`);
|
|
2562
|
+
// Header height positions the header's scroll shadow just below it.
|
|
2563
|
+
const headerRow = scroller.querySelector('tr.header') as HTMLElement | null;
|
|
2564
|
+
if (headerRow) {
|
|
2565
|
+
this.style.setProperty(
|
|
2566
|
+
'--cl-header-height',
|
|
2567
|
+
`${headerRow.offsetHeight}px`
|
|
2568
|
+
);
|
|
2569
|
+
}
|
|
2570
|
+
// Left edge of the row's leading content — the bulk-action bar's
|
|
2571
|
+
// first chip aligns here. That's the row icon when there is one
|
|
2572
|
+
// (e.g. the contact silhouette) and otherwise the first column's
|
|
2573
|
+
// text (e.g. the message contact), so the actions line up with the
|
|
2574
|
+
// row content rather than the checkbox cell.
|
|
2575
|
+
const frameRect = frame.getBoundingClientRect();
|
|
2576
|
+
const lead =
|
|
2577
|
+
(scroller.querySelector(
|
|
2578
|
+
'tr.row td.lead-cell .lead-icon'
|
|
2579
|
+
) as HTMLElement | null) ||
|
|
2580
|
+
(scroller.querySelector(
|
|
2581
|
+
'tr.row td.cell .cell-inner'
|
|
2582
|
+
) as HTMLElement | null) ||
|
|
2583
|
+
(scroller.querySelector(
|
|
2584
|
+
'tr.header th.head-cell .head-inner'
|
|
2585
|
+
) as HTMLElement | null);
|
|
2586
|
+
if (lead) {
|
|
2587
|
+
this.style.setProperty(
|
|
2588
|
+
'--cl-firstcol-left',
|
|
2589
|
+
`${lead.getBoundingClientRect().left - frameRect.left}px`
|
|
2590
|
+
);
|
|
2591
|
+
}
|
|
2175
2592
|
}
|
|
2176
2593
|
|
|
2177
2594
|
private renderHeader(): TemplateResult {
|
|
@@ -2200,12 +2617,6 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
2200
2617
|
</th>
|
|
2201
2618
|
`
|
|
2202
2619
|
: null}
|
|
2203
|
-
${this.reservesIcon
|
|
2204
|
-
? html`<th
|
|
2205
|
-
class="icon-cell ${this.pinClass(this.iconPinIndex)}"
|
|
2206
|
-
style=${this.pinStyle(this.iconPinIndex)}
|
|
2207
|
-
></th>`
|
|
2208
|
-
: null}
|
|
2209
2620
|
${this.columns.map((c, i) =>
|
|
2210
2621
|
i === this.spacerAfterIndex
|
|
2211
2622
|
? html`${this.renderHeaderCell(c)}
|
|
@@ -2267,7 +2678,6 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
2267
2678
|
const id = this.rowId(item);
|
|
2268
2679
|
const selected = this.selectedIds.has(id);
|
|
2269
2680
|
const href = this.getRowHref(item);
|
|
2270
|
-
const icon = this.getRowIcon(item);
|
|
2271
2681
|
return html`
|
|
2272
2682
|
<tr
|
|
2273
2683
|
class="row ${selected ? 'selected' : ''} ${href ? 'clickable' : ''}"
|
|
@@ -2296,57 +2706,73 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
2296
2706
|
</td>
|
|
2297
2707
|
`
|
|
2298
2708
|
: null}
|
|
2299
|
-
${this.reservesIcon
|
|
2300
|
-
? html`
|
|
2301
|
-
<td
|
|
2302
|
-
class="icon-cell ${this.pinClass(this.iconPinIndex)}"
|
|
2303
|
-
style=${this.pinStyle(this.iconPinIndex)}
|
|
2304
|
-
>
|
|
2305
|
-
${icon
|
|
2306
|
-
? html`<div class="icon-inner">
|
|
2307
|
-
<temba-icon name=${icon} size="1"></temba-icon>
|
|
2308
|
-
</div>`
|
|
2309
|
-
: null}
|
|
2310
|
-
</td>
|
|
2311
|
-
`
|
|
2312
|
-
: null}
|
|
2313
2709
|
${this.columns.map((c, i) =>
|
|
2314
2710
|
i === this.spacerAfterIndex
|
|
2315
|
-
? html`${this.renderBodyCell(item, c)}
|
|
2711
|
+
? html`${this.renderBodyCell(item, c, i === 0)}
|
|
2316
2712
|
<td class="spacer"></td>`
|
|
2317
|
-
: this.renderBodyCell(item, c)
|
|
2713
|
+
: this.renderBodyCell(item, c, i === 0)
|
|
2318
2714
|
)}
|
|
2319
2715
|
</tr>
|
|
2320
2716
|
`;
|
|
2321
2717
|
}
|
|
2322
2718
|
|
|
2323
|
-
private renderBodyCell(
|
|
2719
|
+
private renderBodyCell(
|
|
2720
|
+
item: T,
|
|
2721
|
+
column: ContentListColumn,
|
|
2722
|
+
isLead = false
|
|
2723
|
+
): TemplateResult {
|
|
2724
|
+
const inner = html`
|
|
2725
|
+
<div class="cell-inner" style=${this.cellWidthStyle(column)}>
|
|
2726
|
+
${this.renderCell(item, column)}
|
|
2727
|
+
</div>
|
|
2728
|
+
`;
|
|
2729
|
+
// The first column carries the row's leading icon (when the list
|
|
2730
|
+
// reserves one), so its header aligns with the icon rather than the
|
|
2731
|
+
// value. A row with no icon still reserves the space to keep values
|
|
2732
|
+
// aligned down the column.
|
|
2733
|
+
const lead = isLead && this.reservesIcon;
|
|
2734
|
+
const icon = lead ? this.getRowIcon(item) : null;
|
|
2324
2735
|
return html`
|
|
2325
2736
|
<td
|
|
2326
|
-
class="cell ${
|
|
2327
|
-
|
|
2328
|
-
: ''} ${this.columnPinClass(column)}"
|
|
2737
|
+
class="cell ${lead ? 'lead-cell' : ''} ${column.align ||
|
|
2738
|
+
''} ${column.grow ? 'grow' : ''} ${this.columnPinClass(column)}"
|
|
2329
2739
|
style=${this.columnPinStyle(column)}
|
|
2330
2740
|
>
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2741
|
+
${lead
|
|
2742
|
+
? html`<div class="lead-wrap">
|
|
2743
|
+
<span class="lead-icon"
|
|
2744
|
+
>${icon
|
|
2745
|
+
? html`<temba-icon name=${icon} size="1"></temba-icon>`
|
|
2746
|
+
: null}</span
|
|
2747
|
+
>${inner}
|
|
2748
|
+
</div>`
|
|
2749
|
+
: inner}
|
|
2334
2750
|
</td>
|
|
2335
2751
|
`;
|
|
2336
2752
|
}
|
|
2337
2753
|
|
|
2338
2754
|
/** The pager — a compact "‹ 1–N of Total ›" stepper for the
|
|
2339
|
-
* header's actions cluster.
|
|
2340
|
-
*
|
|
2341
|
-
*
|
|
2342
|
-
*
|
|
2755
|
+
* header's actions cluster. The "N–M of Total" status shows whenever
|
|
2756
|
+
* the response carried a count (`hasCount`) — in cursor mode too,
|
|
2757
|
+
* using the synthetic page for the range; an uncounted cursor list
|
|
2758
|
+
* falls back to chevrons only, gated on whether the last response
|
|
2759
|
+
* handed back a cursor for that direction. Returns nothing when there
|
|
2760
|
+
* is neither a page to move to nor a count worth showing. */
|
|
2343
2761
|
private renderPager(): TemplateResult {
|
|
2344
2762
|
const lastPage = Math.max(1, Math.ceil(this.total / this.pageSize));
|
|
2345
2763
|
const first = this.total === 0 ? 0 : (this.page - 1) * this.pageSize + 1;
|
|
2346
|
-
|
|
2764
|
+
// Derive `last` from the rows actually shown rather than page*pageSize,
|
|
2765
|
+
// so a short slice (a partial cursor page, or the final page) reports
|
|
2766
|
+
// the true position instead of overshooting the total.
|
|
2767
|
+
const last = first === 0 ? 0 : first + this.items.length - 1;
|
|
2347
2768
|
const atStart = this.cursorMode ? !this.prevCursor : this.page <= 1;
|
|
2348
2769
|
const atEnd = this.cursorMode ? !this.nextCursor : this.page >= lastPage;
|
|
2349
|
-
|
|
2770
|
+
// Nothing to show: an empty counted list (the .list-state covers it),
|
|
2771
|
+
// or an uncounted cursor list with no other page to step to. When the
|
|
2772
|
+
// endpoint provides a count we show the "N–M of Total" status in both
|
|
2773
|
+
// page and cursor mode (the synthetic page tracks position in cursor
|
|
2774
|
+
// mode too).
|
|
2775
|
+
if (this.hasCount ? this.total === 0 : atStart && atEnd) {
|
|
2350
2776
|
return html``;
|
|
2351
2777
|
}
|
|
2352
2778
|
return html`
|
|
@@ -2359,7 +2785,7 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
2359
2785
|
>
|
|
2360
2786
|
<temba-icon name=${Icon.arrow_left} size="1"></temba-icon>
|
|
2361
2787
|
</span>
|
|
2362
|
-
${
|
|
2788
|
+
${this.hasCount
|
|
2363
2789
|
? html`<span class="pager-status"
|
|
2364
2790
|
>${first}–${last} of ${this.total}</span
|
|
2365
2791
|
>`
|
|
@@ -2380,42 +2806,56 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
2380
2806
|
// Pin layout depends on the current columns + items, so resolve
|
|
2381
2807
|
// it once per render before the header and rows are built.
|
|
2382
2808
|
this.computePinLayout();
|
|
2383
|
-
|
|
2809
|
+
// The empty / loading / searching state is shown over the body area
|
|
2810
|
+
// (see .list-state) rather than as a colspan row, so it centers in
|
|
2811
|
+
// the visible container instead of the overflowing table width.
|
|
2812
|
+
// A query error takes over the empty-table treatment (with its own
|
|
2813
|
+
// error styling) in place of the plain "nothing to show" copy.
|
|
2814
|
+
const stateError = !this.searching && !this.loading && !!this.searchError;
|
|
2815
|
+
const stateMessage = this.searching
|
|
2816
|
+
? 'Searching…'
|
|
2817
|
+
: this.loading && this.items.length === 0
|
|
2818
|
+
? 'Loading…'
|
|
2819
|
+
: stateError
|
|
2820
|
+
? this.searchError
|
|
2821
|
+
: this.items.length === 0
|
|
2822
|
+
? this.emptyMessage
|
|
2823
|
+
: null;
|
|
2384
2824
|
return html`
|
|
2385
2825
|
<div class="panel">
|
|
2386
2826
|
${this.renderTitlebar()}
|
|
2387
2827
|
<div class="header-rule"></div>
|
|
2388
2828
|
<div class="table-frame">
|
|
2389
2829
|
<div
|
|
2390
|
-
class="table-scroll"
|
|
2830
|
+
class="table-scroll ${stateMessage ? 'no-rows' : ''}"
|
|
2391
2831
|
@scroll=${() => this.syncScrollAffordance()}
|
|
2392
2832
|
>
|
|
2393
2833
|
<table
|
|
2394
2834
|
class="table ${this.fixedLayout ? 'fixed' : ''}"
|
|
2395
|
-
style=${this.minTableWidth
|
|
2835
|
+
style=${!stateMessage && this.minTableWidth
|
|
2396
2836
|
? `min-width: ${this.minTableWidth};`
|
|
2397
2837
|
: ''}
|
|
2398
2838
|
>
|
|
2399
2839
|
${this.renderHeader()}
|
|
2400
2840
|
<tbody>
|
|
2401
|
-
${
|
|
2402
|
-
?
|
|
2403
|
-
|
|
2404
|
-
</tr>`
|
|
2405
|
-
: this.loading && this.items.length === 0
|
|
2406
|
-
? html`<tr>
|
|
2407
|
-
<td class="loading" colspan=${span}>Loading…</td>
|
|
2408
|
-
</tr>`
|
|
2409
|
-
: this.items.length === 0
|
|
2410
|
-
? html`<tr>
|
|
2411
|
-
<td class="empty" colspan=${span}>
|
|
2412
|
-
${this.emptyMessage}
|
|
2413
|
-
</td>
|
|
2414
|
-
</tr>`
|
|
2415
|
-
: this.items.map((i) => this.renderRow(i))}
|
|
2841
|
+
${stateMessage
|
|
2842
|
+
? null
|
|
2843
|
+
: this.items.map((i) => this.renderRow(i))}
|
|
2416
2844
|
</tbody>
|
|
2417
2845
|
</table>
|
|
2418
2846
|
</div>
|
|
2847
|
+
${stateMessage
|
|
2848
|
+
? html`<div class="list-state ${stateError ? 'error' : ''}">
|
|
2849
|
+
${stateError
|
|
2850
|
+
? html`<span class="state-error">
|
|
2851
|
+
<temba-icon name=${Icon.error} size="1"></temba-icon>
|
|
2852
|
+
${stateMessage}
|
|
2853
|
+
</span>`
|
|
2854
|
+
: stateMessage}
|
|
2855
|
+
</div>`
|
|
2856
|
+
: null}
|
|
2857
|
+
${this.bulkVisible ? this.renderBulkBar() : null}
|
|
2858
|
+
<div class="header-shadow"></div>
|
|
2419
2859
|
<div class="scroll-shadow"></div>
|
|
2420
2860
|
</div>
|
|
2421
2861
|
</div>
|