@nyaruka/temba-components 0.159.3 → 0.159.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,50 @@ 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 shared by every row
922
+ (contact silhouette, flow type icon, etc.). It rides inside the
923
+ first column's cell rather than in its own column, so the column
924
+ header aligns with the icon (the row's leading content) rather
925
+ than the value beside it — and the alignment reads the same
926
+ whether or not the list has a leading icon. Subclasses override
927
+ {@link getRowIcon}; when it returns null for every row no space
928
+ is reserved (see {@link reservesIcon}). */
929
+ .lead-wrap {
930
+ display: flex;
931
+ align-items: center;
932
+ min-width: 0;
933
+ }
934
+ .lead-wrap .cell-inner {
935
+ min-width: 0;
718
936
  }
719
- /* Reserve the icon's footprint 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 {
937
+ /* Reserve the icon's 1em footprint plus a snug 5px gap to the
938
+ value whether or not this row has an icon, so values stay
939
+ aligned down the column. The fixed box also keeps the column's
940
+ intrinsic width stable while <temba-icon> upgrades without it
941
+ the column briefly measures narrow and downstream pinned columns
942
+ jump, which races with whatever moment we snapshot. */
943
+ .lead-icon {
944
+ flex: 0 0 auto;
728
945
  display: flex;
729
946
  align-items: center;
730
947
  justify-content: center;
731
948
  width: 1em;
732
949
  height: 1em;
950
+ margin-right: 5px;
951
+ --icon-color: var(--text-3);
733
952
  }
734
- tr.row.selected .icon-cell {
953
+ tr.row.selected .lead-icon {
735
954
  --icon-color: var(--accent-700);
736
955
  }
737
956
 
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. */
957
+ /* The first column follows the checkbox directly and drops its
958
+ left padding, so its content starts right at the checkbox
959
+ cell's 4px trailing gap: the leading icon on icon lists, the
960
+ value otherwise and the header label lines up at that same
961
+ point. The table-frame sits inside the panel's 20px padding, so
962
+ that edge already aligns with the page-header content; the last
963
+ cell trims its padding-right (below) so content doesn't crowd
964
+ the card chrome. */
754
965
  .check-cell + .head-cell,
755
966
  .check-cell + .cell {
756
967
  padding-left: 0;
@@ -760,11 +971,41 @@ export class ContentList<T = any> extends RapidElement {
760
971
  padding-right: 20px;
761
972
  }
762
973
 
763
- .empty,
764
- .loading {
765
- padding: 40px var(--pad);
766
- text-align: center;
974
+ /* Empty / loading / searching message — rendered after the table
975
+ (not as a colspan row inside it) and positioned over the body
976
+ area so it stays centered in the visible container rather than
977
+ the full, possibly-overflowing table width. Sits below the
978
+ header (--cl-header-height) and inboard of the vertical
979
+ scrollbar (--cl-scrollbar-w). */
980
+ .list-state {
981
+ position: absolute;
982
+ top: var(--cl-header-height, 36px);
983
+ left: 0;
984
+ right: var(--cl-scrollbar-w, 0px);
985
+ bottom: 0;
986
+ display: flex;
987
+ align-items: flex-start;
988
+ justify-content: center;
989
+ padding-top: 40px;
767
990
  color: var(--text-3);
991
+ pointer-events: none;
992
+ }
993
+ /* Query-validation error — the same centered empty-table slot,
994
+ but a danger-tinted pill (icon + message) so a bad search reads
995
+ as something to fix rather than an empty result set. */
996
+ .list-state.error {
997
+ color: var(--danger);
998
+ }
999
+ .list-state .state-error {
1000
+ display: inline-flex;
1001
+ align-items: center;
1002
+ gap: 7px;
1003
+ max-width: min(560px, 80%);
1004
+ padding: 8px 14px;
1005
+ border: 1px solid var(--danger-border);
1006
+ border-radius: var(--r);
1007
+ background: var(--danger-bg);
1008
+ --icon-color: var(--danger);
768
1009
  }
769
1010
 
770
1011
  /* Pager — a compact "‹ 1–N of Total ›" stepper that lives in
@@ -871,6 +1112,26 @@ export class ContentList<T = any> extends RapidElement {
871
1112
  @property({ type: String, attribute: 'content-menu-endpoint' })
872
1113
  contentMenuEndpoint = '';
873
1114
 
1115
+ /** The content-menu endpoint with the current committed search folded
1116
+ * in, so the server's build_context_menu sees the same query the list
1117
+ * is showing. This is what surfaces search-dependent menu items — e.g.
1118
+ * the contact list's "Create Smart Group" button, which only appears
1119
+ * when the active query is saveable as a group. Binding the
1120
+ * page-header attribute to this (rather than the raw endpoint) makes
1121
+ * the menu re-fetch whenever the committed search changes. */
1122
+ private contentMenuEndpointWithSearch(): string {
1123
+ if (!this.contentMenuEndpoint || !this.search) {
1124
+ return this.contentMenuEndpoint;
1125
+ }
1126
+ try {
1127
+ const url = new URL(this.contentMenuEndpoint, window.location.origin);
1128
+ url.searchParams.set('search', this.search);
1129
+ return url.pathname + url.search;
1130
+ } catch {
1131
+ return this.contentMenuEndpoint;
1132
+ }
1133
+ }
1134
+
874
1135
  /** Column definitions. Subclasses set this in the constructor;
875
1136
  * consumers may also override at the element level. */
876
1137
  @property({ type: Array, attribute: false })
@@ -1055,6 +1316,12 @@ export class ContentList<T = any> extends RapidElement {
1055
1316
  @state()
1056
1317
  protected searching = false;
1057
1318
 
1319
+ /** Query-validation error from the last search (e.g. `age >`). Shown
1320
+ * over the empty table in place of the "nothing to show" copy, and
1321
+ * cleared on the next fetch. */
1322
+ @state()
1323
+ protected searchError = '';
1324
+
1058
1325
  @state()
1059
1326
  protected selectedIds: Set<string> = new Set();
1060
1327
 
@@ -1084,6 +1351,16 @@ export class ContentList<T = any> extends RapidElement {
1084
1351
  @state()
1085
1352
  protected pendingLabel: string | null = null;
1086
1353
 
1354
+ /** When the bulk-action bar's chips would overflow the available
1355
+ * width, their labels collapse to icon-only (animated). Measured in
1356
+ * {@link updateBulkCollapse}. */
1357
+ @state()
1358
+ private bulkCollapsed = false;
1359
+
1360
+ /** Pending rAF handle for the deferred collapse re-measure (0 when
1361
+ * none is scheduled). See {@link updateBulkCollapse}. */
1362
+ private bulkCollapseFrame = 0;
1363
+
1087
1364
  private pending: AbortController = null;
1088
1365
  private popstateHandler: () => void;
1089
1366
  private resizeHandler: () => void;
@@ -1096,7 +1373,6 @@ export class ContentList<T = any> extends RapidElement {
1096
1373
  * `right`. Recomputed each render. */
1097
1374
  private rightPinIndexByColumn = new Map<ContentListColumn, number>();
1098
1375
  private checkPinIndex = -1;
1099
- private iconPinIndex = -1;
1100
1376
  private lastPinIndex = -1;
1101
1377
  /** Right-pin index of the leftmost right-pinned column — the one
1102
1378
  * that carries the divider against the scrolling section. */
@@ -1105,7 +1381,8 @@ export class ContentList<T = any> extends RapidElement {
1105
1381
  * rendered (the last pinned column), or -1 when nothing is
1106
1382
  * pinned. Extra table width pools in that spacer. */
1107
1383
  private spacerAfterIndex = -1;
1108
- /** Whether the current items reserve a leading-icon column. */
1384
+ /** Whether the current items reserve leading-icon space in the first
1385
+ * column (true when a representative row carries an icon). */
1109
1386
  private reservesIcon = false;
1110
1387
 
1111
1388
  constructor() {
@@ -1145,7 +1422,10 @@ export class ContentList<T = any> extends RapidElement {
1145
1422
  }
1146
1423
  // A viewport resize changes whether the table overflows, so the
1147
1424
  // right-edge scroll affordance has to be re-evaluated.
1148
- this.resizeHandler = () => this.syncScrollAffordance();
1425
+ this.resizeHandler = () => {
1426
+ this.syncScrollAffordance();
1427
+ this.updateBulkCollapse();
1428
+ };
1149
1429
  window.addEventListener('resize', this.resizeHandler);
1150
1430
  // Pinned columns now size to their content, so a late web-font
1151
1431
  // load shifts their widths — re-measure the sticky offsets once
@@ -1165,6 +1445,10 @@ export class ContentList<T = any> extends RapidElement {
1165
1445
  if (this.resizeHandler) {
1166
1446
  window.removeEventListener('resize', this.resizeHandler);
1167
1447
  }
1448
+ if (this.bulkCollapseFrame) {
1449
+ cancelAnimationFrame(this.bulkCollapseFrame);
1450
+ this.bulkCollapseFrame = 0;
1451
+ }
1168
1452
  if (this.pending) {
1169
1453
  // Null the pending pointer before aborting so fetchPage's
1170
1454
  // finally block — which gates cleanup on `this.pending ===
@@ -1199,6 +1483,47 @@ export class ContentList<T = any> extends RapidElement {
1199
1483
  // on the freshly-laid-out DOM, so settle them after each render.
1200
1484
  this.measurePinOffsets();
1201
1485
  this.syncScrollAffordance();
1486
+ this.updateBulkCollapse();
1487
+ }
1488
+
1489
+ /** Collapse the bulk-action labels to icon-only when the chips would
1490
+ * overflow the bar. Measures synchronously for immediate feedback,
1491
+ * then re-measures on the next frame: the chip `<temba-icon>`s are
1492
+ * child custom elements that render their SVG in a *later* update
1493
+ * cycle, so the moment the bar first appears they still have zero
1494
+ * width and the chips read as narrow — the bar looks like it fits and
1495
+ * doesn't collapse until a later nudge (a second selection or a
1496
+ * resize) re-runs the measurement. The deferred pass settles it on
1497
+ * first show, once the icons have laid out. */
1498
+ private updateBulkCollapse(): void {
1499
+ this.measureBulkCollapse();
1500
+ if (this.bulkCollapseFrame) cancelAnimationFrame(this.bulkCollapseFrame);
1501
+ this.bulkCollapseFrame = requestAnimationFrame(() => {
1502
+ this.bulkCollapseFrame = 0;
1503
+ this.measureBulkCollapse();
1504
+ });
1505
+ }
1506
+
1507
+ /** One overflow measurement. The decision is made against the
1508
+ * fully-expanded width with transitions suppressed (the `measuring`
1509
+ * class) — reading scrollWidth mid-animation otherwise returns a width
1510
+ * between the collapsed and expanded states, which made the collapse
1511
+ * flip-flop and never settle. The expanded layout is forced and the
1512
+ * current state restored synchronously (no paint in between) so
1513
+ * there's no flash, then transitions are re-enabled so a real state
1514
+ * change animates. */
1515
+ private measureBulkCollapse(): void {
1516
+ const bar = this.shadowRoot?.querySelector(
1517
+ '.bulk-bar'
1518
+ ) as HTMLElement | null;
1519
+ if (!bar) return;
1520
+ bar.classList.add('measuring');
1521
+ bar.classList.remove('collapsed');
1522
+ const overflows = bar.scrollWidth > bar.clientWidth + 1;
1523
+ bar.classList.toggle('collapsed', this.bulkCollapsed);
1524
+ void bar.offsetWidth;
1525
+ bar.classList.remove('measuring');
1526
+ if (overflows !== this.bulkCollapsed) this.bulkCollapsed = overflows;
1202
1527
  }
1203
1528
 
1204
1529
  /** Read sort/page/search from the URL on first load / popstate. */
@@ -1378,11 +1703,22 @@ export class ContentList<T = any> extends RapidElement {
1378
1703
  const controller = new AbortController();
1379
1704
  this.pending = controller;
1380
1705
  this.loading = true;
1706
+ // Drop any prior query error so it doesn't linger behind the new
1707
+ // request's loading/searching state.
1708
+ this.searchError = '';
1709
+ // Whether this fetch is a search commit (vs. paging/refresh) —
1710
+ // captured up front since `searching` is cleared in `finally`. Only
1711
+ // a search adopts a server-adjusted query and refocuses the input.
1712
+ const wasSearch = this.searching;
1381
1713
  const requestUrl = url || this.buildRequestUrl();
1382
1714
  this.currentUrl = requestUrl;
1383
1715
  try {
1384
1716
  const response = await getUrl(requestUrl, controller);
1385
1717
  const data = (response.json || {}) as FetchResponse<T>;
1718
+ // A query-validation error comes back list-shaped (status 200,
1719
+ // empty results) with an `error` message — surface it over the
1720
+ // empty table rather than the plain empty-state copy.
1721
+ this.searchError = typeof data.error === 'string' ? data.error : '';
1386
1722
  this.items = data.results || [];
1387
1723
  this.nextCursor = data.next ? this.toRequestUrl(data.next) : '';
1388
1724
  this.prevCursor = data.previous ? this.toRequestUrl(data.previous) : '';
@@ -1411,6 +1747,17 @@ export class ContentList<T = any> extends RapidElement {
1411
1747
  if (visible.has(id)) next.add(id);
1412
1748
  });
1413
1749
  this.selectedIds = next;
1750
+ // If the server echoed an adjusted/normalized query for this
1751
+ // search, adopt it as the basis of the results and mirror it back
1752
+ // into the input — so the box shows exactly what the results
1753
+ // reflect (and the Search button stays hidden until the user
1754
+ // edits away from it again). Keep the URL in step via a replacing
1755
+ // write so it carries the normalized form without a new entry.
1756
+ if (wasSearch && typeof data.query === 'string') {
1757
+ this.search = data.query;
1758
+ this.searchDraft = data.query;
1759
+ this.writeUrlState(true);
1760
+ }
1414
1761
  } catch (err) {
1415
1762
  // aborted or failed; leave items as-is and let the caller see
1416
1763
  // the empty/error state via console — no toast to keep the
@@ -1424,11 +1771,30 @@ export class ContentList<T = any> extends RapidElement {
1424
1771
  this.pending = null;
1425
1772
  this.loading = false;
1426
1773
  this.searching = false;
1774
+ // The input was disabled while the search ran; once it
1775
+ // re-enables, restore focus and drop the cursor at the end of
1776
+ // the (possibly adjusted) query so the user can keep editing
1777
+ // without re-clicking into the box.
1778
+ if (wasSearch && this.searchOpen) {
1779
+ this.updateComplete.then(() => this.focusSearchEnd());
1780
+ }
1427
1781
  this.fireCustomEvent(CustomEventType.FetchComplete);
1428
1782
  }
1429
1783
  }
1430
1784
  }
1431
1785
 
1786
+ /** Focus the search input and place the caret at the end of its
1787
+ * value — used after a search settles and the box re-enables. */
1788
+ private focusSearchEnd(): void {
1789
+ const input = this.shadowRoot?.querySelector(
1790
+ '.searchbar input'
1791
+ ) as HTMLInputElement | null;
1792
+ if (!input) return;
1793
+ input.focus();
1794
+ const end = input.value.length;
1795
+ input.setSelectionRange(end, end);
1796
+ }
1797
+
1432
1798
  /** Public API — programmatic refresh, mirrors `refreshKey` bump.
1433
1799
  * Re-requests the current page (cursor lists included) rather than
1434
1800
  * resetting to the first. */
@@ -1467,19 +1833,16 @@ export class ContentList<T = any> extends RapidElement {
1467
1833
  this.searchDraft = event.target.value || '';
1468
1834
  }
1469
1835
 
1470
- /** Commit on Enter; let other keys through. Escape clears the
1471
- * draft (so the user can bail without firing a search). */
1836
+ /** Commit on Enter; let other keys through. Escape closes the bar
1837
+ * outright (clearing any active search, mirroring toggleSearch's
1838
+ * close path) — the keyboard counterpart to the close (✕) button. */
1472
1839
  private handleSearchKey(event: KeyboardEvent): void {
1473
1840
  if (event.key === 'Enter') {
1474
1841
  event.preventDefault();
1475
1842
  this.commitSearch();
1476
1843
  } else if (event.key === 'Escape') {
1477
1844
  event.preventDefault();
1478
- 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
- }
1845
+ this.toggleSearch();
1483
1846
  }
1484
1847
  }
1485
1848
 
@@ -1592,7 +1955,7 @@ export class ContentList<T = any> extends RapidElement {
1592
1955
 
1593
1956
  const ids = Array.from(this.selectedIds);
1594
1957
 
1595
- if (this.actionEndpoint) {
1958
+ if (this.actionEndpoint && !action.clientOnly) {
1596
1959
  const params = new URLSearchParams();
1597
1960
  params.append('action', action.key);
1598
1961
  ids.forEach((id) => params.append('objects', id));
@@ -1653,82 +2016,98 @@ export class ContentList<T = any> extends RapidElement {
1653
2016
  }
1654
2017
 
1655
2018
  private renderTitlebar(): TemplateResult {
1656
- const selectionCount = this.selectedIds.size;
1657
- const bulkVisible = selectionCount > 0 && this.bulkActions.length > 0;
1658
2019
  const hasSubtitle =
1659
2020
  this.subtitle || this.querySelector('[slot="subtitle"]');
1660
- const resultCount = `${this.total} ${this.total === 1 ? 'result' : 'results'}`;
1661
2021
  // 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.
2022
+ // list forwards its own title/subtitle slots into it and slots its
2023
+ // pager / search into the header's actions area. The bulk actions
2024
+ // are NOT here when rows are selected they overlay the column-
2025
+ // header row instead (see renderBulkBar), so the page header stays
2026
+ // put rather than swapping its contents.
1665
2027
  return html`
1666
2028
  <temba-page-header
1667
- content-menu-endpoint=${this.contentMenuEndpoint}
1668
- ?hide-menu=${bulkVisible}
2029
+ content-menu-endpoint=${this.contentMenuEndpointWithSearch()}
1669
2030
  >
1670
2031
  <slot name="title" slot="title">${this.listTitle}</slot>
1671
2032
  ${hasSubtitle
1672
2033
  ? html`<slot name="subtitle" slot="subtitle">${this.subtitle}</slot>`
1673
2034
  : null}
1674
2035
  <div slot="actions" class="header-actions">
1675
- ${bulkVisible
2036
+ ${this.renderPager()}
2037
+ ${this.searchable && !this.searchOpen
1676
2038
  ? html`
1677
- <span class="bulk-count">${selectionCount} selected</span>
1678
- ${this.bulkActions.map((a) => this.renderBulkAction(a))}
2039
+ <span class="action" @click=${() => this.toggleSearch()}>
2040
+ <temba-icon name=${Icon.search} size="0.95"></temba-icon>
2041
+ Search
2042
+ </span>
1679
2043
  `
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
- `}
2044
+ : null}
2045
+ <slot name="actions"></slot>
1695
2046
  </div>
1696
2047
  </temba-page-header>
1697
2048
  ${this.searchable && this.searchOpen
1698
2049
  ? html`
1699
2050
  <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
2051
  <input
1709
2052
  type="text"
1710
2053
  placeholder=${this.searchPlaceholder}
1711
2054
  .value=${this.searchDraft}
2055
+ ?disabled=${this.searching}
1712
2056
  @input=${this.handleSearchInput}
1713
2057
  @keydown=${this.handleSearchKey}
1714
2058
  />
1715
- ${this.search && this.hasCount && !this.loading
1716
- ? html`<span class="result-count">${resultCount}</span>`
2059
+ ${!this.searching && this.searchDraft !== this.search
2060
+ ? html`
2061
+ <span class="search-hint">
2062
+ <span class="enter-key">↵</span> to search
2063
+ </span>
2064
+ <temba-icon
2065
+ class="search-go"
2066
+ name=${Icon.search}
2067
+ size="1.1"
2068
+ clickable
2069
+ title="Search"
2070
+ aria-label="Run search"
2071
+ @click=${() => this.commitSearch()}
2072
+ ></temba-icon>
2073
+ `
1717
2074
  : null}
1718
- <span
1719
- class="clear"
1720
- title="Close search"
1721
- aria-label="Close search"
2075
+ <temba-icon
2076
+ class="search-cancel"
2077
+ name=${Icon.close}
2078
+ size="1.1"
2079
+ clickable
2080
+ title="Cancel search"
2081
+ aria-label="Cancel search"
1722
2082
  @click=${() => this.toggleSearch()}
1723
- >
1724
- <temba-icon name=${Icon.close} size="0.85"></temba-icon>
1725
- </span>
2083
+ ></temba-icon>
1726
2084
  </div>
1727
2085
  `
1728
2086
  : null}
1729
2087
  `;
1730
2088
  }
1731
2089
 
2090
+ /** True when there's a selection and actions to run on it. */
2091
+ private get bulkVisible(): boolean {
2092
+ return this.selectedIds.size > 0 && this.bulkActions.length > 0;
2093
+ }
2094
+
2095
+ /** The bulk-action bar — overlaid on the column-header row (just
2096
+ * right of the select-all checkbox) when rows are selected, rather
2097
+ * than replacing the page header. Positioned absolutely in the
2098
+ * table-frame so it doesn't scroll horizontally with the columns. */
2099
+ private renderBulkBar(): TemplateResult {
2100
+ // Actions lead (fixed against the checkbox) and the count trails,
2101
+ // right-aligned, so the buttons don't shift as the count's width
2102
+ // changes ("1 selected" vs "100 selected").
2103
+ return html`
2104
+ <div class="bulk-bar ${this.bulkCollapsed ? 'collapsed' : ''}">
2105
+ ${this.bulkActions.map((a) => this.renderBulkAction(a))}
2106
+ <span class="bulk-count">${this.selectedIds.size} selected</span>
2107
+ </div>
2108
+ `;
2109
+ }
2110
+
1732
2111
  private renderBulkAction(action: ContentListBulkAction): TemplateResult {
1733
2112
  if (action.labelsEndpoint) {
1734
2113
  return this.renderLabelDropdown(action);
@@ -1736,12 +2115,13 @@ export class ContentList<T = any> extends RapidElement {
1736
2115
  return html`
1737
2116
  <span
1738
2117
  class="bulk-action ${action.destructive ? 'destructive' : ''}"
2118
+ title=${action.label}
1739
2119
  @click=${() => this.handleBulkAction(action)}
1740
2120
  >
1741
2121
  ${action.icon
1742
2122
  ? html`<temba-icon name=${action.icon} size="0.9"></temba-icon>`
1743
2123
  : null}
1744
- ${action.label}
2124
+ <span class="bulk-label">${action.label}</span>
1745
2125
  </span>
1746
2126
  `;
1747
2127
  }
@@ -1751,17 +2131,17 @@ export class ContentList<T = any> extends RapidElement {
1751
2131
  return html`
1752
2132
  <temba-dropdown
1753
2133
  class="label-dropdown"
1754
- data-action-key=${action.key}
1755
2134
  @temba-opened=${() => this.handleLabelDropdownOpened(action)}
1756
2135
  >
1757
2136
  <span
1758
2137
  slot="toggle"
1759
2138
  class="bulk-action ${action.destructive ? 'destructive' : ''}"
2139
+ title=${action.label}
1760
2140
  >
1761
2141
  ${action.icon
1762
2142
  ? html`<temba-icon name=${action.icon} size="0.9"></temba-icon>`
1763
2143
  : null}
1764
- ${action.label}
2144
+ <span class="bulk-label">${action.label}</span>
1765
2145
  </span>
1766
2146
  <div slot="dropdown" class="label-menu">
1767
2147
  ${labels.length === 0
@@ -1776,7 +2156,7 @@ export class ContentList<T = any> extends RapidElement {
1776
2156
  label: any,
1777
2157
  action: ContentListBulkAction
1778
2158
  ): TemplateResult {
1779
- const state = this.computeLabelState(label.uuid);
2159
+ const state = this.computeLabelState(label.uuid, action.labelsKey);
1780
2160
  const isPending = this.pendingLabel === label.uuid;
1781
2161
  const isBlocked = this.pendingLabel !== null && !isPending;
1782
2162
  return html`
@@ -1787,18 +2167,17 @@ export class ContentList<T = any> extends RapidElement {
1787
2167
  @click=${(e: MouseEvent) => {
1788
2168
  e.stopPropagation();
1789
2169
  if (this.pendingLabel !== null) return;
1790
- this.toggleLabel(label, state, action.key);
2170
+ this.toggleLabel(label, state);
1791
2171
  }}
1792
2172
  >
1793
2173
  <temba-checkbox
1794
2174
  size="1.1"
1795
2175
  ?checked=${state === 'all'}
1796
2176
  ?partial=${state === 'some'}
2177
+ ?busy=${isPending}
2178
+ ?disabled=${isBlocked}
1797
2179
  ></temba-checkbox>
1798
2180
  <span class="lbl-name">${label.name}</span>
1799
- ${isPending
1800
- ? html`<temba-loading units="3" size="6"></temba-loading>`
1801
- : null}
1802
2181
  </div>
1803
2182
  `;
1804
2183
  }
@@ -1809,7 +2188,11 @@ export class ContentList<T = any> extends RapidElement {
1809
2188
  if (this.labelsByActionKey[action.key] || !action.labelsEndpoint) return;
1810
2189
  try {
1811
2190
  const response = await getUrl(action.labelsEndpoint);
1812
- const labels = response.json?.results || [];
2191
+ const labels = (response.json?.results || [])
2192
+ .slice()
2193
+ .sort((a: any, b: any) =>
2194
+ String(a.name || '').localeCompare(String(b.name || ''))
2195
+ );
1813
2196
  this.labelsByActionKey = {
1814
2197
  ...this.labelsByActionKey,
1815
2198
  [action.key]: labels
@@ -1821,15 +2204,20 @@ export class ContentList<T = any> extends RapidElement {
1821
2204
  }
1822
2205
 
1823
2206
  /** 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' {
2207
+ * label/group uuid: 'all' if every selected row has it, 'some' if
2208
+ * at least one but not all do, 'none' otherwise. Membership is read
2209
+ * from the `labelsKey` item field (default 'labels'; contacts use
2210
+ * 'groups'). */
2211
+ private computeLabelState(
2212
+ labelUuid: string,
2213
+ labelsKey = 'labels'
2214
+ ): 'none' | 'some' | 'all' {
1827
2215
  const selected = this.items.filter((item) =>
1828
2216
  this.selectedIds.has(this.rowId(item))
1829
2217
  );
1830
2218
  if (selected.length === 0) return 'none';
1831
2219
  const withLabel = selected.filter((item) =>
1832
- ((item as any).labels || []).some((l: any) => l.uuid === labelUuid)
2220
+ ((item as any)[labelsKey] || []).some((l: any) => l.uuid === labelUuid)
1833
2221
  );
1834
2222
  if (withLabel.length === 0) return 'none';
1835
2223
  if (withLabel.length === selected.length) return 'all';
@@ -1847,27 +2235,15 @@ export class ContentList<T = any> extends RapidElement {
1847
2235
  * filtered result decide which rows stay. We POST first, then
1848
2236
  * refresh once the server confirms. The `pendingLabel` state
1849
2237
  * blocks further toggles until the round-trip completes. */
1850
- private async toggleLabel(
1851
- label: any,
1852
- state: string,
1853
- actionKey: string
1854
- ): Promise<void> {
2238
+ private async toggleLabel(label: any, state: string): Promise<void> {
1855
2239
  if (this.pendingLabel !== null) return;
1856
2240
  const add = state !== 'all';
1857
2241
  const originalSelectedIds = Array.from(this.selectedIds);
1858
2242
  this.pendingLabel = label.uuid;
1859
2243
  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
-
2244
+ // The dropdown is left open so several labels/groups can be
2245
+ // toggled in one pass each checkbox updates in place as the
2246
+ // list re-fetches.
1871
2247
  if (this.actionEndpoint) {
1872
2248
  // application/x-www-form-urlencoded matches what Django's
1873
2249
  // smartmin `BulkActionMixin` reads from `request.POST`, and
@@ -2012,7 +2388,6 @@ export class ContentList<T = any> extends RapidElement {
2012
2388
  this.pinIndexByColumn = new Map();
2013
2389
  this.rightPinIndexByColumn = new Map();
2014
2390
  this.checkPinIndex = -1;
2015
- this.iconPinIndex = -1;
2016
2391
  this.lastPinIndex = -1;
2017
2392
  this.firstRightPinIndex = -1;
2018
2393
  this.spacerAfterIndex = -1;
@@ -2026,14 +2401,15 @@ export class ContentList<T = any> extends RapidElement {
2026
2401
  }
2027
2402
  this.firstRightPinIndex = ridx - 1;
2028
2403
 
2029
- // Left-pinned columns + the leading checkbox/icon cells.
2404
+ // Left-pinned columns + the leading checkbox cell. The leading icon
2405
+ // rides inside the first column's cell, so it adds no pin slot of
2406
+ // its own.
2030
2407
  const leftPinnedCount = this.columns.filter((c) =>
2031
2408
  this.isLeftPinned(c)
2032
2409
  ).length;
2033
2410
  if (leftPinnedCount === 0) return;
2034
2411
  let idx = 0;
2035
2412
  if (this.hasCheckboxes) this.checkPinIndex = idx++;
2036
- if (this.reservesIcon) this.iconPinIndex = idx++;
2037
2413
  this.columns.forEach((c) => {
2038
2414
  if (this.isLeftPinned(c)) this.pinIndexByColumn.set(c, idx++);
2039
2415
  });
@@ -2106,17 +2482,6 @@ export class ContentList<T = any> extends RapidElement {
2106
2482
  return parts.join(' ');
2107
2483
  }
2108
2484
 
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
2485
  /** Measure the header's pinned cells and publish a cumulative
2121
2486
  * `left` offset per pin index as a CSS var on the host. Pinned
2122
2487
  * cells (header + body) read these via {@link pinStyle}. Pinned
@@ -2174,13 +2539,56 @@ export class ContentList<T = any> extends RapidElement {
2174
2539
  'can-scroll-right',
2175
2540
  scroller.scrollLeft < maxScroll - 1
2176
2541
  );
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.
2542
+ // Vertical scroll lifts the sticky header above the rows passing
2543
+ // under it with the same soft drop shadow the pinned columns use.
2544
+ frame.classList.toggle('scrolled-down', scroller.scrollTop > 1);
2545
+ // The vertical scrollbar's width (0 for overlay scrollbars) pulls
2546
+ // the right-edge scroll gradient inboard so it never paints over
2547
+ // the scrollbar track.
2180
2548
  this.style.setProperty(
2181
- '--cl-scrollbar',
2182
- `${scroller.offsetHeight - scroller.clientHeight}px`
2549
+ '--cl-scrollbar-w',
2550
+ `${scroller.offsetWidth - scroller.clientWidth}px`
2183
2551
  );
2552
+ // Height of the visible rows — the lesser of the table's own height
2553
+ // and the visible area (clientHeight already excludes the horizontal
2554
+ // scrollbar). The right-edge scroll gradient is sized to this so it
2555
+ // stops at the bottom of the rows rather than running down the empty
2556
+ // space below a short table.
2557
+ const table = scroller.querySelector('table') as HTMLElement | null;
2558
+ const rowsHeight = table
2559
+ ? Math.min(scroller.clientHeight, table.offsetHeight)
2560
+ : scroller.clientHeight;
2561
+ this.style.setProperty('--cl-rows-height', `${rowsHeight}px`);
2562
+ // Header height positions the header's scroll shadow just below it.
2563
+ const headerRow = scroller.querySelector('tr.header') as HTMLElement | null;
2564
+ if (headerRow) {
2565
+ this.style.setProperty(
2566
+ '--cl-header-height',
2567
+ `${headerRow.offsetHeight}px`
2568
+ );
2569
+ }
2570
+ // Left edge of the row's leading content — the bulk-action bar's
2571
+ // first chip aligns here. That's the row icon when there is one
2572
+ // (e.g. the contact silhouette) and otherwise the first column's
2573
+ // text (e.g. the message contact), so the actions line up with the
2574
+ // row content rather than the checkbox cell.
2575
+ const frameRect = frame.getBoundingClientRect();
2576
+ const lead =
2577
+ (scroller.querySelector(
2578
+ 'tr.row td.lead-cell .lead-icon'
2579
+ ) as HTMLElement | null) ||
2580
+ (scroller.querySelector(
2581
+ 'tr.row td.cell .cell-inner'
2582
+ ) as HTMLElement | null) ||
2583
+ (scroller.querySelector(
2584
+ 'tr.header th.head-cell .head-inner'
2585
+ ) as HTMLElement | null);
2586
+ if (lead) {
2587
+ this.style.setProperty(
2588
+ '--cl-firstcol-left',
2589
+ `${lead.getBoundingClientRect().left - frameRect.left}px`
2590
+ );
2591
+ }
2184
2592
  }
2185
2593
 
2186
2594
  private renderHeader(): TemplateResult {
@@ -2209,12 +2617,6 @@ export class ContentList<T = any> extends RapidElement {
2209
2617
  </th>
2210
2618
  `
2211
2619
  : 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
2620
  ${this.columns.map((c, i) =>
2219
2621
  i === this.spacerAfterIndex
2220
2622
  ? html`${this.renderHeaderCell(c)}
@@ -2276,7 +2678,6 @@ export class ContentList<T = any> extends RapidElement {
2276
2678
  const id = this.rowId(item);
2277
2679
  const selected = this.selectedIds.has(id);
2278
2680
  const href = this.getRowHref(item);
2279
- const icon = this.getRowIcon(item);
2280
2681
  return html`
2281
2682
  <tr
2282
2683
  class="row ${selected ? 'selected' : ''} ${href ? 'clickable' : ''}"
@@ -2305,57 +2706,73 @@ export class ContentList<T = any> extends RapidElement {
2305
2706
  </td>
2306
2707
  `
2307
2708
  : 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
2709
  ${this.columns.map((c, i) =>
2323
2710
  i === this.spacerAfterIndex
2324
- ? html`${this.renderBodyCell(item, c)}
2711
+ ? html`${this.renderBodyCell(item, c, i === 0)}
2325
2712
  <td class="spacer"></td>`
2326
- : this.renderBodyCell(item, c)
2713
+ : this.renderBodyCell(item, c, i === 0)
2327
2714
  )}
2328
2715
  </tr>
2329
2716
  `;
2330
2717
  }
2331
2718
 
2332
- private renderBodyCell(item: T, column: ContentListColumn): TemplateResult {
2719
+ private renderBodyCell(
2720
+ item: T,
2721
+ column: ContentListColumn,
2722
+ isLead = false
2723
+ ): TemplateResult {
2724
+ const inner = html`
2725
+ <div class="cell-inner" style=${this.cellWidthStyle(column)}>
2726
+ ${this.renderCell(item, column)}
2727
+ </div>
2728
+ `;
2729
+ // The first column carries the row's leading icon (when the list
2730
+ // reserves one), so its header aligns with the icon rather than the
2731
+ // value. A row with no icon still reserves the space to keep values
2732
+ // aligned down the column.
2733
+ const lead = isLead && this.reservesIcon;
2734
+ const icon = lead ? this.getRowIcon(item) : null;
2333
2735
  return html`
2334
2736
  <td
2335
- class="cell ${column.align || ''} ${column.grow
2336
- ? 'grow'
2337
- : ''} ${this.columnPinClass(column)}"
2737
+ class="cell ${lead ? 'lead-cell' : ''} ${column.align ||
2738
+ ''} ${column.grow ? 'grow' : ''} ${this.columnPinClass(column)}"
2338
2739
  style=${this.columnPinStyle(column)}
2339
2740
  >
2340
- <div class="cell-inner" style=${this.cellWidthStyle(column)}>
2341
- ${this.renderCell(item, column)}
2342
- </div>
2741
+ ${lead
2742
+ ? html`<div class="lead-wrap">
2743
+ <span class="lead-icon"
2744
+ >${icon
2745
+ ? html`<temba-icon name=${icon} size="1"></temba-icon>`
2746
+ : null}</span
2747
+ >${inner}
2748
+ </div>`
2749
+ : inner}
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>