@nyaruka/temba-components 0.158.1 → 0.159.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 +12 -0
- package/dist/static/svg/index.svg +1 -1
- package/dist/temba-components.js +1458 -600
- package/dist/temba-components.js.map +1 -1
- package/package.json +1 -2
- package/src/Icons.ts +8 -1
- package/src/display/Button.ts +24 -14
- package/src/display/Thumbnail.ts +1 -1
- package/src/flow/nodes/split_by_resthook.ts +3 -3
- package/src/interfaces.ts +46 -2
- package/src/layout/PageHeader.ts +338 -0
- package/src/list/ContactList.ts +68 -52
- package/src/list/ContentList.ts +1461 -346
- package/src/list/FlowList.ts +20 -26
- package/src/list/MsgList.ts +169 -71
- package/src/live/ContactEvents.ts +880 -0
- package/src/styles/designTokens.ts +5 -2
- package/src/styles/pillVariants.ts +21 -6
- package/static/css/design-system.css +769 -0
- package/static/css/temba-components.css +16 -77
- package/static/svg/index.svg +1 -1
- package/static/svg/work/traced/chevron-down-double.svg +1 -0
- package/static/svg/work/traced/chevron-up-double.svg +1 -0
- package/static/svg/work/used/chevron-down-double.svg +3 -0
- package/static/svg/work/used/chevron-up-double.svg +3 -0
- package/temba-modules.ts +4 -2
- package/web-dev-server.config.mjs +9 -0
- package/src/live/ContactPending.ts +0 -247
package/src/list/ContentList.ts
CHANGED
|
@@ -3,8 +3,9 @@ import { property, state } from 'lit/decorators.js';
|
|
|
3
3
|
import { RapidElement } from '../RapidElement';
|
|
4
4
|
import { Icon } from '../Icons';
|
|
5
5
|
import { CustomEventType } from '../interfaces';
|
|
6
|
-
import { getUrl, postUrl
|
|
6
|
+
import { getUrl, postUrl } from '../utils';
|
|
7
7
|
import { designTokens } from '../styles/designTokens';
|
|
8
|
+
import { Dropdown } from '../display/Dropdown';
|
|
8
9
|
|
|
9
10
|
/** A single column in the list. Subclasses typically define a static
|
|
10
11
|
* set via {@link ContentList.columns}; consumers may also set it as
|
|
@@ -14,26 +15,56 @@ export interface ContentListColumn {
|
|
|
14
15
|
label?: string;
|
|
15
16
|
sortable?: boolean;
|
|
16
17
|
align?: 'left' | 'right' | 'center';
|
|
17
|
-
/**
|
|
18
|
-
*
|
|
18
|
+
/** Fixed column width (e.g. "150px"). Pins the column to an exact
|
|
19
|
+
* size regardless of content. Most columns instead leave this
|
|
20
|
+
* unset and size to their content within {@link minWidth} /
|
|
21
|
+
* {@link maxWidth} bounds. */
|
|
19
22
|
width?: string;
|
|
20
|
-
/**
|
|
21
|
-
*
|
|
22
|
-
|
|
23
|
+
/** Optional lower bound for a content-sized column (one with no
|
|
24
|
+
* `width`). When unset the column floors at its own content —
|
|
25
|
+
* typically the width of the header label. */
|
|
26
|
+
minWidth?: string;
|
|
27
|
+
/** Upper bound for a content-sized column (one with no `width`).
|
|
28
|
+
* Defaults to "320px" — the column grows to fit its widest value
|
|
29
|
+
* up to this cap, then ellipsis-truncates. */
|
|
30
|
+
maxWidth?: string;
|
|
31
|
+
/** Greedily absorb the leftover table width — the column's cell
|
|
32
|
+
* stretches to fill the slack between the fixed/content-sized
|
|
33
|
+
* columns, so it always reaches the card edge. At most one column
|
|
34
|
+
* should set this; it stands in for the slack {@link spacer}, so
|
|
35
|
+
* the spacer is skipped when a grow column is present. A `grow`
|
|
36
|
+
* column ignores `maxWidth` but still honours `minWidth` as a
|
|
37
|
+
* floor for when the table overflows. */
|
|
38
|
+
grow?: boolean;
|
|
39
|
+
/** Pin the column so it stays visible while the table scrolls
|
|
40
|
+
* horizontally. `true` / `'left'` freezes it against the left
|
|
41
|
+
* edge; `'right'` freezes it against the right edge. Left-pinned
|
|
42
|
+
* columns must be contiguous from the first column; right-pinned
|
|
43
|
+
* columns contiguous to the last. */
|
|
44
|
+
pinned?: boolean | 'left' | 'right';
|
|
23
45
|
}
|
|
24
46
|
|
|
25
47
|
/** A bulk action surfaced in the toolbar when one or more rows are
|
|
26
|
-
* selected.
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
48
|
+
* selected. When {@link ContentList.actionEndpoint} is set the
|
|
49
|
+
* component POSTs the action there directly and re-fetches the
|
|
50
|
+
* current page so the user stays put — the `temba-bulk-action`
|
|
51
|
+
* event then fires after the round-trip for consumers that need to
|
|
52
|
+
* react (refresh a sidebar count, etc.). When `actionEndpoint` is
|
|
53
|
+
* empty the component just fires the event and leaves the POST to
|
|
54
|
+
* the host. The label-toggle action is a special case — when
|
|
55
|
+
* `labelsEndpoint` is set, the component renders a dropdown of
|
|
56
|
+
* label checkboxes and POSTs the apply/remove directly to
|
|
57
|
+
* `actionEndpoint`, mirroring rapidpro's
|
|
58
|
+
* `runActionOnObjectRows('label', …)` flow. */
|
|
32
59
|
export interface ContentListBulkAction {
|
|
33
60
|
key: string;
|
|
34
61
|
label: string;
|
|
35
62
|
icon?: string;
|
|
36
63
|
destructive?: boolean;
|
|
64
|
+
/** When set, the component shows window.confirm(message) before
|
|
65
|
+
* applying the action — used for destructive operations whose
|
|
66
|
+
* wording is localized server-side. */
|
|
67
|
+
confirm?: string;
|
|
37
68
|
/** GET endpoint returning `{ results: [{ uuid, name, count? }] }`.
|
|
38
69
|
* Setting this turns the action into a label-toggle dropdown
|
|
39
70
|
* instead of a fire-and-forget bulk-action event. */
|
|
@@ -66,40 +97,50 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
66
97
|
${designTokens}
|
|
67
98
|
|
|
68
99
|
:host {
|
|
69
|
-
|
|
100
|
+
/* Flex column so the panel fills the host's height — when
|
|
101
|
+
the host is given a bounded height (see fillWindow) the
|
|
102
|
+
table scrolls internally instead of growing the page. */
|
|
103
|
+
display: flex;
|
|
104
|
+
flex-direction: column;
|
|
105
|
+
min-height: 0;
|
|
70
106
|
font-family: var(--font);
|
|
71
107
|
color: var(--text-1);
|
|
72
108
|
font-size: 13.5px;
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
109
|
+
/* Fixed-width slot for a column's sort arrow. It is reserved
|
|
110
|
+
on the inboard side of the label so the arrow never shifts
|
|
111
|
+
the label, and the label stays flush with the column's
|
|
112
|
+
values. */
|
|
113
|
+
--sort-gutter: 16px;
|
|
114
|
+
/* Selected-row wash — accent-50 on its own reads grey, so a
|
|
115
|
+
touch of the accent-400 rail colour is mixed in to give
|
|
116
|
+
the selection a faint accent tint. */
|
|
117
|
+
--cl-selected: color-mix(
|
|
118
|
+
in oklab,
|
|
119
|
+
var(--accent-400) 9%,
|
|
120
|
+
var(--accent-50)
|
|
121
|
+
);
|
|
122
|
+
/* The dividers bracketing a selected row — the plain grey
|
|
123
|
+
--border reads as a seam against the wash, so this is the
|
|
124
|
+
same accent pushed a little further. */
|
|
125
|
+
--cl-selected-border: color-mix(
|
|
126
|
+
in oklab,
|
|
127
|
+
var(--accent-400) 24%,
|
|
128
|
+
var(--accent-50)
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
/* fillWindow — take the slack of a height-bounded flex-column
|
|
132
|
+
parent so the table scrolls internally; min-height: 0 (set
|
|
133
|
+
above) lets the host shrink enough for that scroll. */
|
|
134
|
+
:host([fill-window]) {
|
|
86
135
|
flex: 1 1 auto;
|
|
87
|
-
min-width: 0;
|
|
88
|
-
}
|
|
89
|
-
.title {
|
|
90
|
-
font-size: 15.5px;
|
|
91
|
-
font-weight: var(--w-semibold);
|
|
92
|
-
color: var(--text-1);
|
|
93
|
-
line-height: 1.3;
|
|
94
136
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
.actions {
|
|
102
|
-
flex: 0 0 auto;
|
|
137
|
+
|
|
138
|
+
/* The header — title + content menu — is temba-page-header.
|
|
139
|
+
The list slots its search / bulk-action controls into that
|
|
140
|
+
header's actions area through this row. When rows are
|
|
141
|
+
selected the search is replaced inline by bulk-action chips
|
|
142
|
+
so the toolbar stays in the same spot visually. */
|
|
143
|
+
.header-actions {
|
|
103
144
|
display: flex;
|
|
104
145
|
align-items: center;
|
|
105
146
|
gap: 14px;
|
|
@@ -235,170 +276,429 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
235
276
|
.searchbar input::placeholder {
|
|
236
277
|
color: var(--text-3);
|
|
237
278
|
}
|
|
238
|
-
.searchbar .clear
|
|
279
|
+
.searchbar .clear,
|
|
280
|
+
.searchbar .submit {
|
|
239
281
|
cursor: pointer;
|
|
240
282
|
color: var(--text-3);
|
|
241
283
|
padding: 2px;
|
|
284
|
+
display: inline-flex;
|
|
285
|
+
align-items: center;
|
|
242
286
|
}
|
|
243
|
-
.searchbar .clear:hover
|
|
244
|
-
|
|
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 {
|
|
295
|
+
flex: 0 0 auto;
|
|
296
|
+
color: var(--text-3);
|
|
297
|
+
font-size: 12px;
|
|
298
|
+
white-space: nowrap;
|
|
299
|
+
padding: 0 4px;
|
|
245
300
|
}
|
|
246
301
|
|
|
247
|
-
/* Card panel — surface white wrapping
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
302
|
+
/* Card panel — surface white wrapping the header and table.
|
|
303
|
+
Soft shadow + radius gives it the contained-card feel from
|
|
304
|
+
the styleguide. The 20px horizontal padding insets the
|
|
305
|
+
header and table from the card edges. A flex column so the
|
|
306
|
+
header stays put and the table region takes the slack —
|
|
307
|
+
when the host is height-bounded the table scrolls inside it
|
|
308
|
+
rather than growing the page. */
|
|
253
309
|
.panel {
|
|
254
310
|
background: var(--surface);
|
|
255
311
|
border-radius: var(--r);
|
|
256
312
|
overflow: hidden;
|
|
257
313
|
box-shadow: var(--shadow-1);
|
|
258
314
|
padding: 0 20px;
|
|
315
|
+
flex: 1 1 auto;
|
|
316
|
+
min-height: 0;
|
|
317
|
+
display: flex;
|
|
318
|
+
flex-direction: column;
|
|
319
|
+
}
|
|
320
|
+
/* A window-filling list isn't a floating card — it fills its
|
|
321
|
+
container flush, so it drops the card radius + shadow that
|
|
322
|
+
would otherwise reveal the page background at its corners. */
|
|
323
|
+
:host([fill-window]) .panel {
|
|
324
|
+
border-radius: 0;
|
|
325
|
+
box-shadow: none;
|
|
326
|
+
}
|
|
327
|
+
/* The header holds its size; the table frame takes the slack. */
|
|
328
|
+
temba-page-header,
|
|
329
|
+
.header-rule,
|
|
330
|
+
.searchbar {
|
|
331
|
+
flex: 0 0 auto;
|
|
259
332
|
}
|
|
260
333
|
|
|
261
|
-
/*
|
|
262
|
-
|
|
263
|
-
elements so they inset 20px from the card edges instead
|
|
264
|
-
of bleeding full-width — same with the row separators
|
|
265
|
-
below. The header background stays untinted; only weight
|
|
266
|
-
+ uppercase distinguish it from data rows. */
|
|
267
|
-
/* Full-bleed rule between titlebar/searchbar and the header
|
|
268
|
-
row. Negative horizontal margin escapes the panel's 20px
|
|
334
|
+
/* Full-bleed rule between the titlebar/searchbar and the
|
|
335
|
+
table. Negative horizontal margin escapes the panel's 20px
|
|
269
336
|
padding so the line reaches the card chrome on both sides
|
|
270
|
-
— the
|
|
271
|
-
inset. */
|
|
337
|
+
— the table itself (rows, lines, hover wash) stays inset. */
|
|
272
338
|
.header-rule {
|
|
273
339
|
height: 1px;
|
|
274
340
|
background: var(--border);
|
|
275
341
|
margin: 0 -20px;
|
|
276
342
|
}
|
|
277
343
|
|
|
278
|
-
|
|
344
|
+
/* Scroll frame — flexes to fill the panel's slack and is the
|
|
345
|
+
positioning context for the right-edge scroll shadow. The
|
|
346
|
+
negative margin escapes the panel's 20px panel padding so
|
|
347
|
+
both the vertical scrollbar AND the right-pinned column ride
|
|
348
|
+
the component's edge — .table-scroll deliberately carries no
|
|
349
|
+
padding because position: sticky resolves offsets against
|
|
350
|
+
the scroller's padding-box, and any padding here would leave
|
|
351
|
+
a transparent gap where overflowing content shows through
|
|
352
|
+
behind the pinned cell. The lead/trail inset comes from the
|
|
353
|
+
first/last cell padding instead. The inner .table-scroll
|
|
354
|
+
scrolls the table both ways (vertically within this frame,
|
|
355
|
+
horizontally for overflow) so the header row and data rows
|
|
356
|
+
scroll together as one table. */
|
|
357
|
+
.table-frame {
|
|
279
358
|
position: relative;
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
359
|
+
flex: 1 1 auto;
|
|
360
|
+
min-height: 0;
|
|
361
|
+
/* Extend only the right side past the panel's 20px padding
|
|
362
|
+
so the vertical scrollbar (and any right-pinned column)
|
|
363
|
+
rides the card chrome instead of stranding whitespace
|
|
364
|
+
between the bar and the panel edge. The left side stays
|
|
365
|
+
within the panel padding so row dividers don't bleed full-
|
|
366
|
+
width. */
|
|
367
|
+
margin-right: -20px;
|
|
368
|
+
/* Contain the sticky header's z-index inside this frame so it
|
|
369
|
+
can't compete with the page header's content-menu dropdown
|
|
370
|
+
(also z-index 2), which otherwise paints under the table
|
|
371
|
+
header by DOM-order tie-break. */
|
|
372
|
+
isolation: isolate;
|
|
373
|
+
}
|
|
374
|
+
.table-scroll {
|
|
375
|
+
overflow: auto;
|
|
376
|
+
height: 100%;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/* auto layout lets the contact-field columns size to the
|
|
380
|
+
content shown in them; system columns are instead held to a
|
|
381
|
+
fixed size by their .cell-inner width. width: 100% makes the
|
|
382
|
+
table fill the card, then overflow once the fixed widths
|
|
383
|
+
outgrow it — which is what arms the horizontal scroll. */
|
|
384
|
+
table.table {
|
|
385
|
+
width: 100%;
|
|
386
|
+
border-collapse: collapse;
|
|
387
|
+
table-layout: auto;
|
|
388
|
+
}
|
|
389
|
+
/* A fixed-layout list sizes every column up front, so the
|
|
390
|
+
table is exactly its container width and never arms a
|
|
391
|
+
horizontal scroll — overflowing cells truncate instead. The
|
|
392
|
+
leading cells need an explicit width here, since the auto-
|
|
393
|
+
layout shrink trick (width 1%) collapses them to nothing
|
|
394
|
+
under fixed layout. */
|
|
395
|
+
table.table.fixed {
|
|
396
|
+
table-layout: fixed;
|
|
397
|
+
}
|
|
398
|
+
/* Just wide enough for the checkbox glyph (~15px); the cell's
|
|
399
|
+
lead inset and trailing padding supply the breathing room
|
|
400
|
+
on either side. Anything wider strands the checkbox in dead
|
|
401
|
+
space ahead of column one. */
|
|
402
|
+
table.table.fixed th.check-cell {
|
|
403
|
+
width: 16px;
|
|
404
|
+
}
|
|
405
|
+
table.table.fixed th.icon-cell {
|
|
406
|
+
width: 32px;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/* The header row sticks to the top of the scroll frame so the
|
|
410
|
+
column labels stay put while the rows scroll under them.
|
|
411
|
+
z-index 2 lifts it above the body; a pinned header cell
|
|
412
|
+
needs 3 to also clear the body's pinned column (z-index 1). */
|
|
413
|
+
tr.header th {
|
|
414
|
+
height: 36px;
|
|
415
|
+
vertical-align: middle;
|
|
416
|
+
text-align: left;
|
|
284
417
|
color: var(--text-3);
|
|
285
418
|
font-size: 11px;
|
|
286
419
|
font-weight: var(--w-medium);
|
|
287
420
|
text-transform: uppercase;
|
|
288
421
|
letter-spacing: 0.06em;
|
|
422
|
+
white-space: nowrap;
|
|
423
|
+
background: var(--surface);
|
|
424
|
+
border-bottom: 1px solid var(--border);
|
|
425
|
+
position: sticky;
|
|
426
|
+
top: 0;
|
|
427
|
+
z-index: 2;
|
|
289
428
|
}
|
|
290
|
-
.header
|
|
291
|
-
|
|
292
|
-
position: absolute;
|
|
293
|
-
left: 0;
|
|
294
|
-
right: 0;
|
|
295
|
-
bottom: 0;
|
|
296
|
-
height: 1px;
|
|
297
|
-
background: var(--border);
|
|
429
|
+
tr.header th.pinned {
|
|
430
|
+
z-index: 3;
|
|
298
431
|
}
|
|
299
432
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
the full row box (inset by the panel), and the bottom
|
|
304
|
-
separator spans the same width. */
|
|
305
|
-
.row {
|
|
306
|
-
position: relative;
|
|
307
|
-
display: flex;
|
|
308
|
-
align-items: center;
|
|
309
|
-
min-height: 44px;
|
|
310
|
-
padding: 0 12px;
|
|
433
|
+
tr.row td {
|
|
434
|
+
height: 44px;
|
|
435
|
+
vertical-align: middle;
|
|
311
436
|
color: var(--text-1);
|
|
312
|
-
|
|
313
|
-
}
|
|
314
|
-
.row::after {
|
|
315
|
-
content: '';
|
|
316
|
-
position: absolute;
|
|
317
|
-
bottom: 0;
|
|
318
|
-
left: 0;
|
|
319
|
-
right: 0;
|
|
320
|
-
height: 1px;
|
|
321
|
-
background: var(--border);
|
|
437
|
+
border-bottom: 1px solid var(--border);
|
|
322
438
|
}
|
|
323
|
-
.row:last-child
|
|
324
|
-
|
|
439
|
+
tbody tr.row:last-child td {
|
|
440
|
+
border-bottom: none;
|
|
325
441
|
}
|
|
326
|
-
.row:hover {
|
|
442
|
+
tr.row:hover {
|
|
327
443
|
background: var(--accent-50);
|
|
328
444
|
}
|
|
329
|
-
|
|
330
|
-
|
|
445
|
+
/* Selection — a light accent wash (--cl-selected) carrying a
|
|
446
|
+
hint of the accent-400 rail colour so it reads clearly as
|
|
447
|
+
accent rather than grey. The accent-400 leading rail (below)
|
|
448
|
+
still sets a selected row apart from a hovered one. */
|
|
449
|
+
tr.row.selected {
|
|
450
|
+
background: var(--cl-selected);
|
|
451
|
+
}
|
|
452
|
+
/* Recolour the dividers that bracket a selected row — its own
|
|
453
|
+
bottom border and the bottom border of the row above it —
|
|
454
|
+
so the grey row seam doesn't cut across the accent wash.
|
|
455
|
+
Between two adjacent selected rows both rules agree. */
|
|
456
|
+
tr.row.selected td,
|
|
457
|
+
tr.row:has(+ tr.row.selected) td {
|
|
458
|
+
border-bottom-color: var(--cl-selected-border);
|
|
459
|
+
}
|
|
460
|
+
/* Grey pills (label / neutral / keyword variants) border off
|
|
461
|
+
--pill-border; setting it on a selected row's cells retints
|
|
462
|
+
those borders to the same colour as the row dividers. The
|
|
463
|
+
custom property inherits into the pill's shadow DOM, where
|
|
464
|
+
--border itself is re-declared and so can't be overridden. */
|
|
465
|
+
tr.row.selected td {
|
|
466
|
+
--pill-border: var(--cl-selected-border);
|
|
467
|
+
}
|
|
468
|
+
/* Solid accent-400 rail down the row's leading edge — the
|
|
469
|
+
design system's "active rail" selection affordance. Drawn
|
|
470
|
+
as a ::before on the first cell so it never competes with
|
|
471
|
+
the pinned cells' own box-shadow divider; bottom: -1px
|
|
472
|
+
bridges the row border so a run of selected rows reads as
|
|
473
|
+
one continuous rail. */
|
|
474
|
+
tr.row.selected td:first-child::before {
|
|
475
|
+
content: '';
|
|
476
|
+
position: absolute;
|
|
477
|
+
left: 0;
|
|
478
|
+
top: 0;
|
|
479
|
+
bottom: -1px;
|
|
480
|
+
width: 3px;
|
|
481
|
+
background: var(--accent-400);
|
|
482
|
+
}
|
|
483
|
+
/* A pinned first cell is position: sticky and already a
|
|
484
|
+
containing block for the rail; an unpinned one needs an
|
|
485
|
+
explicit positioning context. */
|
|
486
|
+
tr.row td:first-child:not(.pinned) {
|
|
487
|
+
position: relative;
|
|
331
488
|
}
|
|
332
|
-
.row.clickable {
|
|
489
|
+
tr.row.clickable {
|
|
333
490
|
cursor: pointer;
|
|
334
491
|
}
|
|
335
492
|
|
|
336
|
-
.cell,
|
|
337
|
-
.
|
|
493
|
+
.head-cell,
|
|
494
|
+
.cell {
|
|
338
495
|
padding: 0 8px;
|
|
339
|
-
|
|
340
|
-
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/* The inner wrapper carries each column's width contract: a
|
|
499
|
+
fixed width for system columns, a max-width cap for the
|
|
500
|
+
content-fit field columns. overflow/ellipsis keeps a long
|
|
501
|
+
value from blowing the column out. */
|
|
502
|
+
.cell-inner {
|
|
503
|
+
display: block;
|
|
341
504
|
overflow: hidden;
|
|
342
505
|
text-overflow: ellipsis;
|
|
343
506
|
white-space: nowrap;
|
|
344
507
|
}
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
.head-cell.right {
|
|
508
|
+
/* Right-aligned values sit flush against the column edge; the
|
|
509
|
+
header label matches because its sort slot is inboard (left
|
|
510
|
+
of the label), not between the label and the edge. */
|
|
511
|
+
.cell.right .cell-inner {
|
|
512
|
+
margin-left: auto;
|
|
351
513
|
text-align: right;
|
|
352
|
-
justify-content: flex-end;
|
|
353
514
|
}
|
|
354
|
-
.cell.center
|
|
355
|
-
|
|
515
|
+
.cell.center .cell-inner {
|
|
516
|
+
margin-left: auto;
|
|
517
|
+
margin-right: auto;
|
|
356
518
|
text-align: center;
|
|
519
|
+
}
|
|
520
|
+
/* No gap between label and sort slot — the slot's fixed width
|
|
521
|
+
is the whole gutter, which keeps the header-to-content
|
|
522
|
+
alignment math down to a single value. */
|
|
523
|
+
.head-inner {
|
|
524
|
+
display: flex;
|
|
525
|
+
align-items: center;
|
|
526
|
+
gap: 0;
|
|
527
|
+
overflow: hidden;
|
|
528
|
+
}
|
|
529
|
+
/* The label never shrinks — its full width becomes the
|
|
530
|
+
column's minimum, so a header is never truncated and a
|
|
531
|
+
content-sized column is always at least as wide as its
|
|
532
|
+
name. */
|
|
533
|
+
.head-inner .label {
|
|
534
|
+
flex: 0 0 auto;
|
|
535
|
+
white-space: nowrap;
|
|
536
|
+
}
|
|
537
|
+
.head-cell.right .head-inner {
|
|
538
|
+
margin-left: auto;
|
|
539
|
+
justify-content: flex-end;
|
|
540
|
+
}
|
|
541
|
+
.head-cell.center .head-inner {
|
|
542
|
+
margin-left: auto;
|
|
543
|
+
margin-right: auto;
|
|
357
544
|
justify-content: center;
|
|
358
545
|
}
|
|
359
546
|
|
|
360
|
-
/*
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
547
|
+
/* Pinned columns stay fixed against their edge while the rest
|
|
548
|
+
of the table scrolls under them. The frozen-region look —
|
|
549
|
+
the tint and the divider — only kicks in once the table
|
|
550
|
+
actually overflows (.overflowing); until then a pinned
|
|
551
|
+
column is indistinguishable from a plain one. */
|
|
552
|
+
th.pinned,
|
|
553
|
+
td.pinned {
|
|
554
|
+
position: sticky;
|
|
555
|
+
z-index: 1;
|
|
556
|
+
}
|
|
557
|
+
/* A faint tint sets the frozen region apart as its own sub-
|
|
558
|
+
panel — and, being opaque, stops scrolled content bleeding
|
|
559
|
+
through. The header/row selectors out-specify the plain
|
|
560
|
+
cell backgrounds so the tint actually lands. */
|
|
561
|
+
.table-frame.overflowing tr.header th.pinned,
|
|
562
|
+
.table-frame.overflowing tr.row td.pinned {
|
|
563
|
+
background: color-mix(in oklab, var(--sunken) 35%, var(--surface));
|
|
564
|
+
}
|
|
565
|
+
/* The hover/selected wash still wins over the tint so a
|
|
566
|
+
hovered/selected row reads as one continuous strip. */
|
|
567
|
+
.table-frame.overflowing tr.row:hover td.pinned {
|
|
568
|
+
background: var(--accent-50);
|
|
569
|
+
}
|
|
570
|
+
.table-frame.overflowing tr.row.selected td.pinned {
|
|
571
|
+
background: var(--cl-selected);
|
|
572
|
+
}
|
|
573
|
+
/* A subtle vertical rule marks where the pinned section ends.
|
|
574
|
+
It is an inset shadow rather than a border so it stays put
|
|
575
|
+
with the sticky cell under border-collapse. */
|
|
576
|
+
.table-frame.overflowing th.pin-last,
|
|
577
|
+
.table-frame.overflowing td.pin-last {
|
|
578
|
+
box-shadow: inset -1px 0 0 0 var(--border);
|
|
579
|
+
}
|
|
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
|
+
/* Mirror of the rule for the right-pinned group — the divider
|
|
589
|
+
sits on the inboard (left) edge of its first cell. */
|
|
590
|
+
.table-frame.overflowing th.pin-first,
|
|
591
|
+
.table-frame.overflowing td.pin-first {
|
|
592
|
+
box-shadow: inset 1px 0 0 0 var(--border);
|
|
593
|
+
}
|
|
594
|
+
/* While there is more table to the right, a drop shadow lifts
|
|
595
|
+
the right-frozen edge above the content sliding under it. */
|
|
596
|
+
.table-frame.can-scroll-right th.pin-first,
|
|
597
|
+
.table-frame.can-scroll-right td.pin-first {
|
|
598
|
+
box-shadow:
|
|
599
|
+
inset 1px 0 0 0 var(--border),
|
|
600
|
+
-8px 0 9px -9px rgba(15, 23, 42, 0.45);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/* Slack-absorbing column between the pinned and scrolling
|
|
604
|
+
sections — width: 100% makes it greedily take all leftover
|
|
605
|
+
table width, so extra space pools here as a gap instead of
|
|
606
|
+
stretching the real columns. It collapses to nothing once
|
|
607
|
+
the columns outgrow the viewport and the table scrolls. */
|
|
608
|
+
.spacer {
|
|
609
|
+
width: 100%;
|
|
610
|
+
padding: 0;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/* A 'grow' column does the spacer's job inline — width: 100%
|
|
614
|
+
makes its cell pool the leftover table width so the column
|
|
615
|
+
stretches to the card edge. It collapses back toward its
|
|
616
|
+
content (held at minWidth) once the table overflows. */
|
|
617
|
+
th.grow,
|
|
618
|
+
td.grow {
|
|
619
|
+
width: 100%;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/* Scroll gradient — fades in while there is more table to the
|
|
623
|
+
right, signalling the row scrolls horizontally. It sits
|
|
624
|
+
against the inboard edge of the right-pinned group (via
|
|
625
|
+
--cl-rpin-total, 0 when nothing is right-pinned) so it fades
|
|
626
|
+
the scrolling content just before it slides under the
|
|
627
|
+
frozen columns, rather than behind them, and stops short of
|
|
628
|
+
the horizontal scrollbar (via --cl-scrollbar).
|
|
629
|
+
pointer-events: none keeps it from eating row clicks. */
|
|
630
|
+
.scroll-shadow {
|
|
631
|
+
position: absolute;
|
|
632
|
+
top: 0;
|
|
633
|
+
bottom: var(--cl-scrollbar, 0px);
|
|
634
|
+
right: var(--cl-rpin-total, 0px);
|
|
635
|
+
width: 28px;
|
|
636
|
+
pointer-events: none;
|
|
637
|
+
opacity: 0;
|
|
638
|
+
transition: opacity 0.15s ease;
|
|
639
|
+
background: linear-gradient(
|
|
640
|
+
to right,
|
|
641
|
+
transparent,
|
|
642
|
+
color-mix(in oklab, var(--text-1) 16%, transparent)
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
.table-frame.can-scroll-right .scroll-shadow {
|
|
646
|
+
opacity: 1;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/* Checkbox column — shrink-to-fit (width: 1% is the table-
|
|
650
|
+
cell trick for that). The cell-level @click owns selection;
|
|
651
|
+
the inner checkbox is display-only (pointer-events: none)
|
|
652
|
+
so it can't double-fire on the same click. temba-checkbox
|
|
653
|
+
sizes its icon in em, so .check-inner pins the font-size to
|
|
654
|
+
keep header + row checkboxes the same visual scale. */
|
|
366
655
|
.check-cell {
|
|
367
|
-
|
|
368
|
-
|
|
656
|
+
width: 1%;
|
|
657
|
+
white-space: nowrap;
|
|
658
|
+
padding: 0 12px;
|
|
659
|
+
cursor: pointer;
|
|
660
|
+
--icon-color: var(--text-3);
|
|
661
|
+
}
|
|
662
|
+
.check-inner {
|
|
369
663
|
display: flex;
|
|
370
664
|
align-items: center;
|
|
371
665
|
font-size: 13.5px;
|
|
372
|
-
cursor: pointer;
|
|
373
|
-
--icon-color: var(--text-3);
|
|
374
666
|
}
|
|
375
|
-
/* The inner temba-checkbox is purely a visual indicator —
|
|
376
|
-
the cell-level @click is the single source of truth for
|
|
377
|
-
toggling selection. Disabling pointer events on the
|
|
378
|
-
checkbox prevents its internal click handler from firing
|
|
379
|
-
a second toggle on the same user click. */
|
|
380
667
|
.check-cell temba-checkbox {
|
|
381
668
|
pointer-events: none;
|
|
382
669
|
}
|
|
383
|
-
.row.selected .check-cell {
|
|
670
|
+
tr.row.selected .check-cell {
|
|
384
671
|
--icon-color: var(--accent-700);
|
|
385
672
|
}
|
|
386
673
|
|
|
387
674
|
.head-cell.sortable {
|
|
388
675
|
cursor: pointer;
|
|
389
676
|
user-select: none;
|
|
390
|
-
display: inline-flex;
|
|
391
|
-
align-items: center;
|
|
392
|
-
gap: 4px;
|
|
393
677
|
}
|
|
394
678
|
.head-cell.sortable:hover {
|
|
395
679
|
color: var(--text-2);
|
|
396
680
|
}
|
|
397
|
-
|
|
681
|
+
/* The sort arrow trails the label in a fixed-width slot. The
|
|
682
|
+
slot is reserved whenever a column is sortable or right-
|
|
683
|
+
aligned, so the label never shifts when the arrow appears
|
|
684
|
+
and the gutter math holds. The arrow itself is invisible
|
|
685
|
+
until the column is the active sort — or, for an inactive
|
|
686
|
+
sortable column, while its header is hovered. */
|
|
687
|
+
.sort-slot {
|
|
688
|
+
flex: 0 0 var(--sort-gutter);
|
|
689
|
+
display: inline-flex;
|
|
690
|
+
align-items: center;
|
|
691
|
+
justify-content: center;
|
|
692
|
+
}
|
|
693
|
+
.sort-icon {
|
|
398
694
|
--icon-color: var(--text-3);
|
|
695
|
+
opacity: 0;
|
|
696
|
+
transition: opacity 0.12s ease;
|
|
697
|
+
}
|
|
698
|
+
.head-cell.sortable:hover .sort-icon {
|
|
399
699
|
opacity: 0.55;
|
|
400
700
|
}
|
|
401
|
-
.head-cell.sortable.active
|
|
701
|
+
.head-cell.sortable.active .sort-icon {
|
|
402
702
|
--icon-color: var(--accent-700);
|
|
403
703
|
opacity: 1;
|
|
404
704
|
}
|
|
@@ -406,22 +706,49 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
406
706
|
color: var(--accent-700);
|
|
407
707
|
}
|
|
408
708
|
|
|
409
|
-
/* Leading icon column — small
|
|
410
|
-
every row
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
collapses. */
|
|
709
|
+
/* Leading entity-type icon column — small icon shared by
|
|
710
|
+
every row (contact silhouette, flow type icon, etc.).
|
|
711
|
+
Subclasses override {@link getRowIcon}; when it returns
|
|
712
|
+
null for every row the column is never rendered. */
|
|
414
713
|
.icon-cell {
|
|
415
|
-
|
|
416
|
-
|
|
714
|
+
width: 1%;
|
|
715
|
+
white-space: nowrap;
|
|
716
|
+
padding: 0 6px 0 0;
|
|
717
|
+
--icon-color: var(--text-3);
|
|
718
|
+
}
|
|
719
|
+
.icon-inner {
|
|
417
720
|
display: flex;
|
|
418
721
|
align-items: center;
|
|
419
|
-
--icon-color: var(--text-3);
|
|
420
722
|
}
|
|
421
|
-
.row.selected .icon-cell {
|
|
723
|
+
tr.row.selected .icon-cell {
|
|
422
724
|
--icon-color: var(--accent-700);
|
|
423
725
|
}
|
|
424
726
|
|
|
727
|
+
/* The table-frame sits inside the panel's 20px padding, so the
|
|
728
|
+
first cell already starts at the page-header's content edge
|
|
729
|
+
— no extra lead inset needed. The last cell still trims its
|
|
730
|
+
padding-right (below) so column content doesn't crowd the
|
|
731
|
+
card chrome. */
|
|
732
|
+
/* The first data cell trims its left padding to sit close to
|
|
733
|
+
the leading icon — the icon cell's 6px trailing padding
|
|
734
|
+
plus this 4px makes a snug 10px gap. */
|
|
735
|
+
.icon-cell + .head-cell,
|
|
736
|
+
.icon-cell + .cell {
|
|
737
|
+
padding-left: 4px;
|
|
738
|
+
}
|
|
739
|
+
/* With no icon column the first data cell follows the
|
|
740
|
+
checkbox directly; it drops its left padding entirely so
|
|
741
|
+
the value isn't marooned past a gap meant to clear an
|
|
742
|
+
icon — just the checkbox cell's 12px trailing padding. */
|
|
743
|
+
.check-cell + .head-cell,
|
|
744
|
+
.check-cell + .cell {
|
|
745
|
+
padding-left: 0;
|
|
746
|
+
}
|
|
747
|
+
tr.header th:last-child,
|
|
748
|
+
tr.row td:last-child {
|
|
749
|
+
padding-right: 20px;
|
|
750
|
+
}
|
|
751
|
+
|
|
425
752
|
.empty,
|
|
426
753
|
.loading {
|
|
427
754
|
padding: 40px var(--pad);
|
|
@@ -429,22 +756,20 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
429
756
|
color: var(--text-3);
|
|
430
757
|
}
|
|
431
758
|
|
|
432
|
-
/*
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
759
|
+
/* Pager — a compact "‹ 1–N of Total ›" stepper that lives in
|
|
760
|
+
the header's actions cluster: chevron-only paging buttons
|
|
761
|
+
bracketing a plain count, no borders or labels, matching the
|
|
762
|
+
quiet Search action it sits beside. */
|
|
763
|
+
.pager {
|
|
436
764
|
display: flex;
|
|
437
765
|
align-items: center;
|
|
438
|
-
|
|
439
|
-
|
|
766
|
+
gap: 2px;
|
|
767
|
+
}
|
|
768
|
+
.pager-status {
|
|
769
|
+
padding: 0 4px;
|
|
440
770
|
color: var(--text-3);
|
|
441
771
|
font-size: 12.5px;
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
.pager {
|
|
445
|
-
display: flex;
|
|
446
|
-
align-items: center;
|
|
447
|
-
gap: 4px;
|
|
772
|
+
white-space: nowrap;
|
|
448
773
|
}
|
|
449
774
|
|
|
450
775
|
.page-btn {
|
|
@@ -518,12 +843,23 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
518
843
|
`;
|
|
519
844
|
}
|
|
520
845
|
|
|
521
|
-
/** JSON endpoint URL. The component appends `
|
|
522
|
-
*
|
|
523
|
-
*
|
|
846
|
+
/** JSON endpoint URL. The component appends `sort` and `search`
|
|
847
|
+
* params. Two pagination shapes are supported, picked per
|
|
848
|
+
* response: a page-counted list — `{ results, count }`, navigated
|
|
849
|
+
* by appending `page` — or a cursor list — `{ results, next,
|
|
850
|
+
* previous }` with no `count`, navigated by following the opaque
|
|
851
|
+
* `next` / `previous` URLs (rapidpro's `CursorPagination`). */
|
|
524
852
|
@property({ type: String })
|
|
525
853
|
endpoint: string;
|
|
526
854
|
|
|
855
|
+
/** Endpoint for the page's content menu. Passed straight through
|
|
856
|
+
* to the embedded {@link PageHeader}, which fetches it and renders
|
|
857
|
+
* the menu's action buttons + overflow in the list header — so the
|
|
858
|
+
* list header doubles as the page header instead of the page
|
|
859
|
+
* chrome carrying a separate title + menu bar. */
|
|
860
|
+
@property({ type: String, attribute: 'content-menu-endpoint' })
|
|
861
|
+
contentMenuEndpoint = '';
|
|
862
|
+
|
|
527
863
|
/** Column definitions. Subclasses set this in the constructor;
|
|
528
864
|
* consumers may also override at the element level. */
|
|
529
865
|
@property({ type: Array, attribute: false })
|
|
@@ -543,10 +879,48 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
543
879
|
@property({ type: Boolean })
|
|
544
880
|
searchable = true;
|
|
545
881
|
|
|
546
|
-
/**
|
|
882
|
+
/** Enables the multi-select checkbox column. The column only
|
|
883
|
+
* actually renders when this is true AND {@link bulkActions} has
|
|
884
|
+
* entries — a list with no bulk actions has nothing for selection
|
|
885
|
+
* to drive, so checkboxes would just take up space. See
|
|
886
|
+
* {@link hasCheckboxes}. */
|
|
547
887
|
@property({ type: Boolean })
|
|
548
888
|
selectable = true;
|
|
549
889
|
|
|
890
|
+
/** Whether the selection column actually renders — true only when
|
|
891
|
+
* the list is `selectable` AND has at least one bulk action. */
|
|
892
|
+
protected get hasCheckboxes(): boolean {
|
|
893
|
+
return this.selectable && this.bulkActions.length > 0;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/** When true, the table uses a fixed layout: every column is
|
|
897
|
+
* sized up front (from each column's `width`, with the `grow`
|
|
898
|
+
* column taking the remainder), so a cell whose content doesn't
|
|
899
|
+
* fit ellipsis-truncates rather than stretching its column.
|
|
900
|
+
* Intended for lists whose columns are all `width`-set or `grow`.
|
|
901
|
+
* Pair with {@link minTableWidth} to allow a horizontal scroll
|
|
902
|
+
* once the container is too narrow for those column shares. */
|
|
903
|
+
@property({ type: Boolean, attribute: 'fixed-layout' })
|
|
904
|
+
fixedLayout = false;
|
|
905
|
+
|
|
906
|
+
/** Minimum table width (e.g. "640px"). The table won't shrink
|
|
907
|
+
* below it — once the container is narrower, the list scrolls
|
|
908
|
+
* horizontally instead. With {@link fixedLayout} this is what
|
|
909
|
+
* lets the table scroll at all: fixed layout keeps each column's
|
|
910
|
+
* share stable and truncates overflow, and this floor decides
|
|
911
|
+
* when that share stops shrinking and the scroll takes over. */
|
|
912
|
+
@property({ type: String, attribute: 'min-table-width' })
|
|
913
|
+
minTableWidth = '';
|
|
914
|
+
|
|
915
|
+
/** When true, the list grows to fill its container — the table
|
|
916
|
+
* body scrolls inside it rather than the page. The host's parent
|
|
917
|
+
* must be a height-bounded flex column (the list takes the slack
|
|
918
|
+
* via `flex: 1`); anything below the list in that column, such as
|
|
919
|
+
* a page footer, stays visible. Off by default; a full-page list
|
|
920
|
+
* (e.g. the inbox) opts in. */
|
|
921
|
+
@property({ type: Boolean, attribute: 'fill-window' })
|
|
922
|
+
fillWindow = false;
|
|
923
|
+
|
|
550
924
|
/** When true, sort/search/page state is reflected to the URL via
|
|
551
925
|
* `history.pushState` so the page is deep-linkable and back/forward
|
|
552
926
|
* navigates between list states. Off by default — opt in. */
|
|
@@ -558,6 +932,25 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
558
932
|
@property({ type: String })
|
|
559
933
|
urlParamPrefix = '';
|
|
560
934
|
|
|
935
|
+
/** Key under which restorable list state (page, sort, search) is
|
|
936
|
+
* stashed in the browser's history entry — set this to opt into
|
|
937
|
+
* history-state restoration without touching the URL. On every
|
|
938
|
+
* user-driven page/sort/search change the list fires a
|
|
939
|
+
* `temba-history-change` event carrying `{key, state, replace}`;
|
|
940
|
+
* the host (e.g. an SPA frame) is expected to merge `state` into
|
|
941
|
+
* the current history entry and either `pushState` (when
|
|
942
|
+
* `replace` is false — paging, sort, committed search) or
|
|
943
|
+
* `replaceState` (when `replace` is true — typing in the search
|
|
944
|
+
* box, or other no-history-entry updates). On mount the list
|
|
945
|
+
* reads back `history.state?.[key]` and resumes from those values
|
|
946
|
+
* before its initial fetch, and an in-list `popstate` re-reads
|
|
947
|
+
* the active entry and re-fetches so back/forward navigates
|
|
948
|
+
* between the user's page/sort/search states. Picks one slot per
|
|
949
|
+
* list, so multiple lists on a page coexist by using distinct
|
|
950
|
+
* keys. */
|
|
951
|
+
@property({ type: String, attribute: 'history-state-key' })
|
|
952
|
+
historyStateKey = '';
|
|
953
|
+
|
|
561
954
|
/** Placeholder for the search input. */
|
|
562
955
|
@property({ type: String })
|
|
563
956
|
searchPlaceholder = 'Search';
|
|
@@ -593,9 +986,51 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
593
986
|
@state()
|
|
594
987
|
protected total = 0;
|
|
595
988
|
|
|
989
|
+
/** Whether the last response carried a server `count`. Distinct
|
|
990
|
+
* from {@link total} because cursor lists fall back to the visible
|
|
991
|
+
* page length so the empty-state math still works — only an actual
|
|
992
|
+
* server count is reliable enough to surface in the UI (e.g. the
|
|
993
|
+
* search result indicator). */
|
|
994
|
+
@state()
|
|
995
|
+
protected hasCount = false;
|
|
996
|
+
|
|
596
997
|
@state()
|
|
597
998
|
protected page = 1;
|
|
598
999
|
|
|
1000
|
+
/** Whether the last response was a cursor list. Detected from the
|
|
1001
|
+
* shape of `next` / `previous` (a `cursor=` query param marks DRF
|
|
1002
|
+
* CursorPagination) so the mode survives a count being returned
|
|
1003
|
+
* alongside cursor URLs — a searched cursor endpoint may include
|
|
1004
|
+
* `count` for the result indicator without abandoning cursor
|
|
1005
|
+
* navigation. Falls back to count-absent on single-page responses
|
|
1006
|
+
* where neither nav URL is set. In cursor mode the pager follows
|
|
1007
|
+
* {@link nextCursor} / {@link prevCursor} instead of computing
|
|
1008
|
+
* page numbers off {@link total}. */
|
|
1009
|
+
@state()
|
|
1010
|
+
protected cursorMode = false;
|
|
1011
|
+
|
|
1012
|
+
/** Same-origin path+query of the cursor list's `next` page, or ''
|
|
1013
|
+
* when there is none. Only meaningful in {@link cursorMode}. */
|
|
1014
|
+
@state()
|
|
1015
|
+
protected nextCursor = '';
|
|
1016
|
+
|
|
1017
|
+
/** Same-origin path+query of the cursor list's `previous` page, or
|
|
1018
|
+
* '' when there is none. Only meaningful in {@link cursorMode}. */
|
|
1019
|
+
@state()
|
|
1020
|
+
protected prevCursor = '';
|
|
1021
|
+
|
|
1022
|
+
/** URL of the most recent fetch — re-requested by {@link refresh}
|
|
1023
|
+
* (and after a bulk action) so a cursor list stays on its current
|
|
1024
|
+
* page rather than snapping back to the first. */
|
|
1025
|
+
private currentUrl = '';
|
|
1026
|
+
|
|
1027
|
+
/** URL to fetch on the next initial-fetch pass, lifted from the
|
|
1028
|
+
* host's `history.state` by {@link readHistoryState}. Lets a
|
|
1029
|
+
* cursor-paginated list resume on the exact slice the user was on
|
|
1030
|
+
* (cursor URLs are opaque, so reconstructing them from page/sort
|
|
1031
|
+
* isn't possible). Cleared after use. */
|
|
1032
|
+
private restoreUrl = '';
|
|
1033
|
+
|
|
599
1034
|
/** Sort key; prefix with `-` for descending. Empty = server default. */
|
|
600
1035
|
@state()
|
|
601
1036
|
protected sort = '';
|
|
@@ -606,6 +1041,9 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
606
1041
|
@state()
|
|
607
1042
|
protected loading = false;
|
|
608
1043
|
|
|
1044
|
+
@state()
|
|
1045
|
+
protected searching = false;
|
|
1046
|
+
|
|
609
1047
|
@state()
|
|
610
1048
|
protected selectedIds: Set<string> = new Set();
|
|
611
1049
|
|
|
@@ -615,6 +1053,14 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
615
1053
|
@state()
|
|
616
1054
|
protected searchOpen = false;
|
|
617
1055
|
|
|
1056
|
+
/** Uncommitted input text — what's in the textbox while the user
|
|
1057
|
+
* is typing. Distinct from {@link search}, which is the committed
|
|
1058
|
+
* query that drives the fetch; the draft is only promoted to
|
|
1059
|
+
* `search` when the user presses Enter or clicks the search icon.
|
|
1060
|
+
* Bound to the input's `.value` so re-renders preserve typing. */
|
|
1061
|
+
@state()
|
|
1062
|
+
protected searchDraft = '';
|
|
1063
|
+
|
|
618
1064
|
/** Cache of labels fetched per label-toggle action key.
|
|
619
1065
|
* Populated lazily the first time a label dropdown opens. */
|
|
620
1066
|
@state()
|
|
@@ -628,12 +1074,31 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
628
1074
|
protected pendingLabel: string | null = null;
|
|
629
1075
|
|
|
630
1076
|
private pending: AbortController = null;
|
|
631
|
-
private debouncedFetch: () => void;
|
|
632
1077
|
private popstateHandler: () => void;
|
|
1078
|
+
private resizeHandler: () => void;
|
|
1079
|
+
|
|
1080
|
+
/** Pin index assigned to each left-pinned column / leading cell,
|
|
1081
|
+
* used to resolve its sticky `left`. Recomputed each render. */
|
|
1082
|
+
private pinIndexByColumn = new Map<ContentListColumn, number>();
|
|
1083
|
+
/** Pin index assigned to each right-pinned column, counted from
|
|
1084
|
+
* the right edge (0 = rightmost), used to resolve its sticky
|
|
1085
|
+
* `right`. Recomputed each render. */
|
|
1086
|
+
private rightPinIndexByColumn = new Map<ContentListColumn, number>();
|
|
1087
|
+
private checkPinIndex = -1;
|
|
1088
|
+
private iconPinIndex = -1;
|
|
1089
|
+
private lastPinIndex = -1;
|
|
1090
|
+
/** Right-pin index of the leftmost right-pinned column — the one
|
|
1091
|
+
* that carries the divider against the scrolling section. */
|
|
1092
|
+
private firstRightPinIndex = -1;
|
|
1093
|
+
/** Column index after which the slack-absorbing spacer cell is
|
|
1094
|
+
* rendered (the last pinned column), or -1 when nothing is
|
|
1095
|
+
* pinned. Extra table width pools in that spacer. */
|
|
1096
|
+
private spacerAfterIndex = -1;
|
|
1097
|
+
/** Whether the current items reserve a leading-icon column. */
|
|
1098
|
+
private reservesIcon = false;
|
|
633
1099
|
|
|
634
1100
|
constructor() {
|
|
635
1101
|
super();
|
|
636
|
-
this.debouncedFetch = debounce(() => this.fetchPage(), 250);
|
|
637
1102
|
}
|
|
638
1103
|
|
|
639
1104
|
public connectedCallback(): void {
|
|
@@ -645,6 +1110,40 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
645
1110
|
this.fetchPage();
|
|
646
1111
|
};
|
|
647
1112
|
window.addEventListener('popstate', this.popstateHandler);
|
|
1113
|
+
} else if (this.historyStateKey) {
|
|
1114
|
+
// Restore from the host SPA's history entry on mount so the
|
|
1115
|
+
// list resumes on whatever page it was on the last time the
|
|
1116
|
+
// user was here — works in tandem with the host's
|
|
1117
|
+
// `temba-history-change` listener (which pushes a new entry
|
|
1118
|
+
// for paging/sort/committed-search and replaces in place for
|
|
1119
|
+
// search-typing, per the event's `replace` flag). The
|
|
1120
|
+
// popstate handler covers in-list back/forward, where the
|
|
1121
|
+
// URL doesn't change so the SPA frame doesn't remount the
|
|
1122
|
+
// list — we re-read state from the active entry and re-fetch
|
|
1123
|
+
// ourselves. Cross-URL back navigation still goes through
|
|
1124
|
+
// the SPA frame and remounts a fresh list, which then reads
|
|
1125
|
+
// state on mount.
|
|
1126
|
+
this.readHistoryState();
|
|
1127
|
+
this.popstateHandler = () => {
|
|
1128
|
+
this.readHistoryState();
|
|
1129
|
+
const restore = this.restoreUrl;
|
|
1130
|
+
this.restoreUrl = '';
|
|
1131
|
+
this.fetchPage(restore || undefined);
|
|
1132
|
+
};
|
|
1133
|
+
window.addEventListener('popstate', this.popstateHandler);
|
|
1134
|
+
}
|
|
1135
|
+
// A viewport resize changes whether the table overflows, so the
|
|
1136
|
+
// right-edge scroll affordance has to be re-evaluated.
|
|
1137
|
+
this.resizeHandler = () => this.syncScrollAffordance();
|
|
1138
|
+
window.addEventListener('resize', this.resizeHandler);
|
|
1139
|
+
// Pinned columns now size to their content, so a late web-font
|
|
1140
|
+
// load shifts their widths — re-measure the sticky offsets once
|
|
1141
|
+
// fonts settle so the pinned cells don't drift out of alignment.
|
|
1142
|
+
if (document.fonts && document.fonts.ready) {
|
|
1143
|
+
document.fonts.ready.then(() => {
|
|
1144
|
+
this.measurePinOffsets();
|
|
1145
|
+
this.syncScrollAffordance();
|
|
1146
|
+
});
|
|
648
1147
|
}
|
|
649
1148
|
}
|
|
650
1149
|
|
|
@@ -652,8 +1151,17 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
652
1151
|
if (this.popstateHandler) {
|
|
653
1152
|
window.removeEventListener('popstate', this.popstateHandler);
|
|
654
1153
|
}
|
|
1154
|
+
if (this.resizeHandler) {
|
|
1155
|
+
window.removeEventListener('resize', this.resizeHandler);
|
|
1156
|
+
}
|
|
655
1157
|
if (this.pending) {
|
|
656
|
-
|
|
1158
|
+
// Null the pending pointer before aborting so fetchPage's
|
|
1159
|
+
// finally block — which gates cleanup on `this.pending ===
|
|
1160
|
+
// controller` — skips firing FetchComplete on a disconnected
|
|
1161
|
+
// component.
|
|
1162
|
+
const controller = this.pending;
|
|
1163
|
+
this.pending = null;
|
|
1164
|
+
controller.abort();
|
|
657
1165
|
}
|
|
658
1166
|
super.disconnectedCallback();
|
|
659
1167
|
}
|
|
@@ -663,11 +1171,23 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
663
1171
|
// Only watch endpoint and refreshKey here — both are typically
|
|
664
1172
|
// set externally and have no other handler that already fires a
|
|
665
1173
|
// fetch. Sort/page/search are mutated by internal handlers that
|
|
666
|
-
// call fetchPage
|
|
667
|
-
//
|
|
668
|
-
if (
|
|
669
|
-
|
|
1174
|
+
// call fetchPage themselves, so tracking them here would
|
|
1175
|
+
// double-fire the request.
|
|
1176
|
+
if (
|
|
1177
|
+
(changes.has('endpoint') || changes.has('refreshKey')) &&
|
|
1178
|
+
this.endpoint
|
|
1179
|
+
) {
|
|
1180
|
+
// If readHistoryState staged a restoreUrl, the first fetch
|
|
1181
|
+
// follows that URL so a cursor list lands on the saved slice.
|
|
1182
|
+
// Clear it so subsequent fetches use the live state.
|
|
1183
|
+
const restore = this.restoreUrl;
|
|
1184
|
+
this.restoreUrl = '';
|
|
1185
|
+
this.fetchPage(restore || undefined);
|
|
670
1186
|
}
|
|
1187
|
+
// Pinned-column offsets and the scroll affordances both depend
|
|
1188
|
+
// on the freshly-laid-out DOM, so settle them after each render.
|
|
1189
|
+
this.measurePinOffsets();
|
|
1190
|
+
this.syncScrollAffordance();
|
|
671
1191
|
}
|
|
672
1192
|
|
|
673
1193
|
/** Read sort/page/search from the URL on first load / popstate. */
|
|
@@ -675,15 +1195,32 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
675
1195
|
const params = new URLSearchParams(window.location.search);
|
|
676
1196
|
const k = (name: string) =>
|
|
677
1197
|
this.urlParamPrefix ? `${this.urlParamPrefix}_${name}` : name;
|
|
1198
|
+
const previousSearch = this.search;
|
|
678
1199
|
this.search = params.get(k('search')) || '';
|
|
679
1200
|
this.sort = params.get(k('sort')) || '';
|
|
680
1201
|
const pageParam = parseInt(params.get(k('page')) || '1', 10);
|
|
681
1202
|
this.page = isNaN(pageParam) || pageParam < 1 ? 1 : pageParam;
|
|
1203
|
+
// Reveal the search input when the URL carries an active query —
|
|
1204
|
+
// see readHistoryState for the equivalent treatment. The
|
|
1205
|
+
// close-on-empty branch only fires when the navigation actually
|
|
1206
|
+
// cleared a prior search; an unrelated popstate that arrives
|
|
1207
|
+
// while the user has the searchbar open and is mid-typing must
|
|
1208
|
+
// not slam their draft.
|
|
1209
|
+
if (this.search) {
|
|
1210
|
+
this.searchOpen = true;
|
|
1211
|
+
this.searchDraft = this.search;
|
|
1212
|
+
} else if (previousSearch) {
|
|
1213
|
+
this.searchOpen = false;
|
|
1214
|
+
this.searchDraft = '';
|
|
1215
|
+
}
|
|
682
1216
|
}
|
|
683
1217
|
|
|
684
|
-
/** Push current sort/page/search to the URL
|
|
685
|
-
* the
|
|
1218
|
+
/** Push current sort/page/search to the URL and/or bubble it up
|
|
1219
|
+
* to the host for stashing in history.state — call this on every
|
|
1220
|
+
* user-driven page/sort/search change. `replace` is true while the
|
|
1221
|
+
* user is typing in the search box (don't pollute history). */
|
|
686
1222
|
private writeUrlState(replace = false): void {
|
|
1223
|
+
this.bubbleHistoryState(replace);
|
|
687
1224
|
if (!this.urlState) return;
|
|
688
1225
|
const params = new URLSearchParams(window.location.search);
|
|
689
1226
|
const k = (name: string) =>
|
|
@@ -706,6 +1243,73 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
706
1243
|
}
|
|
707
1244
|
}
|
|
708
1245
|
|
|
1246
|
+
/** Read saved list state out of the host's history entry under
|
|
1247
|
+
* {@link historyStateKey}. Mirrors {@link readUrlState} but pulls
|
|
1248
|
+
* from `history.state` rather than the query string. The `url`
|
|
1249
|
+
* field, when present, is the cursor-or-page URL of the slice the
|
|
1250
|
+
* user was on — staged in {@link restoreUrl} so the very next
|
|
1251
|
+
* fetch follows it (the only way to land on a specific slice of a
|
|
1252
|
+
* cursor-paginated list, whose pages have no numeric identifier). */
|
|
1253
|
+
private readHistoryState(): void {
|
|
1254
|
+
const key = this.historyStateKey;
|
|
1255
|
+
if (!key) return;
|
|
1256
|
+
const stash = (window.history.state || {})[key] || {};
|
|
1257
|
+
const previousSearch = this.search;
|
|
1258
|
+
this.search = typeof stash.search === 'string' ? stash.search : '';
|
|
1259
|
+
this.sort = typeof stash.sort === 'string' ? stash.sort : '';
|
|
1260
|
+
const p = parseInt(stash.page, 10);
|
|
1261
|
+
this.page = isNaN(p) || p < 1 ? 1 : p;
|
|
1262
|
+
this.restoreUrl = typeof stash.url === 'string' ? stash.url : '';
|
|
1263
|
+
// A restored search needs visible affordance — open the search
|
|
1264
|
+
// bar and seed the draft so the user sees the active query and
|
|
1265
|
+
// can edit or clear it without having to click the search
|
|
1266
|
+
// toggle and discover the term was retained. Only auto-close on
|
|
1267
|
+
// empty when the navigation actually cleared a prior search, so
|
|
1268
|
+
// an unrelated popstate that arrives while the user is mid-
|
|
1269
|
+
// typing doesn't slam their draft.
|
|
1270
|
+
if (this.search) {
|
|
1271
|
+
this.searchOpen = true;
|
|
1272
|
+
this.searchDraft = this.search;
|
|
1273
|
+
} else if (previousSearch) {
|
|
1274
|
+
this.searchOpen = false;
|
|
1275
|
+
this.searchDraft = '';
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
/** Bubble the current page/sort/search/url up to the host so it
|
|
1280
|
+
* can stash them in the active history entry. The `url` field
|
|
1281
|
+
* carries `currentUrl` (the URL of the most recent successful
|
|
1282
|
+
* fetch) — page-mode lists can rebuild that from page/sort/search,
|
|
1283
|
+
* but cursor-mode lists rely on it to land on the exact slice the
|
|
1284
|
+
* user was on. `replace` tells the host whether this change should
|
|
1285
|
+
* create a new back-history entry (false — paging, sort,
|
|
1286
|
+
* committed search) or overwrite the current one (true — typing
|
|
1287
|
+
* in the search box, cursor-page snap-back). The component never
|
|
1288
|
+
* touches `history` itself in this mode — that keeps the host's
|
|
1289
|
+
* SPA navigation in charge of history mutations and lets multiple
|
|
1290
|
+
* lists on a page coexist under distinct {@link historyStateKey}s. */
|
|
1291
|
+
private bubbleHistoryState(replace: boolean): void {
|
|
1292
|
+
if (!this.historyStateKey) return;
|
|
1293
|
+
// For a page-mode list, page/sort/search are enough — the
|
|
1294
|
+
// initial fetch on restore rebuilds the request URL from them.
|
|
1295
|
+
// For a cursor list, page numbers are meaningless, so we also
|
|
1296
|
+
// stash the most recent cursor URL (set synchronously by
|
|
1297
|
+
// fetchPage(target)) so restore can land on the exact slice.
|
|
1298
|
+
const state: Record<string, any> = {
|
|
1299
|
+
page: this.page,
|
|
1300
|
+
sort: this.sort,
|
|
1301
|
+
search: this.search
|
|
1302
|
+
};
|
|
1303
|
+
if (this.cursorMode && this.currentUrl) {
|
|
1304
|
+
state.url = this.currentUrl;
|
|
1305
|
+
}
|
|
1306
|
+
this.fireCustomEvent(CustomEventType.HistoryChange, {
|
|
1307
|
+
key: this.historyStateKey,
|
|
1308
|
+
state,
|
|
1309
|
+
replace
|
|
1310
|
+
});
|
|
1311
|
+
}
|
|
1312
|
+
|
|
709
1313
|
/** Build the request URL by appending sort/search/page params to
|
|
710
1314
|
* the configured endpoint. */
|
|
711
1315
|
private buildRequestUrl(): string {
|
|
@@ -718,17 +1322,75 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
718
1322
|
return url.pathname + url.search;
|
|
719
1323
|
}
|
|
720
1324
|
|
|
721
|
-
|
|
1325
|
+
/** Tell a cursor list from a page-counted one by inspecting the
|
|
1326
|
+
* server's nav URLs. DRF CursorPagination always emits a `cursor=`
|
|
1327
|
+
* query param; PageNumberPagination uses `page=`. A response that
|
|
1328
|
+
* carries `count` alongside cursor URLs — e.g. a searched cursor
|
|
1329
|
+
* endpoint that returns a result tally for the UI indicator — must
|
|
1330
|
+
* still be navigated by following the cursor URLs, so we can't use
|
|
1331
|
+
* count presence alone. Falls back to the count-absent heuristic
|
|
1332
|
+
* for single-page responses where neither nav URL is populated. */
|
|
1333
|
+
private detectCursorMode(data: FetchResponse<T>): boolean {
|
|
1334
|
+
const hasCursor = (raw: string | undefined | null): boolean => {
|
|
1335
|
+
if (!raw) return false;
|
|
1336
|
+
try {
|
|
1337
|
+
return new URL(raw, window.location.origin).searchParams.has('cursor');
|
|
1338
|
+
} catch {
|
|
1339
|
+
return false;
|
|
1340
|
+
}
|
|
1341
|
+
};
|
|
1342
|
+
if (hasCursor(data.next) || hasCursor(data.previous)) return true;
|
|
1343
|
+
return data.count == null;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
/** Reduce a cursor `next` / `previous` URL — which the server
|
|
1347
|
+
* returns absolute — to a same-origin path+query for `getUrl`.
|
|
1348
|
+
* A cross-origin URL is rejected (returns '') so a malformed
|
|
1349
|
+
* response can't redirect the fetch off-site. */
|
|
1350
|
+
private toRequestUrl(raw: string): string {
|
|
1351
|
+
try {
|
|
1352
|
+
const url = new URL(raw, window.location.origin);
|
|
1353
|
+
if (url.origin !== window.location.origin) return '';
|
|
1354
|
+
return url.pathname + url.search;
|
|
1355
|
+
} catch {
|
|
1356
|
+
return '';
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
/** Fetch a page. With no argument this builds a fresh request from
|
|
1361
|
+
* the endpoint + current sort/search/page (resetting a cursor list
|
|
1362
|
+
* to its first page); pass an explicit `url` to follow a cursor or
|
|
1363
|
+
* to re-request {@link currentUrl}. */
|
|
1364
|
+
private async fetchPage(url?: string): Promise<void> {
|
|
722
1365
|
if (!this.endpoint) return;
|
|
723
1366
|
if (this.pending) this.pending.abort();
|
|
724
1367
|
const controller = new AbortController();
|
|
725
1368
|
this.pending = controller;
|
|
726
1369
|
this.loading = true;
|
|
1370
|
+
const requestUrl = url || this.buildRequestUrl();
|
|
1371
|
+
this.currentUrl = requestUrl;
|
|
727
1372
|
try {
|
|
728
|
-
const response = await getUrl(
|
|
1373
|
+
const response = await getUrl(requestUrl, controller);
|
|
729
1374
|
const data = (response.json || {}) as FetchResponse<T>;
|
|
730
1375
|
this.items = data.results || [];
|
|
1376
|
+
this.nextCursor = data.next ? this.toRequestUrl(data.next) : '';
|
|
1377
|
+
this.prevCursor = data.previous ? this.toRequestUrl(data.previous) : '';
|
|
1378
|
+
// Cursor mode is detected from the shape of next/previous,
|
|
1379
|
+
// not the absence of `count` — a cursor endpoint may include
|
|
1380
|
+
// `count` (e.g. during search) without switching to page-mode
|
|
1381
|
+
// navigation. See {@link detectCursorMode}.
|
|
1382
|
+
this.cursorMode = this.detectCursorMode(data);
|
|
1383
|
+
this.hasCount = data.count != null;
|
|
731
1384
|
this.total = data.count ?? this.items.length;
|
|
1385
|
+
// A cursor endpoint has no way to honor `?page=N` on first
|
|
1386
|
+
// load, so a hard refresh that lands with a stale synthetic
|
|
1387
|
+
// page param would leave the URL out of sync with what the
|
|
1388
|
+
// server actually returned (the first slice). Snap the
|
|
1389
|
+
// synthetic page back to 1 and rewrite the URL in place.
|
|
1390
|
+
if (this.cursorMode && !this.prevCursor && this.page !== 1) {
|
|
1391
|
+
this.page = 1;
|
|
1392
|
+
this.writeUrlState(true);
|
|
1393
|
+
}
|
|
732
1394
|
// drop any selected ids that aren't visible anymore — selection
|
|
733
1395
|
// is per-page, not cross-page, so users don't accidentally bulk
|
|
734
1396
|
// act on rows they can't see.
|
|
@@ -750,14 +1412,17 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
750
1412
|
if (this.pending === controller) {
|
|
751
1413
|
this.pending = null;
|
|
752
1414
|
this.loading = false;
|
|
1415
|
+
this.searching = false;
|
|
753
1416
|
this.fireCustomEvent(CustomEventType.FetchComplete);
|
|
754
1417
|
}
|
|
755
1418
|
}
|
|
756
1419
|
}
|
|
757
1420
|
|
|
758
|
-
/** Public API — programmatic refresh, mirrors `refreshKey` bump.
|
|
1421
|
+
/** Public API — programmatic refresh, mirrors `refreshKey` bump.
|
|
1422
|
+
* Re-requests the current page (cursor lists included) rather than
|
|
1423
|
+
* resetting to the first. */
|
|
759
1424
|
public refresh(): void {
|
|
760
|
-
this.fetchPage();
|
|
1425
|
+
this.fetchPage(this.currentUrl || undefined);
|
|
761
1426
|
}
|
|
762
1427
|
|
|
763
1428
|
/** Identity helper — uses the `valueKey` to pull a stable id from
|
|
@@ -784,11 +1449,42 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
784
1449
|
return null;
|
|
785
1450
|
}
|
|
786
1451
|
|
|
1452
|
+
/** Update the uncommitted input value as the user types. The
|
|
1453
|
+
* fetch is deferred until the user submits (Enter / search-icon
|
|
1454
|
+
* click) so we don't pound the server on every keystroke. */
|
|
787
1455
|
private handleSearchInput(event: any): void {
|
|
788
|
-
this.
|
|
1456
|
+
this.searchDraft = event.target.value || '';
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
/** Commit on Enter; let other keys through. Escape clears the
|
|
1460
|
+
* draft (so the user can bail without firing a search). */
|
|
1461
|
+
private handleSearchKey(event: KeyboardEvent): void {
|
|
1462
|
+
if (event.key === 'Enter') {
|
|
1463
|
+
event.preventDefault();
|
|
1464
|
+
this.commitSearch();
|
|
1465
|
+
} else if (event.key === 'Escape') {
|
|
1466
|
+
event.preventDefault();
|
|
1467
|
+
if (this.searchDraft && this.searchDraft !== this.search) {
|
|
1468
|
+
// Discard the in-progress draft, leaving the committed
|
|
1469
|
+
// search alone — a quick way out without altering results.
|
|
1470
|
+
this.searchDraft = this.search;
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
/** Promote the input's draft to the committed search and fetch.
|
|
1476
|
+
* fetchPage runs first so currentUrl reflects the new search before
|
|
1477
|
+
* the state bubbles — bubbling first would stash the pre-search URL
|
|
1478
|
+
* and break history restoration on the way back. Pushes a new
|
|
1479
|
+
* history entry so the prior search (or unsearched view) is one
|
|
1480
|
+
* "back" away, matching paging and sort semantics. */
|
|
1481
|
+
private commitSearch(): void {
|
|
1482
|
+
if (this.search === this.searchDraft) return;
|
|
1483
|
+
this.search = this.searchDraft;
|
|
789
1484
|
this.page = 1;
|
|
790
|
-
this.
|
|
791
|
-
this.
|
|
1485
|
+
this.searching = true;
|
|
1486
|
+
this.fetchPage();
|
|
1487
|
+
this.writeUrlState();
|
|
792
1488
|
}
|
|
793
1489
|
|
|
794
1490
|
private handleSortClick(column: ContentListColumn): void {
|
|
@@ -801,8 +1497,10 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
801
1497
|
this.sort = column.key;
|
|
802
1498
|
}
|
|
803
1499
|
this.page = 1;
|
|
804
|
-
|
|
1500
|
+
// fetchPage first so currentUrl reflects the new sort before the
|
|
1501
|
+
// state bubbles — see commitSearch for the full reasoning.
|
|
805
1502
|
this.fetchPage();
|
|
1503
|
+
this.writeUrlState();
|
|
806
1504
|
}
|
|
807
1505
|
|
|
808
1506
|
private handleRowClick(item: T, event: MouseEvent): void {
|
|
@@ -824,7 +1522,7 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
824
1522
|
* permit same-origin navigation — absolute URLs must match the
|
|
825
1523
|
* current origin, relative URLs must be path-only (starting with
|
|
826
1524
|
* `/` and not `//`, which would be protocol-relative). */
|
|
827
|
-
|
|
1525
|
+
protected isSafeHref(href: string): boolean {
|
|
828
1526
|
if (typeof href !== 'string' || href.length === 0) return false;
|
|
829
1527
|
// Reject protocol-relative URLs ("//evil.com/...") and any
|
|
830
1528
|
// scheme-prefixed URL that isn't same-origin.
|
|
@@ -859,45 +1557,108 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
859
1557
|
});
|
|
860
1558
|
}
|
|
861
1559
|
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
1560
|
+
/** Run a non-label bulk action. With an `actionEndpoint` set, POST
|
|
1561
|
+
* the action server-side (form-encoded to match smartmin's
|
|
1562
|
+
* `BulkActionMixin`), then re-fetch the current page so the user
|
|
1563
|
+
* stays where they were — rather than letting the host trigger a
|
|
1564
|
+
* full SPA page replacement that drops them back to page 1. With
|
|
1565
|
+
* no `actionEndpoint`, just fire the event for the host to
|
|
1566
|
+
* handle. Destructive actions can carry a `confirm` string for a
|
|
1567
|
+
* window.confirm() prompt (text comes from the host so it can be
|
|
1568
|
+
* localized). The event fires after the POST/refresh so a host
|
|
1569
|
+
* sidebar can refresh counts when notified. */
|
|
1570
|
+
private async handleBulkAction(action: ContentListBulkAction): Promise<void> {
|
|
1571
|
+
if (action.confirm && !window.confirm(action.confirm)) return;
|
|
1572
|
+
|
|
1573
|
+
const ids = Array.from(this.selectedIds);
|
|
1574
|
+
|
|
1575
|
+
if (this.actionEndpoint) {
|
|
1576
|
+
const params = new URLSearchParams();
|
|
1577
|
+
params.append('action', action.key);
|
|
1578
|
+
ids.forEach((id) => params.append('objects', id));
|
|
1579
|
+
try {
|
|
1580
|
+
await postUrl(this.actionEndpoint, params);
|
|
1581
|
+
// Re-request the current page so a filtered view (e.g.
|
|
1582
|
+
// archive removes rows from inbox) drops the acted-on rows,
|
|
1583
|
+
// staying on the user's page rather than resetting to one.
|
|
1584
|
+
await this.fetchPage(this.currentUrl || undefined);
|
|
1585
|
+
// Drop selection for any ids the server filtered out of the
|
|
1586
|
+
// refreshed view; survivors stay selected.
|
|
1587
|
+
this.recheckSelection(ids);
|
|
1588
|
+
// Only fire after the server confirms — a failed POST
|
|
1589
|
+
// shouldn't trigger consumers to refresh based on a
|
|
1590
|
+
// non-event.
|
|
1591
|
+
this.fireCustomEvent(CustomEventType.BulkAction, {
|
|
1592
|
+
action: action.key,
|
|
1593
|
+
ids
|
|
1594
|
+
});
|
|
1595
|
+
} catch (err) {
|
|
1596
|
+
// eslint-disable-next-line no-console
|
|
1597
|
+
console.error('bulk action POST failed', err);
|
|
1598
|
+
}
|
|
1599
|
+
} else {
|
|
1600
|
+
// No server round-trip — leave the action entirely up to the
|
|
1601
|
+
// host and let it know.
|
|
1602
|
+
this.fireCustomEvent(CustomEventType.BulkAction, {
|
|
1603
|
+
action: action.key,
|
|
1604
|
+
ids
|
|
1605
|
+
});
|
|
1606
|
+
}
|
|
867
1607
|
}
|
|
868
1608
|
|
|
869
1609
|
private handlePage(delta: number): void {
|
|
1610
|
+
// A cursor list has no page numbers — step by following the
|
|
1611
|
+
// opaque next/previous URL the last response handed back. Call
|
|
1612
|
+
// fetchPage first so currentUrl is updated synchronously, then
|
|
1613
|
+
// bubble state so the saved URL points at the new view. The
|
|
1614
|
+
// synthetic page number is bumped only to give each history entry
|
|
1615
|
+
// a distinct URL; the cursor URL stashed in history.state is what
|
|
1616
|
+
// actually drives restoration.
|
|
1617
|
+
if (this.cursorMode) {
|
|
1618
|
+
const target = delta > 0 ? this.nextCursor : this.prevCursor;
|
|
1619
|
+
if (target) {
|
|
1620
|
+
this.page = Math.max(1, this.page + delta);
|
|
1621
|
+
this.fetchPage(target);
|
|
1622
|
+
this.writeUrlState();
|
|
1623
|
+
}
|
|
1624
|
+
return;
|
|
1625
|
+
}
|
|
870
1626
|
const lastPage = Math.max(1, Math.ceil(this.total / this.pageSize));
|
|
871
1627
|
const next = Math.min(lastPage, Math.max(1, this.page + delta));
|
|
872
1628
|
if (next !== this.page) {
|
|
873
1629
|
this.page = next;
|
|
874
|
-
this.writeUrlState();
|
|
875
1630
|
this.fetchPage();
|
|
1631
|
+
this.writeUrlState();
|
|
876
1632
|
}
|
|
877
1633
|
}
|
|
878
1634
|
|
|
879
1635
|
private renderTitlebar(): TemplateResult {
|
|
880
1636
|
const selectionCount = this.selectedIds.size;
|
|
881
1637
|
const bulkVisible = selectionCount > 0 && this.bulkActions.length > 0;
|
|
1638
|
+
const hasSubtitle =
|
|
1639
|
+
this.subtitle || this.querySelector('[slot="subtitle"]');
|
|
1640
|
+
const resultCount = `${this.total} ${this.total === 1 ? 'result' : 'results'}`;
|
|
1641
|
+
// The header — title + content menu — is temba-page-header. The
|
|
1642
|
+
// list forwards its own title/subtitle slots into it and slots
|
|
1643
|
+
// its search / bulk-action controls into the header's actions
|
|
1644
|
+
// area, so the list and a plain page share one header.
|
|
882
1645
|
return html`
|
|
883
|
-
<
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
: null}
|
|
893
|
-
</div>
|
|
894
|
-
<div class="actions">
|
|
1646
|
+
<temba-page-header
|
|
1647
|
+
content-menu-endpoint=${this.contentMenuEndpoint}
|
|
1648
|
+
?hide-menu=${bulkVisible}
|
|
1649
|
+
>
|
|
1650
|
+
<slot name="title" slot="title">${this.listTitle}</slot>
|
|
1651
|
+
${hasSubtitle
|
|
1652
|
+
? html`<slot name="subtitle" slot="subtitle">${this.subtitle}</slot>`
|
|
1653
|
+
: null}
|
|
1654
|
+
<div slot="actions" class="header-actions">
|
|
895
1655
|
${bulkVisible
|
|
896
1656
|
? html`
|
|
897
1657
|
<span class="bulk-count">${selectionCount} selected</span>
|
|
898
1658
|
${this.bulkActions.map((a) => this.renderBulkAction(a))}
|
|
899
1659
|
`
|
|
900
1660
|
: html`
|
|
1661
|
+
${this.renderPager()}
|
|
901
1662
|
${this.searchable && !this.searchOpen
|
|
902
1663
|
? html`
|
|
903
1664
|
<span class="action" @click=${() => this.toggleSearch()}>
|
|
@@ -912,23 +1673,36 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
912
1673
|
<slot name="actions"></slot>
|
|
913
1674
|
`}
|
|
914
1675
|
</div>
|
|
915
|
-
</
|
|
1676
|
+
</temba-page-header>
|
|
916
1677
|
${this.searchable && this.searchOpen
|
|
917
1678
|
? html`
|
|
918
1679
|
<div class="searchbar">
|
|
919
|
-
<
|
|
1680
|
+
<span
|
|
1681
|
+
class="submit"
|
|
1682
|
+
title="Search"
|
|
1683
|
+
aria-label="Search"
|
|
1684
|
+
@click=${() => this.commitSearch()}
|
|
1685
|
+
>
|
|
1686
|
+
<temba-icon name=${Icon.search} size="0.95"></temba-icon>
|
|
1687
|
+
</span>
|
|
920
1688
|
<input
|
|
921
1689
|
type="text"
|
|
922
1690
|
placeholder=${this.searchPlaceholder}
|
|
923
|
-
.value=${this.
|
|
1691
|
+
.value=${this.searchDraft}
|
|
924
1692
|
@input=${this.handleSearchInput}
|
|
925
|
-
|
|
1693
|
+
@keydown=${this.handleSearchKey}
|
|
926
1694
|
/>
|
|
927
|
-
${this.search
|
|
928
|
-
? html`<span class="
|
|
929
|
-
<temba-icon name=${Icon.close} size="0.85"></temba-icon>
|
|
930
|
-
</span>`
|
|
1695
|
+
${this.search && this.hasCount && !this.loading
|
|
1696
|
+
? html`<span class="result-count">${resultCount}</span>`
|
|
931
1697
|
: null}
|
|
1698
|
+
<span
|
|
1699
|
+
class="clear"
|
|
1700
|
+
title="Close search"
|
|
1701
|
+
aria-label="Close search"
|
|
1702
|
+
@click=${() => this.toggleSearch()}
|
|
1703
|
+
>
|
|
1704
|
+
<temba-icon name=${Icon.close} size="0.85"></temba-icon>
|
|
1705
|
+
</span>
|
|
932
1706
|
</div>
|
|
933
1707
|
`
|
|
934
1708
|
: null}
|
|
@@ -957,6 +1731,7 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
957
1731
|
return html`
|
|
958
1732
|
<temba-dropdown
|
|
959
1733
|
class="label-dropdown"
|
|
1734
|
+
data-action-key=${action.key}
|
|
960
1735
|
@temba-opened=${() => this.handleLabelDropdownOpened(action)}
|
|
961
1736
|
>
|
|
962
1737
|
<span
|
|
@@ -971,13 +1746,16 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
971
1746
|
<div slot="dropdown" class="label-menu">
|
|
972
1747
|
${labels.length === 0
|
|
973
1748
|
? html`<div class="label-menu-empty">Loading…</div>`
|
|
974
|
-
: labels.map((label) => this.renderLabelOption(label))}
|
|
1749
|
+
: labels.map((label) => this.renderLabelOption(label, action))}
|
|
975
1750
|
</div>
|
|
976
1751
|
</temba-dropdown>
|
|
977
1752
|
`;
|
|
978
1753
|
}
|
|
979
1754
|
|
|
980
|
-
private renderLabelOption(
|
|
1755
|
+
private renderLabelOption(
|
|
1756
|
+
label: any,
|
|
1757
|
+
action: ContentListBulkAction
|
|
1758
|
+
): TemplateResult {
|
|
981
1759
|
const state = this.computeLabelState(label.uuid);
|
|
982
1760
|
const isPending = this.pendingLabel === label.uuid;
|
|
983
1761
|
const isBlocked = this.pendingLabel !== null && !isPending;
|
|
@@ -989,7 +1767,7 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
989
1767
|
@click=${(e: MouseEvent) => {
|
|
990
1768
|
e.stopPropagation();
|
|
991
1769
|
if (this.pendingLabel !== null) return;
|
|
992
|
-
this.toggleLabel(label, state);
|
|
1770
|
+
this.toggleLabel(label, state, action.key);
|
|
993
1771
|
}}
|
|
994
1772
|
>
|
|
995
1773
|
<temba-checkbox
|
|
@@ -1049,47 +1827,78 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
1049
1827
|
* filtered result decide which rows stay. We POST first, then
|
|
1050
1828
|
* refresh once the server confirms. The `pendingLabel` state
|
|
1051
1829
|
* blocks further toggles until the round-trip completes. */
|
|
1052
|
-
private async toggleLabel(
|
|
1830
|
+
private async toggleLabel(
|
|
1831
|
+
label: any,
|
|
1832
|
+
state: string,
|
|
1833
|
+
actionKey: string
|
|
1834
|
+
): Promise<void> {
|
|
1053
1835
|
if (this.pendingLabel !== null) return;
|
|
1054
1836
|
const add = state !== 'all';
|
|
1055
1837
|
const originalSelectedIds = Array.from(this.selectedIds);
|
|
1056
1838
|
this.pendingLabel = label.uuid;
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
//
|
|
1060
|
-
//
|
|
1061
|
-
// is
|
|
1062
|
-
//
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
//
|
|
1071
|
-
//
|
|
1072
|
-
|
|
1073
|
-
//
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1839
|
+
try {
|
|
1840
|
+
// Close just the dropdown for the action that fired — other
|
|
1841
|
+
// label dropdowns in the toolbar (e.g. a separate "labels"
|
|
1842
|
+
// grouping) stay in whatever state the user left them.
|
|
1843
|
+
// `actionKey` is a consumer-supplied public-API field, so
|
|
1844
|
+
// CSS.escape() keeps a key containing `"` or `\` from throwing
|
|
1845
|
+
// SyntaxError (and leaving the dropdown stuck open).
|
|
1846
|
+
const dropdown = this.shadowRoot?.querySelector(
|
|
1847
|
+
`.label-dropdown[data-action-key="${CSS.escape(actionKey)}"]`
|
|
1848
|
+
) as Dropdown | null;
|
|
1849
|
+
if (dropdown) dropdown.open = false;
|
|
1850
|
+
|
|
1851
|
+
if (this.actionEndpoint) {
|
|
1852
|
+
// application/x-www-form-urlencoded matches what Django's
|
|
1853
|
+
// smartmin `BulkActionMixin` reads from `request.POST`, and
|
|
1854
|
+
// is trivial to parse server-side (URLSearchParams) without
|
|
1855
|
+
// pulling in a multipart parser for the demo mock.
|
|
1856
|
+
const params = new URLSearchParams();
|
|
1857
|
+
params.append('action', 'label');
|
|
1858
|
+
params.append('label', label.uuid);
|
|
1859
|
+
if (!add) params.append('add', 'false');
|
|
1860
|
+
originalSelectedIds.forEach((id) => params.append('objects', id));
|
|
1861
|
+
try {
|
|
1862
|
+
await postUrl(this.actionEndpoint, params);
|
|
1863
|
+
// Re-fetch the current page so a filtered view (e.g. a
|
|
1864
|
+
// label-filter) drops rows that no longer match — staying on
|
|
1865
|
+
// the page being acted on rather than resetting to the first.
|
|
1866
|
+
await this.fetchPage(this.currentUrl || undefined);
|
|
1867
|
+
// Re-check the ids we were operating on. Items that survived
|
|
1868
|
+
// the refresh stay selected; items the server filtered out
|
|
1869
|
+
// (label removed → no longer matches the view) are absent
|
|
1870
|
+
// from `this.items` and won't be re-selected. Mirrors
|
|
1871
|
+
// rapidpro's `recheckIds()` after a `spaPost`.
|
|
1872
|
+
this.recheckSelection(originalSelectedIds);
|
|
1873
|
+
// Only fire after the server confirms — a failed POST
|
|
1874
|
+
// shouldn't tell consumers (e.g. a sidebar refreshing
|
|
1875
|
+
// counts) that the label actually changed.
|
|
1876
|
+
this.fireCustomEvent(CustomEventType.BulkAction, {
|
|
1877
|
+
action: 'label',
|
|
1878
|
+
ids: originalSelectedIds,
|
|
1879
|
+
label: label.uuid,
|
|
1880
|
+
add
|
|
1881
|
+
});
|
|
1882
|
+
} catch (err) {
|
|
1883
|
+
// eslint-disable-next-line no-console
|
|
1884
|
+
console.error('label toggle POST failed', err);
|
|
1885
|
+
}
|
|
1886
|
+
} else {
|
|
1887
|
+
// No server round-trip — the host is fully responsible for the
|
|
1888
|
+
// action, so fire so it can react.
|
|
1889
|
+
this.fireCustomEvent(CustomEventType.BulkAction, {
|
|
1890
|
+
action: 'label',
|
|
1891
|
+
ids: originalSelectedIds,
|
|
1892
|
+
label: label.uuid,
|
|
1893
|
+
add
|
|
1894
|
+
});
|
|
1082
1895
|
}
|
|
1896
|
+
} finally {
|
|
1897
|
+
// Always release the toggle gate, even if an early return or a
|
|
1898
|
+
// throw from a future edit short-circuits the round-trip — the
|
|
1899
|
+
// dropdown's other rows must never get permanently wedged.
|
|
1900
|
+
this.pendingLabel = null;
|
|
1083
1901
|
}
|
|
1084
|
-
|
|
1085
|
-
this.pendingLabel = null;
|
|
1086
|
-
|
|
1087
|
-
this.fireCustomEvent(CustomEventType.BulkAction, {
|
|
1088
|
-
action: 'label',
|
|
1089
|
-
ids: originalSelectedIds,
|
|
1090
|
-
label: label.uuid,
|
|
1091
|
-
add
|
|
1092
|
-
});
|
|
1093
1902
|
}
|
|
1094
1903
|
|
|
1095
1904
|
/** Re-apply a selection set against the current `items`. Used
|
|
@@ -1102,16 +1911,45 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
1102
1911
|
|
|
1103
1912
|
private toggleSearch(): void {
|
|
1104
1913
|
this.searchOpen = !this.searchOpen;
|
|
1105
|
-
if (
|
|
1914
|
+
if (this.searchOpen) {
|
|
1915
|
+
// Reopen with the committed query in the input so the user
|
|
1916
|
+
// can edit it rather than starting over.
|
|
1917
|
+
this.searchDraft = this.search;
|
|
1918
|
+
this.updateComplete.then(() => {
|
|
1919
|
+
const input = this.shadowRoot?.querySelector(
|
|
1920
|
+
'.searchbar input'
|
|
1921
|
+
) as HTMLInputElement | null;
|
|
1922
|
+
input?.focus();
|
|
1923
|
+
});
|
|
1924
|
+
} else if (this.search) {
|
|
1925
|
+
// Closing while a search is active is the same as clearing
|
|
1926
|
+
// it — keeps the toolbar from misleading once the input is
|
|
1927
|
+
// gone (no clear-X to signal the active filter).
|
|
1106
1928
|
this.clearSearch();
|
|
1929
|
+
} else {
|
|
1930
|
+
// No committed search, but a draft may have been typed; toss
|
|
1931
|
+
// it so reopening starts clean.
|
|
1932
|
+
this.searchDraft = '';
|
|
1107
1933
|
}
|
|
1108
1934
|
}
|
|
1109
1935
|
|
|
1110
1936
|
private clearSearch(): void {
|
|
1937
|
+
this.searchDraft = '';
|
|
1938
|
+
if (!this.search) return;
|
|
1111
1939
|
this.search = '';
|
|
1112
1940
|
this.page = 1;
|
|
1113
|
-
this
|
|
1941
|
+
// fetchPage's `finally` will clear this once the kicked-off
|
|
1942
|
+
// request settles, but doing it synchronously here is a UX
|
|
1943
|
+
// optimization: "Searching…" disappears the instant the user
|
|
1944
|
+
// clears, rather than flickering until the in-flight request
|
|
1945
|
+
// resolves.
|
|
1946
|
+
this.searching = false;
|
|
1947
|
+
// fetchPage first so currentUrl reflects the cleared search before
|
|
1948
|
+
// the state bubbles — see commitSearch for the full reasoning.
|
|
1949
|
+
// Pushes a new entry so the cleared-search view is its own back
|
|
1950
|
+
// step, paired with commitSearch.
|
|
1114
1951
|
this.fetchPage();
|
|
1952
|
+
this.writeUrlState();
|
|
1115
1953
|
}
|
|
1116
1954
|
|
|
1117
1955
|
/** Render a status pill — convenience for subclasses. The
|
|
@@ -1129,92 +1967,306 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
1129
1967
|
return null;
|
|
1130
1968
|
}
|
|
1131
1969
|
|
|
1970
|
+
/** Whether a column is pinned against the left edge. */
|
|
1971
|
+
private isLeftPinned(column: ContentListColumn): boolean {
|
|
1972
|
+
return column.pinned === true || column.pinned === 'left';
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
/** Whether a column is pinned against the right edge. */
|
|
1976
|
+
private isRightPinned(column: ContentListColumn): boolean {
|
|
1977
|
+
return column.pinned === 'right';
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
/** Recompute which leading cells + columns are pinned and assign
|
|
1981
|
+
* each a sticky "pin index". Called at the top of render() so the
|
|
1982
|
+
* header and rows agree. Left-pinned columns are expected to be
|
|
1983
|
+
* contiguous from the first column (the leading checkbox/icon
|
|
1984
|
+
* cells pin alongside them so identity stays anchored); right-
|
|
1985
|
+
* pinned columns contiguous to the last. */
|
|
1986
|
+
private computePinLayout(): void {
|
|
1987
|
+
// Reserve an empty leading-icon column when any row would carry
|
|
1988
|
+
// an icon — probe a representative row, then skip the icon
|
|
1989
|
+
// per-row if that row's own getRowIcon returns null.
|
|
1990
|
+
this.reservesIcon =
|
|
1991
|
+
this.items.length > 0 && this.getRowIcon(this.items[0]) !== null;
|
|
1992
|
+
this.pinIndexByColumn = new Map();
|
|
1993
|
+
this.rightPinIndexByColumn = new Map();
|
|
1994
|
+
this.checkPinIndex = -1;
|
|
1995
|
+
this.iconPinIndex = -1;
|
|
1996
|
+
this.lastPinIndex = -1;
|
|
1997
|
+
this.firstRightPinIndex = -1;
|
|
1998
|
+
this.spacerAfterIndex = -1;
|
|
1999
|
+
|
|
2000
|
+
// Right-pinned columns are contiguous at the end; walk inward
|
|
2001
|
+
// from the last column, numbering 0 = rightmost.
|
|
2002
|
+
let ridx = 0;
|
|
2003
|
+
for (let i = this.columns.length - 1; i >= 0; i--) {
|
|
2004
|
+
if (!this.isRightPinned(this.columns[i])) break;
|
|
2005
|
+
this.rightPinIndexByColumn.set(this.columns[i], ridx++);
|
|
2006
|
+
}
|
|
2007
|
+
this.firstRightPinIndex = ridx - 1;
|
|
2008
|
+
|
|
2009
|
+
// Left-pinned columns + the leading checkbox/icon cells.
|
|
2010
|
+
const leftPinnedCount = this.columns.filter((c) =>
|
|
2011
|
+
this.isLeftPinned(c)
|
|
2012
|
+
).length;
|
|
2013
|
+
if (leftPinnedCount === 0) return;
|
|
2014
|
+
let idx = 0;
|
|
2015
|
+
if (this.hasCheckboxes) this.checkPinIndex = idx++;
|
|
2016
|
+
if (this.reservesIcon) this.iconPinIndex = idx++;
|
|
2017
|
+
this.columns.forEach((c) => {
|
|
2018
|
+
if (this.isLeftPinned(c)) this.pinIndexByColumn.set(c, idx++);
|
|
2019
|
+
});
|
|
2020
|
+
this.lastPinIndex = idx - 1;
|
|
2021
|
+
// Left-pinned columns are contiguous from the start, so the last
|
|
2022
|
+
// one sits at column index leftPinnedCount - 1; the spacer
|
|
2023
|
+
// follows it — unless a `grow` column is present, in which case
|
|
2024
|
+
// that column already pools the slack and a spacer would only
|
|
2025
|
+
// split it.
|
|
2026
|
+
this.spacerAfterIndex = this.columns.some((c) => c.grow)
|
|
2027
|
+
? -1
|
|
2028
|
+
: leftPinnedCount - 1;
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
/** `pinned` (+ `pin-last` for the rightmost left-pinned cell)
|
|
2032
|
+
* class string for a left-pinned leading cell at the given pin
|
|
2033
|
+
* index, or '' when unpinned. */
|
|
2034
|
+
private pinClass(index: number): string {
|
|
2035
|
+
if (index < 0) return '';
|
|
2036
|
+
return index === this.lastPinIndex ? 'pinned pin-last' : 'pinned';
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
/** Sticky `left` for a left-pinned cell — resolved from a per-
|
|
2040
|
+
* index CSS var that {@link measurePinOffsets} sets from the real
|
|
2041
|
+
* header cell widths after each render. */
|
|
2042
|
+
private pinStyle(index: number): string {
|
|
2043
|
+
return index < 0 ? '' : `left: var(--cl-pin-${index}, 0px);`;
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
/** Pin class string for a column cell (header or body) — handles
|
|
2047
|
+
* both edges: `pin-last` marks the inboard edge of the left group,
|
|
2048
|
+
* `pin-first` the inboard edge of the right group. */
|
|
2049
|
+
private columnPinClass(column: ContentListColumn): string {
|
|
2050
|
+
const left = this.pinIndexByColumn.get(column);
|
|
2051
|
+
if (left != null) return this.pinClass(left);
|
|
2052
|
+
const right = this.rightPinIndexByColumn.get(column);
|
|
2053
|
+
if (right != null) {
|
|
2054
|
+
return right === this.firstRightPinIndex
|
|
2055
|
+
? 'pinned pin-right pin-first'
|
|
2056
|
+
: 'pinned pin-right';
|
|
2057
|
+
}
|
|
2058
|
+
return '';
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
/** Sticky `left`/`right` style for a column cell, resolved from
|
|
2062
|
+
* the per-index CSS vars {@link measurePinOffsets} publishes. */
|
|
2063
|
+
private columnPinStyle(column: ContentListColumn): string {
|
|
2064
|
+
const left = this.pinIndexByColumn.get(column);
|
|
2065
|
+
if (left != null) return `left: var(--cl-pin-${left}, 0px);`;
|
|
2066
|
+
const right = this.rightPinIndexByColumn.get(column);
|
|
2067
|
+
if (right != null) return `right: var(--cl-rpin-${right}, 0px);`;
|
|
2068
|
+
return '';
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
/** Width contract for a column's inner wrapper — a hard `width`
|
|
2072
|
+
* when set, otherwise optional min/max bounds. With neither bound
|
|
2073
|
+
* the column simply sizes to its content (header label or widest
|
|
2074
|
+
* value) via the table's auto layout. */
|
|
2075
|
+
private cellWidthStyle(column: ContentListColumn): string {
|
|
2076
|
+
// Under fixed layout the column widths are set on the cells
|
|
2077
|
+
// themselves (see {@link renderHeaderCell}); the inner wrapper
|
|
2078
|
+
// just fills its cell and ellipsis-truncates against it.
|
|
2079
|
+
if (this.fixedLayout) return '';
|
|
2080
|
+
if (column.width) return `width: ${column.width};`;
|
|
2081
|
+
const parts: string[] = [];
|
|
2082
|
+
if (column.minWidth) parts.push(`min-width: ${column.minWidth};`);
|
|
2083
|
+
// A grow column drops the upper cap so it can stretch with the
|
|
2084
|
+
// table; every other column caps its content-driven width.
|
|
2085
|
+
if (!column.grow) parts.push(`max-width: ${column.maxWidth || '320px'};`);
|
|
2086
|
+
return parts.join(' ');
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
/** Column count for the empty/loading row's colspan — includes
|
|
2090
|
+
* the leading cells and the slack spacer when present. */
|
|
2091
|
+
private colSpan(): number {
|
|
2092
|
+
return (
|
|
2093
|
+
(this.hasCheckboxes ? 1 : 0) +
|
|
2094
|
+
(this.reservesIcon ? 1 : 0) +
|
|
2095
|
+
(this.spacerAfterIndex >= 0 ? 1 : 0) +
|
|
2096
|
+
this.columns.length
|
|
2097
|
+
);
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
/** Measure the header's pinned cells and publish a cumulative
|
|
2101
|
+
* `left` offset per pin index as a CSS var on the host. Pinned
|
|
2102
|
+
* cells (header + body) read these via {@link pinStyle}. Pinned
|
|
2103
|
+
* columns size to content, so this re-runs after every render. */
|
|
2104
|
+
private measurePinOffsets(): void {
|
|
2105
|
+
const headRow = this.shadowRoot?.querySelector('tr.header');
|
|
2106
|
+
if (!headRow) return;
|
|
2107
|
+
const cells = Array.from(headRow.children) as HTMLElement[];
|
|
2108
|
+
// Left group — cumulative `left` offset, walking from the start.
|
|
2109
|
+
let offset = 0;
|
|
2110
|
+
let idx = 0;
|
|
2111
|
+
for (const cell of cells) {
|
|
2112
|
+
if (!cell.classList.contains('pinned')) break;
|
|
2113
|
+
if (cell.classList.contains('pin-right')) break;
|
|
2114
|
+
this.style.setProperty(`--cl-pin-${idx}`, `${offset}px`);
|
|
2115
|
+
offset += cell.offsetWidth;
|
|
2116
|
+
idx++;
|
|
2117
|
+
}
|
|
2118
|
+
// Right group — cumulative `right` offset, walking from the end.
|
|
2119
|
+
let roffset = 0;
|
|
2120
|
+
let ridx = 0;
|
|
2121
|
+
for (let i = cells.length - 1; i >= 0; i--) {
|
|
2122
|
+
if (!cells[i].classList.contains('pin-right')) break;
|
|
2123
|
+
this.style.setProperty(`--cl-rpin-${ridx}`, `${roffset}px`);
|
|
2124
|
+
roffset += cells[i].offsetWidth;
|
|
2125
|
+
ridx++;
|
|
2126
|
+
}
|
|
2127
|
+
// Total width of the right-pinned group — the scroll gradient
|
|
2128
|
+
// is inset by this so it lands just left of the frozen columns.
|
|
2129
|
+
this.style.setProperty('--cl-rpin-total', `${roffset}px`);
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
/** Refresh the horizontal-scroll affordances — whether the table
|
|
2133
|
+
* overflows at all (`overflowing`, which gates the pinned-column
|
|
2134
|
+
* tint + divider), the pinned-column divider shadow (table
|
|
2135
|
+
* scrolled off its start) and the right-edge fade (more table
|
|
2136
|
+
* hidden to the right). These are purely presentational, so the
|
|
2137
|
+
* classes are toggled straight on the frame rather than through
|
|
2138
|
+
* reactive state — that keeps a scroll (or a post-render
|
|
2139
|
+
* re-measure) from scheduling another render. */
|
|
2140
|
+
private syncScrollAffordance(): void {
|
|
2141
|
+
const frame = this.shadowRoot?.querySelector(
|
|
2142
|
+
'.table-frame'
|
|
2143
|
+
) as HTMLElement | null;
|
|
2144
|
+
const scroller = this.shadowRoot?.querySelector(
|
|
2145
|
+
'.table-scroll'
|
|
2146
|
+
) as HTMLElement | null;
|
|
2147
|
+
if (!frame || !scroller) return;
|
|
2148
|
+
const maxScroll = scroller.scrollWidth - scroller.clientWidth;
|
|
2149
|
+
// Below a 1px slack the table fits and there is nothing to
|
|
2150
|
+
// scroll — the pinned columns then read as plain columns.
|
|
2151
|
+
frame.classList.toggle('overflowing', maxScroll > 1);
|
|
2152
|
+
frame.classList.toggle('scrolled', scroller.scrollLeft > 1);
|
|
2153
|
+
frame.classList.toggle(
|
|
2154
|
+
'can-scroll-right',
|
|
2155
|
+
scroller.scrollLeft < maxScroll - 1
|
|
2156
|
+
);
|
|
2157
|
+
// Height of the horizontal scrollbar (0 for overlay scrollbars)
|
|
2158
|
+
// — the scroll gradient is lifted by this so it never paints
|
|
2159
|
+
// over the scrollbar track.
|
|
2160
|
+
this.style.setProperty(
|
|
2161
|
+
'--cl-scrollbar',
|
|
2162
|
+
`${scroller.offsetHeight - scroller.clientHeight}px`
|
|
2163
|
+
);
|
|
2164
|
+
}
|
|
2165
|
+
|
|
1132
2166
|
private renderHeader(): TemplateResult {
|
|
1133
2167
|
const allIds = this.items.map((i) => this.rowId(i));
|
|
1134
2168
|
const allSelected =
|
|
1135
2169
|
allIds.length > 0 && allIds.every((id) => this.selectedIds.has(id));
|
|
1136
2170
|
const someSelected = !allSelected && this.selectedIds.size > 0;
|
|
1137
|
-
// Reserve an empty leading-icon column in the header to align
|
|
1138
|
-
// with row icons. We probe a representative row — if any row
|
|
1139
|
-
// returns an icon, every row gets the column (skipped per-row
|
|
1140
|
-
// if its own getRowIcon returns null).
|
|
1141
|
-
const reservesIcon =
|
|
1142
|
-
this.items.length > 0 && this.getRowIcon(this.items[0]) !== null;
|
|
1143
2171
|
|
|
1144
2172
|
return html`
|
|
1145
|
-
<
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
<
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
2173
|
+
<thead>
|
|
2174
|
+
<tr class="header">
|
|
2175
|
+
${this.hasCheckboxes
|
|
2176
|
+
? html`
|
|
2177
|
+
<th
|
|
2178
|
+
class="check-cell ${this.pinClass(this.checkPinIndex)}"
|
|
2179
|
+
style=${this.pinStyle(this.checkPinIndex)}
|
|
2180
|
+
@click=${() => this.handleSelectAll()}
|
|
2181
|
+
>
|
|
2182
|
+
<div class="check-inner">
|
|
2183
|
+
<temba-checkbox
|
|
2184
|
+
size="1.1"
|
|
2185
|
+
?checked=${allSelected}
|
|
2186
|
+
?partial=${someSelected}
|
|
2187
|
+
></temba-checkbox>
|
|
2188
|
+
</div>
|
|
2189
|
+
</th>
|
|
2190
|
+
`
|
|
2191
|
+
: null}
|
|
2192
|
+
${this.reservesIcon
|
|
2193
|
+
? html`<th
|
|
2194
|
+
class="icon-cell ${this.pinClass(this.iconPinIndex)}"
|
|
2195
|
+
style=${this.pinStyle(this.iconPinIndex)}
|
|
2196
|
+
></th>`
|
|
2197
|
+
: null}
|
|
2198
|
+
${this.columns.map((c, i) =>
|
|
2199
|
+
i === this.spacerAfterIndex
|
|
2200
|
+
? html`${this.renderHeaderCell(c)}
|
|
2201
|
+
<th class="spacer"></th>`
|
|
2202
|
+
: this.renderHeaderCell(c)
|
|
2203
|
+
)}
|
|
2204
|
+
</tr>
|
|
2205
|
+
</thead>
|
|
1160
2206
|
`;
|
|
1161
2207
|
}
|
|
1162
2208
|
|
|
1163
2209
|
private renderHeaderCell(column: ContentListColumn): TemplateResult {
|
|
1164
|
-
const style = this.columnStyle(column);
|
|
1165
2210
|
const active = this.sort === column.key || this.sort === '-' + column.key;
|
|
1166
2211
|
const desc = this.sort === '-' + column.key;
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
2212
|
+
const cls = `head-cell ${column.align || ''} ${
|
|
2213
|
+
column.sortable ? 'sortable' : ''
|
|
2214
|
+
} ${active ? 'active' : ''} ${
|
|
2215
|
+
column.grow ? 'grow' : ''
|
|
2216
|
+
} ${this.columnPinClass(column)}`;
|
|
2217
|
+
// The sort arrow sits on the inboard side of the label — left of
|
|
2218
|
+
// it for right-aligned columns, right of it otherwise — so the
|
|
2219
|
+
// label stays flush with the column's values whichever way the
|
|
2220
|
+
// column is aligned, with no offset to reconcile. Its slot is a
|
|
2221
|
+
// fixed width, reserved even while the arrow is hidden, so the
|
|
2222
|
+
// label never shifts when the arrow appears on hover.
|
|
2223
|
+
const label = html`<span class="label"
|
|
2224
|
+
>${column.label ?? column.key}</span
|
|
2225
|
+
>`;
|
|
2226
|
+
const slot = column.sortable
|
|
2227
|
+
? html`<span class="sort-slot"
|
|
2228
|
+
><temba-icon
|
|
2229
|
+
class="sort-icon"
|
|
1181
2230
|
name=${active ? (desc ? Icon.sort_down : Icon.sort_up) : Icon.sort}
|
|
1182
2231
|
size="0.85"
|
|
1183
|
-
></temba-icon
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
2232
|
+
></temba-icon
|
|
2233
|
+
></span>`
|
|
2234
|
+
: null;
|
|
2235
|
+
// Under fixed layout the header row drives the column widths, so
|
|
2236
|
+
// each `width`-set column carries its width on the cell itself;
|
|
2237
|
+
// the grow column is left unsized to claim the remainder.
|
|
2238
|
+
const widthStyle =
|
|
2239
|
+
this.fixedLayout && column.width ? `width: ${column.width};` : '';
|
|
1187
2240
|
return html`
|
|
1188
|
-
<
|
|
1189
|
-
|
|
1190
|
-
|
|
2241
|
+
<th
|
|
2242
|
+
class=${cls}
|
|
2243
|
+
style="${this.columnPinStyle(column)} ${widthStyle}"
|
|
2244
|
+
@click=${column.sortable ? () => this.handleSortClick(column) : null}
|
|
2245
|
+
>
|
|
2246
|
+
<div class="head-inner" style=${this.cellWidthStyle(column)}>
|
|
2247
|
+
${column.align === 'right'
|
|
2248
|
+
? html`${slot}${label}`
|
|
2249
|
+
: html`${label}${slot}`}
|
|
2250
|
+
</div>
|
|
2251
|
+
</th>
|
|
1191
2252
|
`;
|
|
1192
2253
|
}
|
|
1193
2254
|
|
|
1194
|
-
private columnStyle(column: ContentListColumn): string {
|
|
1195
|
-
const parts: string[] = [];
|
|
1196
|
-
if (column.width) {
|
|
1197
|
-
parts.push(`flex: ${column.grow ?? 0} 0 ${column.width}`);
|
|
1198
|
-
} else {
|
|
1199
|
-
parts.push(`flex: ${column.grow ?? 1} 1 0`);
|
|
1200
|
-
}
|
|
1201
|
-
return parts.join('; ');
|
|
1202
|
-
}
|
|
1203
|
-
|
|
1204
2255
|
private renderRow(item: T): TemplateResult {
|
|
1205
2256
|
const id = this.rowId(item);
|
|
1206
2257
|
const selected = this.selectedIds.has(id);
|
|
1207
2258
|
const href = this.getRowHref(item);
|
|
1208
2259
|
const icon = this.getRowIcon(item);
|
|
1209
2260
|
return html`
|
|
1210
|
-
<
|
|
2261
|
+
<tr
|
|
1211
2262
|
class="row ${selected ? 'selected' : ''} ${href ? 'clickable' : ''}"
|
|
1212
2263
|
@click=${(e: MouseEvent) => this.handleRowClick(item, e)}
|
|
1213
2264
|
>
|
|
1214
|
-
${this.
|
|
2265
|
+
${this.hasCheckboxes
|
|
1215
2266
|
? html`
|
|
1216
|
-
<
|
|
1217
|
-
class="check-cell"
|
|
2267
|
+
<td
|
|
2268
|
+
class="check-cell ${this.pinClass(this.checkPinIndex)}"
|
|
2269
|
+
style=${this.pinStyle(this.checkPinIndex)}
|
|
1218
2270
|
@click=${(e: MouseEvent) => {
|
|
1219
2271
|
// Cell-level click is the single source of truth
|
|
1220
2272
|
// for selection. The inner checkbox has
|
|
@@ -1224,74 +2276,137 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
1224
2276
|
this.handleRowToggle(item);
|
|
1225
2277
|
}}
|
|
1226
2278
|
>
|
|
1227
|
-
<
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
2279
|
+
<div class="check-inner">
|
|
2280
|
+
<temba-checkbox
|
|
2281
|
+
size="1.1"
|
|
2282
|
+
?checked=${selected}
|
|
2283
|
+
></temba-checkbox>
|
|
2284
|
+
</div>
|
|
2285
|
+
</td>
|
|
1232
2286
|
`
|
|
1233
2287
|
: null}
|
|
1234
|
-
${
|
|
2288
|
+
${this.reservesIcon
|
|
1235
2289
|
? html`
|
|
1236
|
-
<
|
|
1237
|
-
|
|
1238
|
-
|
|
2290
|
+
<td
|
|
2291
|
+
class="icon-cell ${this.pinClass(this.iconPinIndex)}"
|
|
2292
|
+
style=${this.pinStyle(this.iconPinIndex)}
|
|
2293
|
+
>
|
|
2294
|
+
${icon
|
|
2295
|
+
? html`<div class="icon-inner">
|
|
2296
|
+
<temba-icon name=${icon} size="1"></temba-icon>
|
|
2297
|
+
</div>`
|
|
2298
|
+
: null}
|
|
2299
|
+
</td>
|
|
1239
2300
|
`
|
|
1240
2301
|
: null}
|
|
1241
|
-
${this.columns.map(
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
`
|
|
2302
|
+
${this.columns.map((c, i) =>
|
|
2303
|
+
i === this.spacerAfterIndex
|
|
2304
|
+
? html`${this.renderBodyCell(item, c)}
|
|
2305
|
+
<td class="spacer"></td>`
|
|
2306
|
+
: this.renderBodyCell(item, c)
|
|
1247
2307
|
)}
|
|
1248
|
-
</
|
|
2308
|
+
</tr>
|
|
2309
|
+
`;
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
private renderBodyCell(item: T, column: ContentListColumn): TemplateResult {
|
|
2313
|
+
return html`
|
|
2314
|
+
<td
|
|
2315
|
+
class="cell ${column.align || ''} ${column.grow
|
|
2316
|
+
? 'grow'
|
|
2317
|
+
: ''} ${this.columnPinClass(column)}"
|
|
2318
|
+
style=${this.columnPinStyle(column)}
|
|
2319
|
+
>
|
|
2320
|
+
<div class="cell-inner" style=${this.cellWidthStyle(column)}>
|
|
2321
|
+
${this.renderCell(item, column)}
|
|
2322
|
+
</div>
|
|
2323
|
+
</td>
|
|
1249
2324
|
`;
|
|
1250
2325
|
}
|
|
1251
2326
|
|
|
1252
|
-
|
|
2327
|
+
/** The pager — a compact "‹ 1–N of Total ›" stepper for the
|
|
2328
|
+
* header's actions cluster. A cursor list has no total, so it
|
|
2329
|
+
* shows chevrons only, gated on whether the last response handed
|
|
2330
|
+
* back a cursor for that direction. Returns nothing when there is
|
|
2331
|
+
* neither a page to move to nor a count worth showing. */
|
|
2332
|
+
private renderPager(): TemplateResult {
|
|
1253
2333
|
const lastPage = Math.max(1, Math.ceil(this.total / this.pageSize));
|
|
1254
2334
|
const first = this.total === 0 ? 0 : (this.page - 1) * this.pageSize + 1;
|
|
1255
2335
|
const last = Math.min(this.total, this.page * this.pageSize);
|
|
2336
|
+
const atStart = this.cursorMode ? !this.prevCursor : this.page <= 1;
|
|
2337
|
+
const atEnd = this.cursorMode ? !this.nextCursor : this.page >= lastPage;
|
|
2338
|
+
if (this.cursorMode ? atStart && atEnd : this.total === 0) {
|
|
2339
|
+
return html``;
|
|
2340
|
+
}
|
|
1256
2341
|
return html`
|
|
1257
|
-
<div class="
|
|
1258
|
-
<
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
</
|
|
2342
|
+
<div class="pager">
|
|
2343
|
+
<span
|
|
2344
|
+
class="page-btn"
|
|
2345
|
+
?disabled=${atStart}
|
|
2346
|
+
@click=${() => this.handlePage(-1)}
|
|
2347
|
+
aria-label="Previous page"
|
|
2348
|
+
>
|
|
2349
|
+
<temba-icon name=${Icon.arrow_left} size="1"></temba-icon>
|
|
2350
|
+
</span>
|
|
2351
|
+
${!this.cursorMode
|
|
2352
|
+
? html`<span class="pager-status"
|
|
2353
|
+
>${first}–${last} of ${this.total}</span
|
|
2354
|
+
>`
|
|
2355
|
+
: null}
|
|
2356
|
+
<span
|
|
2357
|
+
class="page-btn"
|
|
2358
|
+
?disabled=${atEnd}
|
|
2359
|
+
@click=${() => this.handlePage(1)}
|
|
2360
|
+
aria-label="Next page"
|
|
2361
|
+
>
|
|
2362
|
+
<temba-icon name=${Icon.arrow_right} size="1"></temba-icon>
|
|
2363
|
+
</span>
|
|
1279
2364
|
</div>
|
|
1280
2365
|
`;
|
|
1281
2366
|
}
|
|
1282
2367
|
|
|
1283
2368
|
public render(): TemplateResult {
|
|
2369
|
+
// Pin layout depends on the current columns + items, so resolve
|
|
2370
|
+
// it once per render before the header and rows are built.
|
|
2371
|
+
this.computePinLayout();
|
|
2372
|
+
const span = this.colSpan();
|
|
1284
2373
|
return html`
|
|
1285
2374
|
<div class="panel">
|
|
1286
2375
|
${this.renderTitlebar()}
|
|
1287
2376
|
<div class="header-rule"></div>
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
2377
|
+
<div class="table-frame">
|
|
2378
|
+
<div
|
|
2379
|
+
class="table-scroll"
|
|
2380
|
+
@scroll=${() => this.syncScrollAffordance()}
|
|
2381
|
+
>
|
|
2382
|
+
<table
|
|
2383
|
+
class="table ${this.fixedLayout ? 'fixed' : ''}"
|
|
2384
|
+
style=${this.minTableWidth
|
|
2385
|
+
? `min-width: ${this.minTableWidth};`
|
|
2386
|
+
: ''}
|
|
2387
|
+
>
|
|
2388
|
+
${this.renderHeader()}
|
|
2389
|
+
<tbody>
|
|
2390
|
+
${this.searching
|
|
2391
|
+
? html`<tr>
|
|
2392
|
+
<td class="loading" colspan=${span}>Searching…</td>
|
|
2393
|
+
</tr>`
|
|
2394
|
+
: this.loading && this.items.length === 0
|
|
2395
|
+
? html`<tr>
|
|
2396
|
+
<td class="loading" colspan=${span}>Loading…</td>
|
|
2397
|
+
</tr>`
|
|
2398
|
+
: this.items.length === 0
|
|
2399
|
+
? html`<tr>
|
|
2400
|
+
<td class="empty" colspan=${span}>
|
|
2401
|
+
${this.emptyMessage}
|
|
2402
|
+
</td>
|
|
2403
|
+
</tr>`
|
|
2404
|
+
: this.items.map((i) => this.renderRow(i))}
|
|
2405
|
+
</tbody>
|
|
2406
|
+
</table>
|
|
2407
|
+
</div>
|
|
2408
|
+
<div class="scroll-shadow"></div>
|
|
2409
|
+
</div>
|
|
1295
2410
|
</div>
|
|
1296
2411
|
`;
|
|
1297
2412
|
}
|