@nyaruka/temba-components 0.158.1 → 0.159.0

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