@nyaruka/temba-components 0.159.3 → 0.159.5

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