@nyaruka/temba-components 0.158.3 → 0.159.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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, debounce } from '../utils';
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
- /** Explicit flex-basis (e.g. "120px" or "20%"). When omitted the
18
- * cell uses `flex: <grow> 1 0` and shares remaining width. */
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
- /** Flex grow factor defaults to 1, set to 0 to keep a column
21
- * sized strictly to its `width`. */
22
- grow?: number;
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. The host typically handles the action by listening for
27
- * `temba-bulk-action` and POSTing as it sees fit. The label-toggle
28
- * action is a special casewhen `labelsEndpoint` is set, the
29
- * component renders a dropdown of label checkboxes and POSTs the
30
- * apply/remove directly to {@link ContentList.actionEndpoint},
31
- * mirroring rapidpro's `runActionOnObjectRows('label', …)` flow. */
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
- display: block;
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
- /* Title row sits inside the panel at the top — title + subtitle
76
- on the left, actions slot on the right. When rows are
77
- selected the actions slot is replaced inline by bulk-action
78
- chips so the toolbar stays in the same spot visually. */
79
- .titlebar {
80
- display: flex;
81
- align-items: flex-start;
82
- gap: var(--gap);
83
- padding: 20px 0 16px 0;
84
- }
85
- .titles {
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
136
  }
89
- .title {
90
- font-size: 15.5px;
91
- font-weight: var(--w-semibold);
92
- color: var(--text-1);
93
- line-height: 1.3;
94
- }
95
- .subtitle {
96
- font-size: 12.5px;
97
- color: var(--text-3);
98
- line-height: 1.3;
99
- margin-top: 1px;
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
- color: var(--text-2);
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 everything from title
248
- to footer. Soft shadow + radius gives it the contained-card
249
- feel from the styleguide. The 20px horizontal padding is
250
- what insets the header, rows, and footer from the card
251
- edges so the row strips (and their hover wash) sit on a
252
- clear margin instead of bleeding to the card chrome. */
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
- /* Header row sits inside the panel below the titlebar. The
262
- separators above/below the header are drawn via pseudo-
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 rest of the table (rows, lines, hover wash) stays
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
- .header {
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
- display: flex;
281
- align-items: center;
282
- min-height: 36px;
283
- padding: 0 12px;
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::after {
291
- content: '';
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
- /* Data rows live inside the panel's 20px horizontal padding,
301
- with an extra 12px of lead padding so the checkbox sits
302
- off the row's left edge. The hover/selected wash paints
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
- cursor: default;
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::after {
324
- display: none;
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
- .row.selected {
330
- background: var(--accent-50);
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
- .head-cell {
493
+ .head-cell,
494
+ .cell {
338
495
  padding: 0 8px;
339
- flex: 1 1 0;
340
- min-width: 0;
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
- .cell.wrap,
346
- .head-cell.wrap {
347
- white-space: normal;
348
- }
349
- .cell.right,
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
- .head-cell.center {
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
- /* temba-checkbox sizes its icon in em, so the parent's
361
- font-size dictates the visual scale. The header row uses
362
- a smaller font-size for its uppercase labels without
363
- this override, the header's select-all checkbox would
364
- render smaller than the row checkboxes. Pin the cell's
365
- font-size so all checkboxes match regardless of parent. */
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
- flex: 0 0 auto;
368
- padding: 0 6px 0 0;
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
- .head-cell.sortable temba-icon {
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 temba-icon {
701
+ .head-cell.sortable.active .sort-icon {
402
702
  --icon-color: var(--accent-700);
403
703
  opacity: 1;
404
704
  }
@@ -406,22 +706,60 @@ export class ContentList<T = any> extends RapidElement {
406
706
  color: var(--accent-700);
407
707
  }
408
708
 
409
- /* Leading icon column — small entity-type icon shared by
410
- every row in the list (e.g. campaign clock-refresh,
411
- contact silhouette, flow type icon). Subclasses override
412
- {@link getRowIcon} to return a name; if null the column
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
- flex: 0 0 auto;
416
- padding: 0 8px 0 0;
714
+ width: 1%;
715
+ white-space: nowrap;
716
+ padding: 0 6px 0 0;
717
+ --icon-color: var(--text-3);
718
+ }
719
+ /* Reserve the icon's footprint on the wrapper itself so the
720
+ icon column's intrinsic width is the same whether
721
+ <temba-icon> has upgraded or not — without this, the column
722
+ briefly measures as just the cell's right-padding (6px) and
723
+ the downstream pinned columns end up positioned ~14px to
724
+ the left, which races with whatever moment we snapshot.
725
+ <temba-icon size="1"> renders at 1em, so we reserve 1em
726
+ square and let the icon paint into it. */
727
+ .icon-inner {
417
728
  display: flex;
418
729
  align-items: center;
419
- --icon-color: var(--text-3);
730
+ justify-content: center;
731
+ width: 1em;
732
+ height: 1em;
420
733
  }
421
- .row.selected .icon-cell {
734
+ tr.row.selected .icon-cell {
422
735
  --icon-color: var(--accent-700);
423
736
  }
424
737
 
738
+ /* The table-frame sits inside the panel's 20px padding, so the
739
+ first cell already starts at the page-header's content edge
740
+ — no extra lead inset needed. The last cell still trims its
741
+ padding-right (below) so column content doesn't crowd the
742
+ card chrome. */
743
+ /* The first data cell trims its left padding to sit close to
744
+ the leading icon — the icon cell's 6px trailing padding
745
+ plus this 4px makes a snug 10px gap. */
746
+ .icon-cell + .head-cell,
747
+ .icon-cell + .cell {
748
+ padding-left: 4px;
749
+ }
750
+ /* With no icon column the first data cell follows the
751
+ checkbox directly; it drops its left padding entirely so
752
+ the value isn't marooned past a gap meant to clear an
753
+ icon — just the checkbox cell's 12px trailing padding. */
754
+ .check-cell + .head-cell,
755
+ .check-cell + .cell {
756
+ padding-left: 0;
757
+ }
758
+ tr.header th:last-child,
759
+ tr.row td:last-child {
760
+ padding-right: 20px;
761
+ }
762
+
425
763
  .empty,
426
764
  .loading {
427
765
  padding: 40px var(--pad);
@@ -429,22 +767,20 @@ export class ContentList<T = any> extends RapidElement {
429
767
  color: var(--text-3);
430
768
  }
431
769
 
432
- /* Footer: plain "1–N of Total" count on the left, chevron-
433
- only paging buttons on the right. No borders, no labels
434
- minimal as the styleguide. */
435
- .footer {
770
+ /* Pager a compact "1–N of Total" stepper that lives in
771
+ the header's actions cluster: chevron-only paging buttons
772
+ bracketing a plain count, no borders or labels, matching the
773
+ quiet Search action it sits beside. */
774
+ .pager {
436
775
  display: flex;
437
776
  align-items: center;
438
- justify-content: space-between;
439
- padding: 12px 0;
777
+ gap: 2px;
778
+ }
779
+ .pager-status {
780
+ padding: 0 4px;
440
781
  color: var(--text-3);
441
782
  font-size: 12.5px;
442
- }
443
-
444
- .pager {
445
- display: flex;
446
- align-items: center;
447
- gap: 4px;
783
+ white-space: nowrap;
448
784
  }
449
785
 
450
786
  .page-btn {
@@ -518,12 +854,23 @@ export class ContentList<T = any> extends RapidElement {
518
854
  `;
519
855
  }
520
856
 
521
- /** JSON endpoint URL. The component appends `page`, `sort`, and
522
- * `search` params. Response must be `{ results, count }` (plus
523
- * optional `next` / `previous` for parity with api/v2). */
857
+ /** JSON endpoint URL. The component appends `sort` and `search`
858
+ * params. Two pagination shapes are supported, picked per
859
+ * response: a page-counted list — `{ results, count }`, navigated
860
+ * by appending `page` — or a cursor list — `{ results, next,
861
+ * previous }` with no `count`, navigated by following the opaque
862
+ * `next` / `previous` URLs (rapidpro's `CursorPagination`). */
524
863
  @property({ type: String })
525
864
  endpoint: string;
526
865
 
866
+ /** Endpoint for the page's content menu. Passed straight through
867
+ * to the embedded {@link PageHeader}, which fetches it and renders
868
+ * the menu's action buttons + overflow in the list header — so the
869
+ * list header doubles as the page header instead of the page
870
+ * chrome carrying a separate title + menu bar. */
871
+ @property({ type: String, attribute: 'content-menu-endpoint' })
872
+ contentMenuEndpoint = '';
873
+
527
874
  /** Column definitions. Subclasses set this in the constructor;
528
875
  * consumers may also override at the element level. */
529
876
  @property({ type: Array, attribute: false })
@@ -543,10 +890,48 @@ export class ContentList<T = any> extends RapidElement {
543
890
  @property({ type: Boolean })
544
891
  searchable = true;
545
892
 
546
- /** When true, multi-select checkboxes render in the first column. */
893
+ /** Enables the multi-select checkbox column. The column only
894
+ * actually renders when this is true AND {@link bulkActions} has
895
+ * entries — a list with no bulk actions has nothing for selection
896
+ * to drive, so checkboxes would just take up space. See
897
+ * {@link hasCheckboxes}. */
547
898
  @property({ type: Boolean })
548
899
  selectable = true;
549
900
 
901
+ /** Whether the selection column actually renders — true only when
902
+ * the list is `selectable` AND has at least one bulk action. */
903
+ protected get hasCheckboxes(): boolean {
904
+ return this.selectable && this.bulkActions.length > 0;
905
+ }
906
+
907
+ /** When true, the table uses a fixed layout: every column is
908
+ * sized up front (from each column's `width`, with the `grow`
909
+ * column taking the remainder), so a cell whose content doesn't
910
+ * fit ellipsis-truncates rather than stretching its column.
911
+ * Intended for lists whose columns are all `width`-set or `grow`.
912
+ * Pair with {@link minTableWidth} to allow a horizontal scroll
913
+ * once the container is too narrow for those column shares. */
914
+ @property({ type: Boolean, attribute: 'fixed-layout' })
915
+ fixedLayout = false;
916
+
917
+ /** Minimum table width (e.g. "640px"). The table won't shrink
918
+ * below it — once the container is narrower, the list scrolls
919
+ * horizontally instead. With {@link fixedLayout} this is what
920
+ * lets the table scroll at all: fixed layout keeps each column's
921
+ * share stable and truncates overflow, and this floor decides
922
+ * when that share stops shrinking and the scroll takes over. */
923
+ @property({ type: String, attribute: 'min-table-width' })
924
+ minTableWidth = '';
925
+
926
+ /** When true, the list grows to fill its container — the table
927
+ * body scrolls inside it rather than the page. The host's parent
928
+ * must be a height-bounded flex column (the list takes the slack
929
+ * via `flex: 1`); anything below the list in that column, such as
930
+ * a page footer, stays visible. Off by default; a full-page list
931
+ * (e.g. the inbox) opts in. */
932
+ @property({ type: Boolean, attribute: 'fill-window' })
933
+ fillWindow = false;
934
+
550
935
  /** When true, sort/search/page state is reflected to the URL via
551
936
  * `history.pushState` so the page is deep-linkable and back/forward
552
937
  * navigates between list states. Off by default — opt in. */
@@ -558,6 +943,25 @@ export class ContentList<T = any> extends RapidElement {
558
943
  @property({ type: String })
559
944
  urlParamPrefix = '';
560
945
 
946
+ /** Key under which restorable list state (page, sort, search) is
947
+ * stashed in the browser's history entry — set this to opt into
948
+ * history-state restoration without touching the URL. On every
949
+ * user-driven page/sort/search change the list fires a
950
+ * `temba-history-change` event carrying `{key, state, replace}`;
951
+ * the host (e.g. an SPA frame) is expected to merge `state` into
952
+ * the current history entry and either `pushState` (when
953
+ * `replace` is false — paging, sort, committed search) or
954
+ * `replaceState` (when `replace` is true — typing in the search
955
+ * box, or other no-history-entry updates). On mount the list
956
+ * reads back `history.state?.[key]` and resumes from those values
957
+ * before its initial fetch, and an in-list `popstate` re-reads
958
+ * the active entry and re-fetches so back/forward navigates
959
+ * between the user's page/sort/search states. Picks one slot per
960
+ * list, so multiple lists on a page coexist by using distinct
961
+ * keys. */
962
+ @property({ type: String, attribute: 'history-state-key' })
963
+ historyStateKey = '';
964
+
561
965
  /** Placeholder for the search input. */
562
966
  @property({ type: String })
563
967
  searchPlaceholder = 'Search';
@@ -593,9 +997,51 @@ export class ContentList<T = any> extends RapidElement {
593
997
  @state()
594
998
  protected total = 0;
595
999
 
1000
+ /** Whether the last response carried a server `count`. Distinct
1001
+ * from {@link total} because cursor lists fall back to the visible
1002
+ * page length so the empty-state math still works — only an actual
1003
+ * server count is reliable enough to surface in the UI (e.g. the
1004
+ * search result indicator). */
1005
+ @state()
1006
+ protected hasCount = false;
1007
+
596
1008
  @state()
597
1009
  protected page = 1;
598
1010
 
1011
+ /** Whether the last response was a cursor list. Detected from the
1012
+ * shape of `next` / `previous` (a `cursor=` query param marks DRF
1013
+ * CursorPagination) so the mode survives a count being returned
1014
+ * alongside cursor URLs — a searched cursor endpoint may include
1015
+ * `count` for the result indicator without abandoning cursor
1016
+ * navigation. Falls back to count-absent on single-page responses
1017
+ * where neither nav URL is set. In cursor mode the pager follows
1018
+ * {@link nextCursor} / {@link prevCursor} instead of computing
1019
+ * page numbers off {@link total}. */
1020
+ @state()
1021
+ protected cursorMode = false;
1022
+
1023
+ /** Same-origin path+query of the cursor list's `next` page, or ''
1024
+ * when there is none. Only meaningful in {@link cursorMode}. */
1025
+ @state()
1026
+ protected nextCursor = '';
1027
+
1028
+ /** Same-origin path+query of the cursor list's `previous` page, or
1029
+ * '' when there is none. Only meaningful in {@link cursorMode}. */
1030
+ @state()
1031
+ protected prevCursor = '';
1032
+
1033
+ /** URL of the most recent fetch — re-requested by {@link refresh}
1034
+ * (and after a bulk action) so a cursor list stays on its current
1035
+ * page rather than snapping back to the first. */
1036
+ private currentUrl = '';
1037
+
1038
+ /** URL to fetch on the next initial-fetch pass, lifted from the
1039
+ * host's `history.state` by {@link readHistoryState}. Lets a
1040
+ * cursor-paginated list resume on the exact slice the user was on
1041
+ * (cursor URLs are opaque, so reconstructing them from page/sort
1042
+ * isn't possible). Cleared after use. */
1043
+ private restoreUrl = '';
1044
+
599
1045
  /** Sort key; prefix with `-` for descending. Empty = server default. */
600
1046
  @state()
601
1047
  protected sort = '';
@@ -606,6 +1052,9 @@ export class ContentList<T = any> extends RapidElement {
606
1052
  @state()
607
1053
  protected loading = false;
608
1054
 
1055
+ @state()
1056
+ protected searching = false;
1057
+
609
1058
  @state()
610
1059
  protected selectedIds: Set<string> = new Set();
611
1060
 
@@ -615,6 +1064,14 @@ export class ContentList<T = any> extends RapidElement {
615
1064
  @state()
616
1065
  protected searchOpen = false;
617
1066
 
1067
+ /** Uncommitted input text — what's in the textbox while the user
1068
+ * is typing. Distinct from {@link search}, which is the committed
1069
+ * query that drives the fetch; the draft is only promoted to
1070
+ * `search` when the user presses Enter or clicks the search icon.
1071
+ * Bound to the input's `.value` so re-renders preserve typing. */
1072
+ @state()
1073
+ protected searchDraft = '';
1074
+
618
1075
  /** Cache of labels fetched per label-toggle action key.
619
1076
  * Populated lazily the first time a label dropdown opens. */
620
1077
  @state()
@@ -628,12 +1085,31 @@ export class ContentList<T = any> extends RapidElement {
628
1085
  protected pendingLabel: string | null = null;
629
1086
 
630
1087
  private pending: AbortController = null;
631
- private debouncedFetch: () => void;
632
1088
  private popstateHandler: () => void;
1089
+ private resizeHandler: () => void;
1090
+
1091
+ /** Pin index assigned to each left-pinned column / leading cell,
1092
+ * used to resolve its sticky `left`. Recomputed each render. */
1093
+ private pinIndexByColumn = new Map<ContentListColumn, number>();
1094
+ /** Pin index assigned to each right-pinned column, counted from
1095
+ * the right edge (0 = rightmost), used to resolve its sticky
1096
+ * `right`. Recomputed each render. */
1097
+ private rightPinIndexByColumn = new Map<ContentListColumn, number>();
1098
+ private checkPinIndex = -1;
1099
+ private iconPinIndex = -1;
1100
+ private lastPinIndex = -1;
1101
+ /** Right-pin index of the leftmost right-pinned column — the one
1102
+ * that carries the divider against the scrolling section. */
1103
+ private firstRightPinIndex = -1;
1104
+ /** Column index after which the slack-absorbing spacer cell is
1105
+ * rendered (the last pinned column), or -1 when nothing is
1106
+ * pinned. Extra table width pools in that spacer. */
1107
+ private spacerAfterIndex = -1;
1108
+ /** Whether the current items reserve a leading-icon column. */
1109
+ private reservesIcon = false;
633
1110
 
634
1111
  constructor() {
635
1112
  super();
636
- this.debouncedFetch = debounce(() => this.fetchPage(), 250);
637
1113
  }
638
1114
 
639
1115
  public connectedCallback(): void {
@@ -645,6 +1121,40 @@ export class ContentList<T = any> extends RapidElement {
645
1121
  this.fetchPage();
646
1122
  };
647
1123
  window.addEventListener('popstate', this.popstateHandler);
1124
+ } else if (this.historyStateKey) {
1125
+ // Restore from the host SPA's history entry on mount so the
1126
+ // list resumes on whatever page it was on the last time the
1127
+ // user was here — works in tandem with the host's
1128
+ // `temba-history-change` listener (which pushes a new entry
1129
+ // for paging/sort/committed-search and replaces in place for
1130
+ // search-typing, per the event's `replace` flag). The
1131
+ // popstate handler covers in-list back/forward, where the
1132
+ // URL doesn't change so the SPA frame doesn't remount the
1133
+ // list — we re-read state from the active entry and re-fetch
1134
+ // ourselves. Cross-URL back navigation still goes through
1135
+ // the SPA frame and remounts a fresh list, which then reads
1136
+ // state on mount.
1137
+ this.readHistoryState();
1138
+ this.popstateHandler = () => {
1139
+ this.readHistoryState();
1140
+ const restore = this.restoreUrl;
1141
+ this.restoreUrl = '';
1142
+ this.fetchPage(restore || undefined);
1143
+ };
1144
+ window.addEventListener('popstate', this.popstateHandler);
1145
+ }
1146
+ // A viewport resize changes whether the table overflows, so the
1147
+ // right-edge scroll affordance has to be re-evaluated.
1148
+ this.resizeHandler = () => this.syncScrollAffordance();
1149
+ window.addEventListener('resize', this.resizeHandler);
1150
+ // Pinned columns now size to their content, so a late web-font
1151
+ // load shifts their widths — re-measure the sticky offsets once
1152
+ // fonts settle so the pinned cells don't drift out of alignment.
1153
+ if (document.fonts && document.fonts.ready) {
1154
+ document.fonts.ready.then(() => {
1155
+ this.measurePinOffsets();
1156
+ this.syncScrollAffordance();
1157
+ });
648
1158
  }
649
1159
  }
650
1160
 
@@ -652,8 +1162,17 @@ export class ContentList<T = any> extends RapidElement {
652
1162
  if (this.popstateHandler) {
653
1163
  window.removeEventListener('popstate', this.popstateHandler);
654
1164
  }
1165
+ if (this.resizeHandler) {
1166
+ window.removeEventListener('resize', this.resizeHandler);
1167
+ }
655
1168
  if (this.pending) {
656
- this.pending.abort();
1169
+ // Null the pending pointer before aborting so fetchPage's
1170
+ // finally block — which gates cleanup on `this.pending ===
1171
+ // controller` — skips firing FetchComplete on a disconnected
1172
+ // component.
1173
+ const controller = this.pending;
1174
+ this.pending = null;
1175
+ controller.abort();
657
1176
  }
658
1177
  super.disconnectedCallback();
659
1178
  }
@@ -663,11 +1182,23 @@ export class ContentList<T = any> extends RapidElement {
663
1182
  // Only watch endpoint and refreshKey here — both are typically
664
1183
  // set externally and have no other handler that already fires a
665
1184
  // fetch. Sort/page/search are mutated by internal handlers that
666
- // call fetchPage (directly or via debouncedFetch) themselves, so
667
- // tracking them here would double-fire the request.
668
- if ((changes.has('endpoint') || changes.has('refreshKey')) && this.endpoint) {
669
- this.fetchPage();
1185
+ // call fetchPage themselves, so tracking them here would
1186
+ // double-fire the request.
1187
+ if (
1188
+ (changes.has('endpoint') || changes.has('refreshKey')) &&
1189
+ this.endpoint
1190
+ ) {
1191
+ // If readHistoryState staged a restoreUrl, the first fetch
1192
+ // follows that URL so a cursor list lands on the saved slice.
1193
+ // Clear it so subsequent fetches use the live state.
1194
+ const restore = this.restoreUrl;
1195
+ this.restoreUrl = '';
1196
+ this.fetchPage(restore || undefined);
670
1197
  }
1198
+ // Pinned-column offsets and the scroll affordances both depend
1199
+ // on the freshly-laid-out DOM, so settle them after each render.
1200
+ this.measurePinOffsets();
1201
+ this.syncScrollAffordance();
671
1202
  }
672
1203
 
673
1204
  /** Read sort/page/search from the URL on first load / popstate. */
@@ -675,15 +1206,32 @@ export class ContentList<T = any> extends RapidElement {
675
1206
  const params = new URLSearchParams(window.location.search);
676
1207
  const k = (name: string) =>
677
1208
  this.urlParamPrefix ? `${this.urlParamPrefix}_${name}` : name;
1209
+ const previousSearch = this.search;
678
1210
  this.search = params.get(k('search')) || '';
679
1211
  this.sort = params.get(k('sort')) || '';
680
1212
  const pageParam = parseInt(params.get(k('page')) || '1', 10);
681
1213
  this.page = isNaN(pageParam) || pageParam < 1 ? 1 : pageParam;
1214
+ // Reveal the search input when the URL carries an active query —
1215
+ // see readHistoryState for the equivalent treatment. The
1216
+ // close-on-empty branch only fires when the navigation actually
1217
+ // cleared a prior search; an unrelated popstate that arrives
1218
+ // while the user has the searchbar open and is mid-typing must
1219
+ // not slam their draft.
1220
+ if (this.search) {
1221
+ this.searchOpen = true;
1222
+ this.searchDraft = this.search;
1223
+ } else if (previousSearch) {
1224
+ this.searchOpen = false;
1225
+ this.searchDraft = '';
1226
+ }
682
1227
  }
683
1228
 
684
- /** Push current sort/page/search to the URL. `replace` is true while
685
- * the user is typing in the search box (don't pollute history). */
1229
+ /** Push current sort/page/search to the URL and/or bubble it up
1230
+ * to the host for stashing in history.state call this on every
1231
+ * user-driven page/sort/search change. `replace` is true while the
1232
+ * user is typing in the search box (don't pollute history). */
686
1233
  private writeUrlState(replace = false): void {
1234
+ this.bubbleHistoryState(replace);
687
1235
  if (!this.urlState) return;
688
1236
  const params = new URLSearchParams(window.location.search);
689
1237
  const k = (name: string) =>
@@ -706,6 +1254,73 @@ export class ContentList<T = any> extends RapidElement {
706
1254
  }
707
1255
  }
708
1256
 
1257
+ /** Read saved list state out of the host's history entry under
1258
+ * {@link historyStateKey}. Mirrors {@link readUrlState} but pulls
1259
+ * from `history.state` rather than the query string. The `url`
1260
+ * field, when present, is the cursor-or-page URL of the slice the
1261
+ * user was on — staged in {@link restoreUrl} so the very next
1262
+ * fetch follows it (the only way to land on a specific slice of a
1263
+ * cursor-paginated list, whose pages have no numeric identifier). */
1264
+ private readHistoryState(): void {
1265
+ const key = this.historyStateKey;
1266
+ if (!key) return;
1267
+ const stash = (window.history.state || {})[key] || {};
1268
+ const previousSearch = this.search;
1269
+ this.search = typeof stash.search === 'string' ? stash.search : '';
1270
+ this.sort = typeof stash.sort === 'string' ? stash.sort : '';
1271
+ const p = parseInt(stash.page, 10);
1272
+ this.page = isNaN(p) || p < 1 ? 1 : p;
1273
+ this.restoreUrl = typeof stash.url === 'string' ? stash.url : '';
1274
+ // A restored search needs visible affordance — open the search
1275
+ // bar and seed the draft so the user sees the active query and
1276
+ // can edit or clear it without having to click the search
1277
+ // toggle and discover the term was retained. Only auto-close on
1278
+ // empty when the navigation actually cleared a prior search, so
1279
+ // an unrelated popstate that arrives while the user is mid-
1280
+ // typing doesn't slam their draft.
1281
+ if (this.search) {
1282
+ this.searchOpen = true;
1283
+ this.searchDraft = this.search;
1284
+ } else if (previousSearch) {
1285
+ this.searchOpen = false;
1286
+ this.searchDraft = '';
1287
+ }
1288
+ }
1289
+
1290
+ /** Bubble the current page/sort/search/url up to the host so it
1291
+ * can stash them in the active history entry. The `url` field
1292
+ * carries `currentUrl` (the URL of the most recent successful
1293
+ * fetch) — page-mode lists can rebuild that from page/sort/search,
1294
+ * but cursor-mode lists rely on it to land on the exact slice the
1295
+ * user was on. `replace` tells the host whether this change should
1296
+ * create a new back-history entry (false — paging, sort,
1297
+ * committed search) or overwrite the current one (true — typing
1298
+ * in the search box, cursor-page snap-back). The component never
1299
+ * touches `history` itself in this mode — that keeps the host's
1300
+ * SPA navigation in charge of history mutations and lets multiple
1301
+ * lists on a page coexist under distinct {@link historyStateKey}s. */
1302
+ private bubbleHistoryState(replace: boolean): void {
1303
+ if (!this.historyStateKey) return;
1304
+ // For a page-mode list, page/sort/search are enough — the
1305
+ // initial fetch on restore rebuilds the request URL from them.
1306
+ // For a cursor list, page numbers are meaningless, so we also
1307
+ // stash the most recent cursor URL (set synchronously by
1308
+ // fetchPage(target)) so restore can land on the exact slice.
1309
+ const state: Record<string, any> = {
1310
+ page: this.page,
1311
+ sort: this.sort,
1312
+ search: this.search
1313
+ };
1314
+ if (this.cursorMode && this.currentUrl) {
1315
+ state.url = this.currentUrl;
1316
+ }
1317
+ this.fireCustomEvent(CustomEventType.HistoryChange, {
1318
+ key: this.historyStateKey,
1319
+ state,
1320
+ replace
1321
+ });
1322
+ }
1323
+
709
1324
  /** Build the request URL by appending sort/search/page params to
710
1325
  * the configured endpoint. */
711
1326
  private buildRequestUrl(): string {
@@ -718,17 +1333,75 @@ export class ContentList<T = any> extends RapidElement {
718
1333
  return url.pathname + url.search;
719
1334
  }
720
1335
 
721
- private async fetchPage(): Promise<void> {
1336
+ /** Tell a cursor list from a page-counted one by inspecting the
1337
+ * server's nav URLs. DRF CursorPagination always emits a `cursor=`
1338
+ * query param; PageNumberPagination uses `page=`. A response that
1339
+ * carries `count` alongside cursor URLs — e.g. a searched cursor
1340
+ * endpoint that returns a result tally for the UI indicator — must
1341
+ * still be navigated by following the cursor URLs, so we can't use
1342
+ * count presence alone. Falls back to the count-absent heuristic
1343
+ * for single-page responses where neither nav URL is populated. */
1344
+ private detectCursorMode(data: FetchResponse<T>): boolean {
1345
+ const hasCursor = (raw: string | undefined | null): boolean => {
1346
+ if (!raw) return false;
1347
+ try {
1348
+ return new URL(raw, window.location.origin).searchParams.has('cursor');
1349
+ } catch {
1350
+ return false;
1351
+ }
1352
+ };
1353
+ if (hasCursor(data.next) || hasCursor(data.previous)) return true;
1354
+ return data.count == null;
1355
+ }
1356
+
1357
+ /** Reduce a cursor `next` / `previous` URL — which the server
1358
+ * returns absolute — to a same-origin path+query for `getUrl`.
1359
+ * A cross-origin URL is rejected (returns '') so a malformed
1360
+ * response can't redirect the fetch off-site. */
1361
+ private toRequestUrl(raw: string): string {
1362
+ try {
1363
+ const url = new URL(raw, window.location.origin);
1364
+ if (url.origin !== window.location.origin) return '';
1365
+ return url.pathname + url.search;
1366
+ } catch {
1367
+ return '';
1368
+ }
1369
+ }
1370
+
1371
+ /** Fetch a page. With no argument this builds a fresh request from
1372
+ * the endpoint + current sort/search/page (resetting a cursor list
1373
+ * to its first page); pass an explicit `url` to follow a cursor or
1374
+ * to re-request {@link currentUrl}. */
1375
+ private async fetchPage(url?: string): Promise<void> {
722
1376
  if (!this.endpoint) return;
723
1377
  if (this.pending) this.pending.abort();
724
1378
  const controller = new AbortController();
725
1379
  this.pending = controller;
726
1380
  this.loading = true;
1381
+ const requestUrl = url || this.buildRequestUrl();
1382
+ this.currentUrl = requestUrl;
727
1383
  try {
728
- const response = await getUrl(this.buildRequestUrl(), controller);
1384
+ const response = await getUrl(requestUrl, controller);
729
1385
  const data = (response.json || {}) as FetchResponse<T>;
730
1386
  this.items = data.results || [];
1387
+ this.nextCursor = data.next ? this.toRequestUrl(data.next) : '';
1388
+ this.prevCursor = data.previous ? this.toRequestUrl(data.previous) : '';
1389
+ // Cursor mode is detected from the shape of next/previous,
1390
+ // not the absence of `count` — a cursor endpoint may include
1391
+ // `count` (e.g. during search) without switching to page-mode
1392
+ // navigation. See {@link detectCursorMode}.
1393
+ this.cursorMode = this.detectCursorMode(data);
1394
+ this.hasCount = data.count != null;
731
1395
  this.total = data.count ?? this.items.length;
1396
+ // A cursor endpoint has no way to honor `?page=N` on first
1397
+ // load, so a hard refresh that lands with a stale synthetic
1398
+ // page param would leave the URL out of sync with what the
1399
+ // server actually returned (the first slice). Snap the
1400
+ // synthetic page back to 1 and rewrite the URL in place.
1401
+ if (this.cursorMode && !this.prevCursor && this.page !== 1) {
1402
+ this.page = 1;
1403
+ this.writeUrlState(true);
1404
+ }
732
1405
  // drop any selected ids that aren't visible anymore — selection
733
1406
  // is per-page, not cross-page, so users don't accidentally bulk
734
1407
  // act on rows they can't see.
@@ -750,14 +1423,17 @@ export class ContentList<T = any> extends RapidElement {
750
1423
  if (this.pending === controller) {
751
1424
  this.pending = null;
752
1425
  this.loading = false;
1426
+ this.searching = false;
753
1427
  this.fireCustomEvent(CustomEventType.FetchComplete);
754
1428
  }
755
1429
  }
756
1430
  }
757
1431
 
758
- /** Public API — programmatic refresh, mirrors `refreshKey` bump. */
1432
+ /** Public API — programmatic refresh, mirrors `refreshKey` bump.
1433
+ * Re-requests the current page (cursor lists included) rather than
1434
+ * resetting to the first. */
759
1435
  public refresh(): void {
760
- this.fetchPage();
1436
+ this.fetchPage(this.currentUrl || undefined);
761
1437
  }
762
1438
 
763
1439
  /** Identity helper — uses the `valueKey` to pull a stable id from
@@ -784,11 +1460,42 @@ export class ContentList<T = any> extends RapidElement {
784
1460
  return null;
785
1461
  }
786
1462
 
1463
+ /** Update the uncommitted input value as the user types. The
1464
+ * fetch is deferred until the user submits (Enter / search-icon
1465
+ * click) so we don't pound the server on every keystroke. */
787
1466
  private handleSearchInput(event: any): void {
788
- this.search = event.target.value || '';
1467
+ this.searchDraft = event.target.value || '';
1468
+ }
1469
+
1470
+ /** Commit on Enter; let other keys through. Escape clears the
1471
+ * draft (so the user can bail without firing a search). */
1472
+ private handleSearchKey(event: KeyboardEvent): void {
1473
+ if (event.key === 'Enter') {
1474
+ event.preventDefault();
1475
+ this.commitSearch();
1476
+ } else if (event.key === 'Escape') {
1477
+ event.preventDefault();
1478
+ if (this.searchDraft && this.searchDraft !== this.search) {
1479
+ // Discard the in-progress draft, leaving the committed
1480
+ // search alone — a quick way out without altering results.
1481
+ this.searchDraft = this.search;
1482
+ }
1483
+ }
1484
+ }
1485
+
1486
+ /** Promote the input's draft to the committed search and fetch.
1487
+ * fetchPage runs first so currentUrl reflects the new search before
1488
+ * the state bubbles — bubbling first would stash the pre-search URL
1489
+ * and break history restoration on the way back. Pushes a new
1490
+ * history entry so the prior search (or unsearched view) is one
1491
+ * "back" away, matching paging and sort semantics. */
1492
+ private commitSearch(): void {
1493
+ if (this.search === this.searchDraft) return;
1494
+ this.search = this.searchDraft;
789
1495
  this.page = 1;
790
- this.writeUrlState(true);
791
- this.debouncedFetch();
1496
+ this.searching = true;
1497
+ this.fetchPage();
1498
+ this.writeUrlState();
792
1499
  }
793
1500
 
794
1501
  private handleSortClick(column: ContentListColumn): void {
@@ -801,8 +1508,10 @@ export class ContentList<T = any> extends RapidElement {
801
1508
  this.sort = column.key;
802
1509
  }
803
1510
  this.page = 1;
804
- this.writeUrlState();
1511
+ // fetchPage first so currentUrl reflects the new sort before the
1512
+ // state bubbles — see commitSearch for the full reasoning.
805
1513
  this.fetchPage();
1514
+ this.writeUrlState();
806
1515
  }
807
1516
 
808
1517
  private handleRowClick(item: T, event: MouseEvent): void {
@@ -824,7 +1533,7 @@ export class ContentList<T = any> extends RapidElement {
824
1533
  * permit same-origin navigation — absolute URLs must match the
825
1534
  * current origin, relative URLs must be path-only (starting with
826
1535
  * `/` and not `//`, which would be protocol-relative). */
827
- private isSafeHref(href: string): boolean {
1536
+ protected isSafeHref(href: string): boolean {
828
1537
  if (typeof href !== 'string' || href.length === 0) return false;
829
1538
  // Reject protocol-relative URLs ("//evil.com/...") and any
830
1539
  // scheme-prefixed URL that isn't same-origin.
@@ -859,45 +1568,108 @@ export class ContentList<T = any> extends RapidElement {
859
1568
  });
860
1569
  }
861
1570
 
862
- private handleBulkAction(action: ContentListBulkAction): void {
863
- this.fireCustomEvent(CustomEventType.BulkAction, {
864
- action: action.key,
865
- ids: Array.from(this.selectedIds)
866
- });
1571
+ /** Run a non-label bulk action. With an `actionEndpoint` set, POST
1572
+ * the action server-side (form-encoded to match smartmin's
1573
+ * `BulkActionMixin`), then re-fetch the current page so the user
1574
+ * stays where they were — rather than letting the host trigger a
1575
+ * full SPA page replacement that drops them back to page 1. With
1576
+ * no `actionEndpoint`, just fire the event for the host to
1577
+ * handle. Destructive actions can carry a `confirm` string for a
1578
+ * window.confirm() prompt (text comes from the host so it can be
1579
+ * localized). The event fires after the POST/refresh so a host
1580
+ * sidebar can refresh counts when notified. */
1581
+ private async handleBulkAction(action: ContentListBulkAction): Promise<void> {
1582
+ if (action.confirm && !window.confirm(action.confirm)) return;
1583
+
1584
+ const ids = Array.from(this.selectedIds);
1585
+
1586
+ if (this.actionEndpoint) {
1587
+ const params = new URLSearchParams();
1588
+ params.append('action', action.key);
1589
+ ids.forEach((id) => params.append('objects', id));
1590
+ try {
1591
+ await postUrl(this.actionEndpoint, params);
1592
+ // Re-request the current page so a filtered view (e.g.
1593
+ // archive removes rows from inbox) drops the acted-on rows,
1594
+ // staying on the user's page rather than resetting to one.
1595
+ await this.fetchPage(this.currentUrl || undefined);
1596
+ // Drop selection for any ids the server filtered out of the
1597
+ // refreshed view; survivors stay selected.
1598
+ this.recheckSelection(ids);
1599
+ // Only fire after the server confirms — a failed POST
1600
+ // shouldn't trigger consumers to refresh based on a
1601
+ // non-event.
1602
+ this.fireCustomEvent(CustomEventType.BulkAction, {
1603
+ action: action.key,
1604
+ ids
1605
+ });
1606
+ } catch (err) {
1607
+ // eslint-disable-next-line no-console
1608
+ console.error('bulk action POST failed', err);
1609
+ }
1610
+ } else {
1611
+ // No server round-trip — leave the action entirely up to the
1612
+ // host and let it know.
1613
+ this.fireCustomEvent(CustomEventType.BulkAction, {
1614
+ action: action.key,
1615
+ ids
1616
+ });
1617
+ }
867
1618
  }
868
1619
 
869
1620
  private handlePage(delta: number): void {
1621
+ // A cursor list has no page numbers — step by following the
1622
+ // opaque next/previous URL the last response handed back. Call
1623
+ // fetchPage first so currentUrl is updated synchronously, then
1624
+ // bubble state so the saved URL points at the new view. The
1625
+ // synthetic page number is bumped only to give each history entry
1626
+ // a distinct URL; the cursor URL stashed in history.state is what
1627
+ // actually drives restoration.
1628
+ if (this.cursorMode) {
1629
+ const target = delta > 0 ? this.nextCursor : this.prevCursor;
1630
+ if (target) {
1631
+ this.page = Math.max(1, this.page + delta);
1632
+ this.fetchPage(target);
1633
+ this.writeUrlState();
1634
+ }
1635
+ return;
1636
+ }
870
1637
  const lastPage = Math.max(1, Math.ceil(this.total / this.pageSize));
871
1638
  const next = Math.min(lastPage, Math.max(1, this.page + delta));
872
1639
  if (next !== this.page) {
873
1640
  this.page = next;
874
- this.writeUrlState();
875
1641
  this.fetchPage();
1642
+ this.writeUrlState();
876
1643
  }
877
1644
  }
878
1645
 
879
1646
  private renderTitlebar(): TemplateResult {
880
1647
  const selectionCount = this.selectedIds.size;
881
1648
  const bulkVisible = selectionCount > 0 && this.bulkActions.length > 0;
1649
+ const hasSubtitle =
1650
+ this.subtitle || this.querySelector('[slot="subtitle"]');
1651
+ const resultCount = `${this.total} ${this.total === 1 ? 'result' : 'results'}`;
1652
+ // The header — title + content menu — is temba-page-header. The
1653
+ // list forwards its own title/subtitle slots into it and slots
1654
+ // its search / bulk-action controls into the header's actions
1655
+ // area, so the list and a plain page share one header.
882
1656
  return html`
883
- <div class="titlebar">
884
- <div class="titles">
885
- <div class="title">
886
- <slot name="title">${this.listTitle}</slot>
887
- </div>
888
- ${this.subtitle || this.querySelector('[slot="subtitle"]')
889
- ? html`<div class="subtitle">
890
- <slot name="subtitle">${this.subtitle}</slot>
891
- </div>`
892
- : null}
893
- </div>
894
- <div class="actions">
1657
+ <temba-page-header
1658
+ content-menu-endpoint=${this.contentMenuEndpoint}
1659
+ ?hide-menu=${bulkVisible}
1660
+ >
1661
+ <slot name="title" slot="title">${this.listTitle}</slot>
1662
+ ${hasSubtitle
1663
+ ? html`<slot name="subtitle" slot="subtitle">${this.subtitle}</slot>`
1664
+ : null}
1665
+ <div slot="actions" class="header-actions">
895
1666
  ${bulkVisible
896
1667
  ? html`
897
1668
  <span class="bulk-count">${selectionCount} selected</span>
898
1669
  ${this.bulkActions.map((a) => this.renderBulkAction(a))}
899
1670
  `
900
1671
  : html`
1672
+ ${this.renderPager()}
901
1673
  ${this.searchable && !this.searchOpen
902
1674
  ? html`
903
1675
  <span class="action" @click=${() => this.toggleSearch()}>
@@ -912,23 +1684,36 @@ export class ContentList<T = any> extends RapidElement {
912
1684
  <slot name="actions"></slot>
913
1685
  `}
914
1686
  </div>
915
- </div>
1687
+ </temba-page-header>
916
1688
  ${this.searchable && this.searchOpen
917
1689
  ? html`
918
1690
  <div class="searchbar">
919
- <temba-icon name=${Icon.search} size="0.95"></temba-icon>
1691
+ <span
1692
+ class="submit"
1693
+ title="Search"
1694
+ aria-label="Search"
1695
+ @click=${() => this.commitSearch()}
1696
+ >
1697
+ <temba-icon name=${Icon.search} size="0.95"></temba-icon>
1698
+ </span>
920
1699
  <input
921
1700
  type="text"
922
1701
  placeholder=${this.searchPlaceholder}
923
- .value=${this.search}
1702
+ .value=${this.searchDraft}
924
1703
  @input=${this.handleSearchInput}
925
- autofocus
1704
+ @keydown=${this.handleSearchKey}
926
1705
  />
927
- ${this.search
928
- ? html`<span class="clear" @click=${() => this.clearSearch()}>
929
- <temba-icon name=${Icon.close} size="0.85"></temba-icon>
930
- </span>`
1706
+ ${this.search && this.hasCount && !this.loading
1707
+ ? html`<span class="result-count">${resultCount}</span>`
931
1708
  : null}
1709
+ <span
1710
+ class="clear"
1711
+ title="Close search"
1712
+ aria-label="Close search"
1713
+ @click=${() => this.toggleSearch()}
1714
+ >
1715
+ <temba-icon name=${Icon.close} size="0.85"></temba-icon>
1716
+ </span>
932
1717
  </div>
933
1718
  `
934
1719
  : null}
@@ -957,6 +1742,7 @@ export class ContentList<T = any> extends RapidElement {
957
1742
  return html`
958
1743
  <temba-dropdown
959
1744
  class="label-dropdown"
1745
+ data-action-key=${action.key}
960
1746
  @temba-opened=${() => this.handleLabelDropdownOpened(action)}
961
1747
  >
962
1748
  <span
@@ -971,13 +1757,16 @@ export class ContentList<T = any> extends RapidElement {
971
1757
  <div slot="dropdown" class="label-menu">
972
1758
  ${labels.length === 0
973
1759
  ? html`<div class="label-menu-empty">Loading&hellip;</div>`
974
- : labels.map((label) => this.renderLabelOption(label))}
1760
+ : labels.map((label) => this.renderLabelOption(label, action))}
975
1761
  </div>
976
1762
  </temba-dropdown>
977
1763
  `;
978
1764
  }
979
1765
 
980
- private renderLabelOption(label: any): TemplateResult {
1766
+ private renderLabelOption(
1767
+ label: any,
1768
+ action: ContentListBulkAction
1769
+ ): TemplateResult {
981
1770
  const state = this.computeLabelState(label.uuid);
982
1771
  const isPending = this.pendingLabel === label.uuid;
983
1772
  const isBlocked = this.pendingLabel !== null && !isPending;
@@ -989,7 +1778,7 @@ export class ContentList<T = any> extends RapidElement {
989
1778
  @click=${(e: MouseEvent) => {
990
1779
  e.stopPropagation();
991
1780
  if (this.pendingLabel !== null) return;
992
- this.toggleLabel(label, state);
1781
+ this.toggleLabel(label, state, action.key);
993
1782
  }}
994
1783
  >
995
1784
  <temba-checkbox
@@ -1049,47 +1838,78 @@ export class ContentList<T = any> extends RapidElement {
1049
1838
  * filtered result decide which rows stay. We POST first, then
1050
1839
  * refresh once the server confirms. The `pendingLabel` state
1051
1840
  * blocks further toggles until the round-trip completes. */
1052
- private async toggleLabel(label: any, state: string): Promise<void> {
1841
+ private async toggleLabel(
1842
+ label: any,
1843
+ state: string,
1844
+ actionKey: string
1845
+ ): Promise<void> {
1053
1846
  if (this.pendingLabel !== null) return;
1054
1847
  const add = state !== 'all';
1055
1848
  const originalSelectedIds = Array.from(this.selectedIds);
1056
1849
  this.pendingLabel = label.uuid;
1057
-
1058
- if (this.actionEndpoint) {
1059
- // application/x-www-form-urlencoded matches what Django's
1060
- // smartmin `BulkActionMixin` reads from `request.POST`, and
1061
- // is trivial to parse server-side (URLSearchParams) without
1062
- // pulling in a multipart parser for the demo mock.
1063
- const params = new URLSearchParams();
1064
- params.append('action', 'label');
1065
- params.append('label', label.uuid);
1066
- if (!add) params.append('add', 'false');
1067
- originalSelectedIds.forEach((id) => params.append('objects', id));
1068
- try {
1069
- await postUrl(this.actionEndpoint, params);
1070
- // Re-fetch the page so a filtered view (e.g. label-filter)
1071
- // drops rows that no longer match.
1072
- await this.fetchPage();
1073
- // Re-check the ids we were operating on. Items that survived
1074
- // the refresh stay selected; items the server filtered out
1075
- // (label removed → no longer matches the view) are absent
1076
- // from `this.items` and won't be re-selected. Mirrors
1077
- // rapidpro's `recheckIds()` after a `spaPost`.
1078
- this.recheckSelection(originalSelectedIds);
1079
- } catch (err) {
1080
- // eslint-disable-next-line no-console
1081
- console.error('label toggle POST failed', err);
1850
+ try {
1851
+ // Close just the dropdown for the action that fired — other
1852
+ // label dropdowns in the toolbar (e.g. a separate "labels"
1853
+ // grouping) stay in whatever state the user left them.
1854
+ // `actionKey` is a consumer-supplied public-API field, so
1855
+ // CSS.escape() keeps a key containing `"` or `\` from throwing
1856
+ // SyntaxError (and leaving the dropdown stuck open).
1857
+ const dropdown = this.shadowRoot?.querySelector(
1858
+ `.label-dropdown[data-action-key="${CSS.escape(actionKey)}"]`
1859
+ ) as Dropdown | null;
1860
+ if (dropdown) dropdown.open = false;
1861
+
1862
+ if (this.actionEndpoint) {
1863
+ // application/x-www-form-urlencoded matches what Django's
1864
+ // smartmin `BulkActionMixin` reads from `request.POST`, and
1865
+ // is trivial to parse server-side (URLSearchParams) without
1866
+ // pulling in a multipart parser for the demo mock.
1867
+ const params = new URLSearchParams();
1868
+ params.append('action', 'label');
1869
+ params.append('label', label.uuid);
1870
+ if (!add) params.append('add', 'false');
1871
+ originalSelectedIds.forEach((id) => params.append('objects', id));
1872
+ try {
1873
+ await postUrl(this.actionEndpoint, params);
1874
+ // Re-fetch the current page so a filtered view (e.g. a
1875
+ // label-filter) drops rows that no longer match — staying on
1876
+ // the page being acted on rather than resetting to the first.
1877
+ await this.fetchPage(this.currentUrl || undefined);
1878
+ // Re-check the ids we were operating on. Items that survived
1879
+ // the refresh stay selected; items the server filtered out
1880
+ // (label removed → no longer matches the view) are absent
1881
+ // from `this.items` and won't be re-selected. Mirrors
1882
+ // rapidpro's `recheckIds()` after a `spaPost`.
1883
+ this.recheckSelection(originalSelectedIds);
1884
+ // Only fire after the server confirms — a failed POST
1885
+ // shouldn't tell consumers (e.g. a sidebar refreshing
1886
+ // counts) that the label actually changed.
1887
+ this.fireCustomEvent(CustomEventType.BulkAction, {
1888
+ action: 'label',
1889
+ ids: originalSelectedIds,
1890
+ label: label.uuid,
1891
+ add
1892
+ });
1893
+ } catch (err) {
1894
+ // eslint-disable-next-line no-console
1895
+ console.error('label toggle POST failed', err);
1896
+ }
1897
+ } else {
1898
+ // No server round-trip — the host is fully responsible for the
1899
+ // action, so fire so it can react.
1900
+ this.fireCustomEvent(CustomEventType.BulkAction, {
1901
+ action: 'label',
1902
+ ids: originalSelectedIds,
1903
+ label: label.uuid,
1904
+ add
1905
+ });
1082
1906
  }
1907
+ } finally {
1908
+ // Always release the toggle gate, even if an early return or a
1909
+ // throw from a future edit short-circuits the round-trip — the
1910
+ // dropdown's other rows must never get permanently wedged.
1911
+ this.pendingLabel = null;
1083
1912
  }
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
1913
  }
1094
1914
 
1095
1915
  /** Re-apply a selection set against the current `items`. Used
@@ -1102,16 +1922,45 @@ export class ContentList<T = any> extends RapidElement {
1102
1922
 
1103
1923
  private toggleSearch(): void {
1104
1924
  this.searchOpen = !this.searchOpen;
1105
- if (!this.searchOpen && this.search) {
1925
+ if (this.searchOpen) {
1926
+ // Reopen with the committed query in the input so the user
1927
+ // can edit it rather than starting over.
1928
+ this.searchDraft = this.search;
1929
+ this.updateComplete.then(() => {
1930
+ const input = this.shadowRoot?.querySelector(
1931
+ '.searchbar input'
1932
+ ) as HTMLInputElement | null;
1933
+ input?.focus();
1934
+ });
1935
+ } else if (this.search) {
1936
+ // Closing while a search is active is the same as clearing
1937
+ // it — keeps the toolbar from misleading once the input is
1938
+ // gone (no clear-X to signal the active filter).
1106
1939
  this.clearSearch();
1940
+ } else {
1941
+ // No committed search, but a draft may have been typed; toss
1942
+ // it so reopening starts clean.
1943
+ this.searchDraft = '';
1107
1944
  }
1108
1945
  }
1109
1946
 
1110
1947
  private clearSearch(): void {
1948
+ this.searchDraft = '';
1949
+ if (!this.search) return;
1111
1950
  this.search = '';
1112
1951
  this.page = 1;
1113
- this.writeUrlState(true);
1952
+ // fetchPage's `finally` will clear this once the kicked-off
1953
+ // request settles, but doing it synchronously here is a UX
1954
+ // optimization: "Searching…" disappears the instant the user
1955
+ // clears, rather than flickering until the in-flight request
1956
+ // resolves.
1957
+ this.searching = false;
1958
+ // fetchPage first so currentUrl reflects the cleared search before
1959
+ // the state bubbles — see commitSearch for the full reasoning.
1960
+ // Pushes a new entry so the cleared-search view is its own back
1961
+ // step, paired with commitSearch.
1114
1962
  this.fetchPage();
1963
+ this.writeUrlState();
1115
1964
  }
1116
1965
 
1117
1966
  /** Render a status pill — convenience for subclasses. The
@@ -1129,92 +1978,306 @@ export class ContentList<T = any> extends RapidElement {
1129
1978
  return null;
1130
1979
  }
1131
1980
 
1981
+ /** Whether a column is pinned against the left edge. */
1982
+ private isLeftPinned(column: ContentListColumn): boolean {
1983
+ return column.pinned === true || column.pinned === 'left';
1984
+ }
1985
+
1986
+ /** Whether a column is pinned against the right edge. */
1987
+ private isRightPinned(column: ContentListColumn): boolean {
1988
+ return column.pinned === 'right';
1989
+ }
1990
+
1991
+ /** Recompute which leading cells + columns are pinned and assign
1992
+ * each a sticky "pin index". Called at the top of render() so the
1993
+ * header and rows agree. Left-pinned columns are expected to be
1994
+ * contiguous from the first column (the leading checkbox/icon
1995
+ * cells pin alongside them so identity stays anchored); right-
1996
+ * pinned columns contiguous to the last. */
1997
+ private computePinLayout(): void {
1998
+ // Reserve an empty leading-icon column when any row would carry
1999
+ // an icon — probe a representative row, then skip the icon
2000
+ // per-row if that row's own getRowIcon returns null.
2001
+ this.reservesIcon =
2002
+ this.items.length > 0 && this.getRowIcon(this.items[0]) !== null;
2003
+ this.pinIndexByColumn = new Map();
2004
+ this.rightPinIndexByColumn = new Map();
2005
+ this.checkPinIndex = -1;
2006
+ this.iconPinIndex = -1;
2007
+ this.lastPinIndex = -1;
2008
+ this.firstRightPinIndex = -1;
2009
+ this.spacerAfterIndex = -1;
2010
+
2011
+ // Right-pinned columns are contiguous at the end; walk inward
2012
+ // from the last column, numbering 0 = rightmost.
2013
+ let ridx = 0;
2014
+ for (let i = this.columns.length - 1; i >= 0; i--) {
2015
+ if (!this.isRightPinned(this.columns[i])) break;
2016
+ this.rightPinIndexByColumn.set(this.columns[i], ridx++);
2017
+ }
2018
+ this.firstRightPinIndex = ridx - 1;
2019
+
2020
+ // Left-pinned columns + the leading checkbox/icon cells.
2021
+ const leftPinnedCount = this.columns.filter((c) =>
2022
+ this.isLeftPinned(c)
2023
+ ).length;
2024
+ if (leftPinnedCount === 0) return;
2025
+ let idx = 0;
2026
+ if (this.hasCheckboxes) this.checkPinIndex = idx++;
2027
+ if (this.reservesIcon) this.iconPinIndex = idx++;
2028
+ this.columns.forEach((c) => {
2029
+ if (this.isLeftPinned(c)) this.pinIndexByColumn.set(c, idx++);
2030
+ });
2031
+ this.lastPinIndex = idx - 1;
2032
+ // Left-pinned columns are contiguous from the start, so the last
2033
+ // one sits at column index leftPinnedCount - 1; the spacer
2034
+ // follows it — unless a `grow` column is present, in which case
2035
+ // that column already pools the slack and a spacer would only
2036
+ // split it.
2037
+ this.spacerAfterIndex = this.columns.some((c) => c.grow)
2038
+ ? -1
2039
+ : leftPinnedCount - 1;
2040
+ }
2041
+
2042
+ /** `pinned` (+ `pin-last` for the rightmost left-pinned cell)
2043
+ * class string for a left-pinned leading cell at the given pin
2044
+ * index, or '' when unpinned. */
2045
+ private pinClass(index: number): string {
2046
+ if (index < 0) return '';
2047
+ return index === this.lastPinIndex ? 'pinned pin-last' : 'pinned';
2048
+ }
2049
+
2050
+ /** Sticky `left` for a left-pinned cell — resolved from a per-
2051
+ * index CSS var that {@link measurePinOffsets} sets from the real
2052
+ * header cell widths after each render. */
2053
+ private pinStyle(index: number): string {
2054
+ return index < 0 ? '' : `left: var(--cl-pin-${index}, 0px);`;
2055
+ }
2056
+
2057
+ /** Pin class string for a column cell (header or body) — handles
2058
+ * both edges: `pin-last` marks the inboard edge of the left group,
2059
+ * `pin-first` the inboard edge of the right group. */
2060
+ private columnPinClass(column: ContentListColumn): string {
2061
+ const left = this.pinIndexByColumn.get(column);
2062
+ if (left != null) return this.pinClass(left);
2063
+ const right = this.rightPinIndexByColumn.get(column);
2064
+ if (right != null) {
2065
+ return right === this.firstRightPinIndex
2066
+ ? 'pinned pin-right pin-first'
2067
+ : 'pinned pin-right';
2068
+ }
2069
+ return '';
2070
+ }
2071
+
2072
+ /** Sticky `left`/`right` style for a column cell, resolved from
2073
+ * the per-index CSS vars {@link measurePinOffsets} publishes. */
2074
+ private columnPinStyle(column: ContentListColumn): string {
2075
+ const left = this.pinIndexByColumn.get(column);
2076
+ if (left != null) return `left: var(--cl-pin-${left}, 0px);`;
2077
+ const right = this.rightPinIndexByColumn.get(column);
2078
+ if (right != null) return `right: var(--cl-rpin-${right}, 0px);`;
2079
+ return '';
2080
+ }
2081
+
2082
+ /** Width contract for a column's inner wrapper — a hard `width`
2083
+ * when set, otherwise optional min/max bounds. With neither bound
2084
+ * the column simply sizes to its content (header label or widest
2085
+ * value) via the table's auto layout. */
2086
+ private cellWidthStyle(column: ContentListColumn): string {
2087
+ // Under fixed layout the column widths are set on the cells
2088
+ // themselves (see {@link renderHeaderCell}); the inner wrapper
2089
+ // just fills its cell and ellipsis-truncates against it.
2090
+ if (this.fixedLayout) return '';
2091
+ if (column.width) return `width: ${column.width};`;
2092
+ const parts: string[] = [];
2093
+ if (column.minWidth) parts.push(`min-width: ${column.minWidth};`);
2094
+ // A grow column drops the upper cap so it can stretch with the
2095
+ // table; every other column caps its content-driven width.
2096
+ if (!column.grow) parts.push(`max-width: ${column.maxWidth || '320px'};`);
2097
+ return parts.join(' ');
2098
+ }
2099
+
2100
+ /** Column count for the empty/loading row's colspan — includes
2101
+ * the leading cells and the slack spacer when present. */
2102
+ private colSpan(): number {
2103
+ return (
2104
+ (this.hasCheckboxes ? 1 : 0) +
2105
+ (this.reservesIcon ? 1 : 0) +
2106
+ (this.spacerAfterIndex >= 0 ? 1 : 0) +
2107
+ this.columns.length
2108
+ );
2109
+ }
2110
+
2111
+ /** Measure the header's pinned cells and publish a cumulative
2112
+ * `left` offset per pin index as a CSS var on the host. Pinned
2113
+ * cells (header + body) read these via {@link pinStyle}. Pinned
2114
+ * columns size to content, so this re-runs after every render. */
2115
+ private measurePinOffsets(): void {
2116
+ const headRow = this.shadowRoot?.querySelector('tr.header');
2117
+ if (!headRow) return;
2118
+ const cells = Array.from(headRow.children) as HTMLElement[];
2119
+ // Left group — cumulative `left` offset, walking from the start.
2120
+ let offset = 0;
2121
+ let idx = 0;
2122
+ for (const cell of cells) {
2123
+ if (!cell.classList.contains('pinned')) break;
2124
+ if (cell.classList.contains('pin-right')) break;
2125
+ this.style.setProperty(`--cl-pin-${idx}`, `${offset}px`);
2126
+ offset += cell.offsetWidth;
2127
+ idx++;
2128
+ }
2129
+ // Right group — cumulative `right` offset, walking from the end.
2130
+ let roffset = 0;
2131
+ let ridx = 0;
2132
+ for (let i = cells.length - 1; i >= 0; i--) {
2133
+ if (!cells[i].classList.contains('pin-right')) break;
2134
+ this.style.setProperty(`--cl-rpin-${ridx}`, `${roffset}px`);
2135
+ roffset += cells[i].offsetWidth;
2136
+ ridx++;
2137
+ }
2138
+ // Total width of the right-pinned group — the scroll gradient
2139
+ // is inset by this so it lands just left of the frozen columns.
2140
+ this.style.setProperty('--cl-rpin-total', `${roffset}px`);
2141
+ }
2142
+
2143
+ /** Refresh the horizontal-scroll affordances — whether the table
2144
+ * overflows at all (`overflowing`, which gates the pinned-column
2145
+ * tint + divider), the pinned-column divider shadow (table
2146
+ * scrolled off its start) and the right-edge fade (more table
2147
+ * hidden to the right). These are purely presentational, so the
2148
+ * classes are toggled straight on the frame rather than through
2149
+ * reactive state — that keeps a scroll (or a post-render
2150
+ * re-measure) from scheduling another render. */
2151
+ private syncScrollAffordance(): void {
2152
+ const frame = this.shadowRoot?.querySelector(
2153
+ '.table-frame'
2154
+ ) as HTMLElement | null;
2155
+ const scroller = this.shadowRoot?.querySelector(
2156
+ '.table-scroll'
2157
+ ) as HTMLElement | null;
2158
+ if (!frame || !scroller) return;
2159
+ const maxScroll = scroller.scrollWidth - scroller.clientWidth;
2160
+ // Below a 1px slack the table fits and there is nothing to
2161
+ // scroll — the pinned columns then read as plain columns.
2162
+ frame.classList.toggle('overflowing', maxScroll > 1);
2163
+ frame.classList.toggle('scrolled', scroller.scrollLeft > 1);
2164
+ frame.classList.toggle(
2165
+ 'can-scroll-right',
2166
+ scroller.scrollLeft < maxScroll - 1
2167
+ );
2168
+ // Height of the horizontal scrollbar (0 for overlay scrollbars)
2169
+ // — the scroll gradient is lifted by this so it never paints
2170
+ // over the scrollbar track.
2171
+ this.style.setProperty(
2172
+ '--cl-scrollbar',
2173
+ `${scroller.offsetHeight - scroller.clientHeight}px`
2174
+ );
2175
+ }
2176
+
1132
2177
  private renderHeader(): TemplateResult {
1133
2178
  const allIds = this.items.map((i) => this.rowId(i));
1134
2179
  const allSelected =
1135
2180
  allIds.length > 0 && allIds.every((id) => this.selectedIds.has(id));
1136
2181
  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
2182
 
1144
2183
  return html`
1145
- <div class="header">
1146
- ${this.selectable
1147
- ? html`
1148
- <div class="check-cell" @click=${() => this.handleSelectAll()}>
1149
- <temba-checkbox
1150
- size="1.1"
1151
- ?checked=${allSelected}
1152
- ?partial=${someSelected}
1153
- ></temba-checkbox>
1154
- </div>
1155
- `
1156
- : null}
1157
- ${reservesIcon ? html`<div class="icon-cell"></div>` : null}
1158
- ${this.columns.map((c) => this.renderHeaderCell(c))}
1159
- </div>
2184
+ <thead>
2185
+ <tr class="header">
2186
+ ${this.hasCheckboxes
2187
+ ? html`
2188
+ <th
2189
+ class="check-cell ${this.pinClass(this.checkPinIndex)}"
2190
+ style=${this.pinStyle(this.checkPinIndex)}
2191
+ @click=${() => this.handleSelectAll()}
2192
+ >
2193
+ <div class="check-inner">
2194
+ <temba-checkbox
2195
+ size="1.1"
2196
+ ?checked=${allSelected}
2197
+ ?partial=${someSelected}
2198
+ ></temba-checkbox>
2199
+ </div>
2200
+ </th>
2201
+ `
2202
+ : null}
2203
+ ${this.reservesIcon
2204
+ ? html`<th
2205
+ class="icon-cell ${this.pinClass(this.iconPinIndex)}"
2206
+ style=${this.pinStyle(this.iconPinIndex)}
2207
+ ></th>`
2208
+ : null}
2209
+ ${this.columns.map((c, i) =>
2210
+ i === this.spacerAfterIndex
2211
+ ? html`${this.renderHeaderCell(c)}
2212
+ <th class="spacer"></th>`
2213
+ : this.renderHeaderCell(c)
2214
+ )}
2215
+ </tr>
2216
+ </thead>
1160
2217
  `;
1161
2218
  }
1162
2219
 
1163
2220
  private renderHeaderCell(column: ContentListColumn): TemplateResult {
1164
- const style = this.columnStyle(column);
1165
2221
  const active = this.sort === column.key || this.sort === '-' + column.key;
1166
2222
  const desc = this.sort === '-' + column.key;
1167
- // Only sortable columns get a click handler, the `sortable`
1168
- // class (which paints the cursor + hover state), and the
1169
- // direction icon. Non-sortable headers render as plain text.
1170
- if (column.sortable) {
1171
- return html`
1172
- <div
1173
- class="head-cell ${column.align || ''} sortable ${active
1174
- ? 'active'
1175
- : ''}"
1176
- style=${style}
1177
- @click=${() => this.handleSortClick(column)}
1178
- >
1179
- <span>${column.label ?? column.key}</span>
1180
- <temba-icon
2223
+ const cls = `head-cell ${column.align || ''} ${
2224
+ column.sortable ? 'sortable' : ''
2225
+ } ${active ? 'active' : ''} ${
2226
+ column.grow ? 'grow' : ''
2227
+ } ${this.columnPinClass(column)}`;
2228
+ // The sort arrow sits on the inboard side of the label — left of
2229
+ // it for right-aligned columns, right of it otherwise — so the
2230
+ // label stays flush with the column's values whichever way the
2231
+ // column is aligned, with no offset to reconcile. Its slot is a
2232
+ // fixed width, reserved even while the arrow is hidden, so the
2233
+ // label never shifts when the arrow appears on hover.
2234
+ const label = html`<span class="label"
2235
+ >${column.label ?? column.key}</span
2236
+ >`;
2237
+ const slot = column.sortable
2238
+ ? html`<span class="sort-slot"
2239
+ ><temba-icon
2240
+ class="sort-icon"
1181
2241
  name=${active ? (desc ? Icon.sort_down : Icon.sort_up) : Icon.sort}
1182
2242
  size="0.85"
1183
- ></temba-icon>
1184
- </div>
1185
- `;
1186
- }
2243
+ ></temba-icon
2244
+ ></span>`
2245
+ : null;
2246
+ // Under fixed layout the header row drives the column widths, so
2247
+ // each `width`-set column carries its width on the cell itself;
2248
+ // the grow column is left unsized to claim the remainder.
2249
+ const widthStyle =
2250
+ this.fixedLayout && column.width ? `width: ${column.width};` : '';
1187
2251
  return html`
1188
- <div class="head-cell ${column.align || ''}" style=${style}>
1189
- <span>${column.label ?? column.key}</span>
1190
- </div>
2252
+ <th
2253
+ class=${cls}
2254
+ style="${this.columnPinStyle(column)} ${widthStyle}"
2255
+ @click=${column.sortable ? () => this.handleSortClick(column) : null}
2256
+ >
2257
+ <div class="head-inner" style=${this.cellWidthStyle(column)}>
2258
+ ${column.align === 'right'
2259
+ ? html`${slot}${label}`
2260
+ : html`${label}${slot}`}
2261
+ </div>
2262
+ </th>
1191
2263
  `;
1192
2264
  }
1193
2265
 
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
2266
  private renderRow(item: T): TemplateResult {
1205
2267
  const id = this.rowId(item);
1206
2268
  const selected = this.selectedIds.has(id);
1207
2269
  const href = this.getRowHref(item);
1208
2270
  const icon = this.getRowIcon(item);
1209
2271
  return html`
1210
- <div
2272
+ <tr
1211
2273
  class="row ${selected ? 'selected' : ''} ${href ? 'clickable' : ''}"
1212
2274
  @click=${(e: MouseEvent) => this.handleRowClick(item, e)}
1213
2275
  >
1214
- ${this.selectable
2276
+ ${this.hasCheckboxes
1215
2277
  ? html`
1216
- <div
1217
- class="check-cell"
2278
+ <td
2279
+ class="check-cell ${this.pinClass(this.checkPinIndex)}"
2280
+ style=${this.pinStyle(this.checkPinIndex)}
1218
2281
  @click=${(e: MouseEvent) => {
1219
2282
  // Cell-level click is the single source of truth
1220
2283
  // for selection. The inner checkbox has
@@ -1224,74 +2287,137 @@ export class ContentList<T = any> extends RapidElement {
1224
2287
  this.handleRowToggle(item);
1225
2288
  }}
1226
2289
  >
1227
- <temba-checkbox
1228
- size="1.1"
1229
- ?checked=${selected}
1230
- ></temba-checkbox>
1231
- </div>
2290
+ <div class="check-inner">
2291
+ <temba-checkbox
2292
+ size="1.1"
2293
+ ?checked=${selected}
2294
+ ></temba-checkbox>
2295
+ </div>
2296
+ </td>
1232
2297
  `
1233
2298
  : null}
1234
- ${icon
2299
+ ${this.reservesIcon
1235
2300
  ? html`
1236
- <div class="icon-cell">
1237
- <temba-icon name=${icon} size="1"></temba-icon>
1238
- </div>
2301
+ <td
2302
+ class="icon-cell ${this.pinClass(this.iconPinIndex)}"
2303
+ style=${this.pinStyle(this.iconPinIndex)}
2304
+ >
2305
+ ${icon
2306
+ ? html`<div class="icon-inner">
2307
+ <temba-icon name=${icon} size="1"></temba-icon>
2308
+ </div>`
2309
+ : null}
2310
+ </td>
1239
2311
  `
1240
2312
  : null}
1241
- ${this.columns.map(
1242
- (c) => html`
1243
- <div class="cell ${c.align || ''}" style=${this.columnStyle(c)}>
1244
- ${this.renderCell(item, c)}
1245
- </div>
1246
- `
2313
+ ${this.columns.map((c, i) =>
2314
+ i === this.spacerAfterIndex
2315
+ ? html`${this.renderBodyCell(item, c)}
2316
+ <td class="spacer"></td>`
2317
+ : this.renderBodyCell(item, c)
1247
2318
  )}
1248
- </div>
2319
+ </tr>
2320
+ `;
2321
+ }
2322
+
2323
+ private renderBodyCell(item: T, column: ContentListColumn): TemplateResult {
2324
+ return html`
2325
+ <td
2326
+ class="cell ${column.align || ''} ${column.grow
2327
+ ? 'grow'
2328
+ : ''} ${this.columnPinClass(column)}"
2329
+ style=${this.columnPinStyle(column)}
2330
+ >
2331
+ <div class="cell-inner" style=${this.cellWidthStyle(column)}>
2332
+ ${this.renderCell(item, column)}
2333
+ </div>
2334
+ </td>
1249
2335
  `;
1250
2336
  }
1251
2337
 
1252
- private renderFooter(): TemplateResult {
2338
+ /** The pager — a compact "‹ 1–N of Total ›" stepper for the
2339
+ * header's actions cluster. A cursor list has no total, so it
2340
+ * shows chevrons only, gated on whether the last response handed
2341
+ * back a cursor for that direction. Returns nothing when there is
2342
+ * neither a page to move to nor a count worth showing. */
2343
+ private renderPager(): TemplateResult {
1253
2344
  const lastPage = Math.max(1, Math.ceil(this.total / this.pageSize));
1254
2345
  const first = this.total === 0 ? 0 : (this.page - 1) * this.pageSize + 1;
1255
2346
  const last = Math.min(this.total, this.page * this.pageSize);
2347
+ const atStart = this.cursorMode ? !this.prevCursor : this.page <= 1;
2348
+ const atEnd = this.cursorMode ? !this.nextCursor : this.page >= lastPage;
2349
+ if (this.cursorMode ? atStart && atEnd : this.total === 0) {
2350
+ return html``;
2351
+ }
1256
2352
  return html`
1257
- <div class="footer">
1258
- <div class="status">
1259
- ${this.total > 0 ? html`${first}&ndash;${last} of ${this.total}` : ''}
1260
- </div>
1261
- <div class="pager">
1262
- <span
1263
- class="page-btn"
1264
- ?disabled=${this.page <= 1}
1265
- @click=${() => this.handlePage(-1)}
1266
- aria-label="Previous page"
1267
- >
1268
- <temba-icon name=${Icon.arrow_left} size="1"></temba-icon>
1269
- </span>
1270
- <span
1271
- class="page-btn"
1272
- ?disabled=${this.page >= lastPage}
1273
- @click=${() => this.handlePage(1)}
1274
- aria-label="Next page"
1275
- >
1276
- <temba-icon name=${Icon.arrow_right} size="1"></temba-icon>
1277
- </span>
1278
- </div>
2353
+ <div class="pager">
2354
+ <span
2355
+ class="page-btn"
2356
+ ?disabled=${atStart}
2357
+ @click=${() => this.handlePage(-1)}
2358
+ aria-label="Previous page"
2359
+ >
2360
+ <temba-icon name=${Icon.arrow_left} size="1"></temba-icon>
2361
+ </span>
2362
+ ${!this.cursorMode
2363
+ ? html`<span class="pager-status"
2364
+ >${first}&ndash;${last} of ${this.total}</span
2365
+ >`
2366
+ : null}
2367
+ <span
2368
+ class="page-btn"
2369
+ ?disabled=${atEnd}
2370
+ @click=${() => this.handlePage(1)}
2371
+ aria-label="Next page"
2372
+ >
2373
+ <temba-icon name=${Icon.arrow_right} size="1"></temba-icon>
2374
+ </span>
1279
2375
  </div>
1280
2376
  `;
1281
2377
  }
1282
2378
 
1283
2379
  public render(): TemplateResult {
2380
+ // Pin layout depends on the current columns + items, so resolve
2381
+ // it once per render before the header and rows are built.
2382
+ this.computePinLayout();
2383
+ const span = this.colSpan();
1284
2384
  return html`
1285
2385
  <div class="panel">
1286
2386
  ${this.renderTitlebar()}
1287
2387
  <div class="header-rule"></div>
1288
- ${this.renderHeader()}
1289
- ${this.loading && this.items.length === 0
1290
- ? html`<div class="loading">Loading&hellip;</div>`
1291
- : this.items.length === 0
1292
- ? html`<div class="empty">${this.emptyMessage}</div>`
1293
- : this.items.map((i) => this.renderRow(i))}
1294
- ${this.renderFooter()}
2388
+ <div class="table-frame">
2389
+ <div
2390
+ class="table-scroll"
2391
+ @scroll=${() => this.syncScrollAffordance()}
2392
+ >
2393
+ <table
2394
+ class="table ${this.fixedLayout ? 'fixed' : ''}"
2395
+ style=${this.minTableWidth
2396
+ ? `min-width: ${this.minTableWidth};`
2397
+ : ''}
2398
+ >
2399
+ ${this.renderHeader()}
2400
+ <tbody>
2401
+ ${this.searching
2402
+ ? html`<tr>
2403
+ <td class="loading" colspan=${span}>Searching&hellip;</td>
2404
+ </tr>`
2405
+ : this.loading && this.items.length === 0
2406
+ ? html`<tr>
2407
+ <td class="loading" colspan=${span}>Loading&hellip;</td>
2408
+ </tr>`
2409
+ : this.items.length === 0
2410
+ ? html`<tr>
2411
+ <td class="empty" colspan=${span}>
2412
+ ${this.emptyMessage}
2413
+ </td>
2414
+ </tr>`
2415
+ : this.items.map((i) => this.renderRow(i))}
2416
+ </tbody>
2417
+ </table>
2418
+ </div>
2419
+ <div class="scroll-shadow"></div>
2420
+ </div>
1295
2421
  </div>
1296
2422
  `;
1297
2423
  }