@nyaruka/temba-components 0.169.0 → 0.170.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/locales/es.js +1 -0
  3. package/dist/locales/es.js.map +1 -1
  4. package/dist/locales/fr.js +1 -0
  5. package/dist/locales/fr.js.map +1 -1
  6. package/dist/locales/pt.js +1 -0
  7. package/dist/locales/pt.js.map +1 -1
  8. package/dist/temba-components.js +501 -173
  9. package/dist/temba-components.js.map +1 -1
  10. package/package.json +1 -1
  11. package/src/display/Button.ts +14 -2
  12. package/src/display/Chat.ts +43 -24
  13. package/src/display/Dropdown.ts +11 -7
  14. package/src/display/Label.ts +18 -3
  15. package/src/display/Options.ts +27 -14
  16. package/src/display/TembaDate.ts +9 -2
  17. package/src/form/Compose.ts +37 -28
  18. package/src/form/select/Select.ts +15 -2
  19. package/src/interfaces.ts +13 -3
  20. package/src/list/BroadcastList.ts +20 -7
  21. package/src/list/ContactList.ts +113 -9
  22. package/src/list/ContentList.ts +380 -21
  23. package/src/list/ContentMenu.ts +12 -6
  24. package/src/list/FieldList.ts +8 -1
  25. package/src/list/TembaList.ts +99 -29
  26. package/src/list/TicketList.ts +87 -18
  27. package/src/live/CampaignEvents.ts +31 -11
  28. package/src/live/ContactChat.ts +36 -7
  29. package/src/live/ContactTimeline.ts +46 -24
  30. package/src/locales/es.ts +1 -0
  31. package/src/locales/fr.ts +1 -0
  32. package/src/locales/pt.ts +1 -0
  33. package/src/simulator/Simulator.ts +1 -0
  34. package/src/styles/designTokens.ts +57 -17
  35. package/src/styles/pillVariants.ts +42 -10
  36. package/static/css/design-system.css +47 -12
  37. package/static/css/temba-components.css +31 -9
  38. package/xliff/es.xlf +3 -0
  39. package/xliff/fr.xlf +3 -0
  40. package/xliff/pt.xlf +3 -0
@@ -3,13 +3,19 @@ import { property, state } from 'lit/decorators.js';
3
3
  import { ContentList, ContentListColumn } from './ContentList';
4
4
  import { Icon } from '../Icons';
5
5
  import { Contact } from '../interfaces';
6
- import { getUrl } from '../utils';
6
+ import { getUrl, postJSON } from '../utils';
7
+ import { getStore } from '../store/Store';
7
8
 
8
9
  const FIELD_PREFIX = 'field:';
9
10
 
10
11
  /** Placeholder shown in any cell whose value is empty. */
11
12
  const EMPTY = '--';
12
13
 
14
+ /** Ceiling on how many pages of fields we'll walk. The API serves 250
15
+ * per page, so this is far past any real workspace — it exists only so
16
+ * an endpoint that keeps handing back a `next` can't spin forever. */
17
+ const MAX_FIELD_PAGES = 20;
18
+
13
19
  /**
14
20
  * Contact CRUDL list — drop-in replacement for the rapidpro
15
21
  * `contacts/contact_list.html` table. Each row carries a contact
@@ -22,6 +28,11 @@ const EMPTY = '--';
22
28
  * {@link ContactList.fieldsEndpoint} on connect; cells read each
23
29
  * contact's value out of `item.fields[<key>]`. Date/time fields
24
30
  * render as a relative duration, matching the Last-seen column.
31
+ *
32
+ * When {@link ContactList.priorityEndpoint} is set the field columns
33
+ * can be dragged into a new order, which is saved as the workspace's
34
+ * featured-field order (the list renders featured fields by priority,
35
+ * so column order and field order are the same thing).
25
36
  */
26
37
  export class ContactList extends ContentList<Contact> {
27
38
  static get styles() {
@@ -45,10 +56,25 @@ export class ContactList extends ContentList<Contact> {
45
56
  }
46
57
 
47
58
  /** Endpoint returning `{ results: ContactField[] }`. Fields where
48
- * `featured: true` become extra columns. */
59
+ * `featured: true` become extra columns.
60
+ *
61
+ * The list deliberately fetches fields itself instead of reading the
62
+ * global store: it has to work standalone (and against a custom
63
+ * endpoint), and it may mount before any store exists on the page.
64
+ * The store, when present, is only *told* about changes — see the
65
+ * refresh after a priority save. The endpoint has no featured filter,
66
+ * so the fetch pages through all fields and filters client-side. */
49
67
  @property({ type: String, attribute: 'fields-endpoint' })
50
68
  fieldsEndpoint = '/api/v2/fields.json';
51
69
 
70
+ /** Endpoint accepting `{ featured: [keys...] }` to save featured-field
71
+ * order (the same one the fields management page posts to). When set,
72
+ * the field columns become drag-reorderable and a drop saves the new
73
+ * order org-wide; the host only sets it for users holding the
74
+ * update-priority permission. */
75
+ @property({ type: String, attribute: 'priority-endpoint' })
76
+ priorityEndpoint = '';
77
+
52
78
  /** Anonymous workspaces mask URN values, so instead of the URN
53
79
  * column the list shows each contact's ref (served as its own
54
80
  * anon-only key by the endpoint). */
@@ -104,7 +130,7 @@ export class ContactList extends ContentList<Contact> {
104
130
  // Rebuilding here (rather than in updated()) means a mounted
105
131
  // anon attribute is reflected in the first paint instead of
106
132
  // flashing the URN header for a frame.
107
- if (changes.has('anon')) {
133
+ if (changes.has('anon') || changes.has('priorityEndpoint')) {
108
134
  this.columns = this.buildColumns();
109
135
  }
110
136
  }
@@ -127,11 +153,27 @@ export class ContactList extends ContentList<Contact> {
127
153
  const controller = new AbortController();
128
154
  this.pendingFieldsController = controller;
129
155
  try {
130
- const response = await getUrl(this.fieldsEndpoint, controller);
131
- // If the controller has been swapped or cleared, the response
132
- // is from a stale request drop it on the floor.
133
- if (this.pendingFieldsController !== controller) return;
134
- const all = response.json?.results || [];
156
+ // The fields API is cursor paginated (250 per page), and the
157
+ // featured list a drop posts back is authoritative any featured
158
+ // field we never read would be silently un-featured org-wide. So
159
+ // walk every page before deciding what's featured. Nothing is
160
+ // assigned until the whole walk lands, so a page that fails leaves
161
+ // the previous (complete) list in place rather than a truncated one.
162
+ const all: any[] = [];
163
+ const fetched = new Set<string>();
164
+ let url: string = this.fieldsEndpoint;
165
+ for (let page = 0; url && page < MAX_FIELD_PAGES; page++) {
166
+ // an endpoint whose `next` points back at a page we've already
167
+ // read would otherwise loop until the page cap
168
+ if (fetched.has(url)) break;
169
+ fetched.add(url);
170
+ const response = await getUrl(url, controller);
171
+ // If the controller has been swapped or cleared, the response
172
+ // is from a stale request — drop it on the floor.
173
+ if (this.pendingFieldsController !== controller) return;
174
+ all.push(...(response.json?.results || []));
175
+ url = response.json?.next || '';
176
+ }
135
177
  this.featuredFields = all
136
178
  .filter((f: any) => f.featured)
137
179
  .sort((a: any, b: any) => (b.priority ?? 0) - (a.priority ?? 0));
@@ -173,7 +215,11 @@ export class ContactList extends ContentList<Contact> {
173
215
  sortable: true,
174
216
  resizeMinWidth: '40px',
175
217
  maxWidth: '200px',
176
- resizable: true
218
+ resizable: true,
219
+ // Field columns can be dragged into a new order, but only when
220
+ // the host wired up somewhere to save it — reordering that
221
+ // silently reverts on reload would read as broken.
222
+ reorderable: !!this.priorityEndpoint
177
223
  })
178
224
  );
179
225
  // Name + URN are the pinned identity columns and are not
@@ -224,6 +270,64 @@ export class ContactList extends ContentList<Contact> {
224
270
  ];
225
271
  }
226
272
 
273
+ /** A committed header drag reordered the field columns — keep
274
+ * featuredFields aligned with the new column order (so any rebuild
275
+ * preserves it) and save it as the workspace's featured-field order.
276
+ * Column order maps directly onto priority: leftmost = highest, the
277
+ * same contract as the fields management page, whose endpoint this
278
+ * posts to with the full featured list. */
279
+ protected onColumnOrderChanged(columns: ContentListColumn[]): void {
280
+ // A fields fetch still in flight was started against the old order
281
+ // and would land on top of the drop, snapping the columns back with
282
+ // the new order already on its way to the server.
283
+ if (this.pendingFieldsController) {
284
+ this.pendingFieldsController.abort();
285
+ this.pendingFieldsController = undefined;
286
+ }
287
+ const keys = columns
288
+ .filter((c) => c.key.startsWith(FIELD_PREFIX))
289
+ .map((c) => c.key.substring(FIELD_PREFIX.length));
290
+ this.featuredFields = keys
291
+ .map((key) => (this.featuredFields || []).find((f: any) => f.key === key))
292
+ .filter(Boolean);
293
+ if (!this.priorityEndpoint) return;
294
+ postJSON(this.priorityEndpoint, { featured: keys })
295
+ .then((response) => {
296
+ // postUrl only rejects on 5xx, so a 400/403/404 arrives here as a
297
+ // perfectly resolved promise — the order was never stored and we
298
+ // have to treat it as the failure it is.
299
+ if (response.status < 200 || response.status >= 300) {
300
+ throw response;
301
+ }
302
+ })
303
+ .catch((error) => {
304
+ console.warn('failed to save featured field order', error);
305
+ // The list went away while the save was in flight — there are no
306
+ // columns left to put back, and refetching would only leave a
307
+ // request outstanding against a detached element.
308
+ if (!this.isConnected) return;
309
+ // Nothing made the dragged order durable, so stop showing it —
310
+ // refetch and fall back on whatever the server still holds. The
311
+ // success path deliberately doesn't refetch: we already display
312
+ // exactly what we just saved, and a read-replica answering with
313
+ // pre-write data would yank the columns back for no reason.
314
+ this.loadFields();
315
+ })
316
+ .finally(() => {
317
+ // Featured fields are workspace state other components on the
318
+ // page read from the store (contact details, the fields page),
319
+ // so its cached copy has to hear about the new order too. Looked
320
+ // up here rather than cached on connect so a list that mounted
321
+ // ahead of the store still finds it.
322
+ getStore()
323
+ ?.refreshFields()
324
+ .catch(() => {
325
+ // a stale store cache is cosmetic and elsewhere; the list
326
+ // itself is already showing the saved order
327
+ });
328
+ });
329
+ }
330
+
227
331
  protected getRowIcon(_item: Contact): string | null {
228
332
  return Icon.contact;
229
333
  }
@@ -44,6 +44,15 @@ export interface ContentListColumn {
44
44
  /** Whether the column can be resized from its trailing edge. Resizing is
45
45
  * opt-in so list types can expose it only where the interaction is useful. */
46
46
  resizable?: boolean;
47
+ /** Whether the column can be reordered by dragging its header.
48
+ * Reorderable columns must form one contiguous run — drags are
49
+ * constrained to slots inside that run so the system columns on
50
+ * either side stay put. Takes effect once two or more columns opt
51
+ * in (there's nothing to reorder against otherwise). The table does
52
+ * not scroll while a drag is in flight, so dragging beyond the
53
+ * visible frame isn't supported — drops clamp to the headers on
54
+ * screen. */
55
+ reorderable?: boolean;
47
56
  /** Optional resize-only floor. Unlike minWidth, this does not affect the
48
57
  * table's native layout before the user resizes the column. */
49
58
  resizeMinWidth?: string;
@@ -145,24 +154,33 @@ export class ContentList<T = any> extends RapidElement {
145
154
  --sort-gutter: 16px;
146
155
  /* Selected-row wash — accent-50 on its own reads grey, so a
147
156
  touch of the accent-400 rail colour is mixed in to give
148
- the selection a faint accent tint. */
149
- --cl-selected: color-mix(
150
- in oklab,
151
- var(--accent-400) 9%,
152
- var(--accent-50)
153
- );
157
+ the selection a faint accent tint. Statics pre-mixed from
158
+ the default accent for browsers without color-mix(); the
159
+ @supports block below re-derives them from the tokens. */
160
+ --cl-selected: #e5ecf8;
154
161
  /* The dividers bracketing a selected row — the plain grey
155
162
  --border reads as a seam against the wash, so this is the
156
163
  same accent pushed a little further. */
157
- --cl-selected-border: color-mix(
158
- in oklab,
159
- var(--accent-400) 24%,
160
- var(--accent-50)
161
- );
164
+ --cl-selected-border: #cbdaf1;
162
165
  /* Tint shared by the frozen (pinned) columns and the header
163
166
  row, so the header reads as the same quiet sub-panel as the
164
167
  pinned section. */
165
- --cl-pin-bg: color-mix(in oklab, var(--sunken) 35%, var(--surface));
168
+ --cl-pin-bg: #fafbfc;
169
+ }
170
+ @supports (color: color-mix(in srgb, red, red)) {
171
+ :host {
172
+ --cl-selected: color-mix(
173
+ in oklab,
174
+ var(--accent-400) 9%,
175
+ var(--accent-50)
176
+ );
177
+ --cl-selected-border: color-mix(
178
+ in oklab,
179
+ var(--accent-400) 24%,
180
+ var(--accent-50)
181
+ );
182
+ --cl-pin-bg: color-mix(in oklab, var(--sunken) 35%, var(--surface));
183
+ }
166
184
  }
167
185
  /* fillWindow — take the slack of a height-bounded flex-column
168
186
  parent so the table scrolls internally; min-height: 0 (set
@@ -287,7 +305,14 @@ export class ContentList<T = any> extends RapidElement {
287
305
  color: var(--danger);
288
306
  }
289
307
  .bulk-action.destructive:hover {
290
- background: color-mix(in oklab, var(--danger) 20%, white);
308
+ /* pre-mixed fallback for browsers without color-mix() */
309
+ background: #f6d9d9;
310
+ }
311
+
312
+ @supports (color: color-mix(in srgb, red, red)) {
313
+ .bulk-action.destructive:hover {
314
+ background: color-mix(in oklab, var(--danger) 20%, white);
315
+ }
291
316
  }
292
317
  .bulk-action temba-icon {
293
318
  --icon-color: currentColor;
@@ -523,10 +548,16 @@ export class ContentList<T = any> extends RapidElement {
523
548
  within the panel padding so row dividers don't bleed full-
524
549
  width. */
525
550
  margin-right: -20px;
526
- /* Contain the sticky header's z-index inside this frame so it
527
- can't compete with the page header's content-menu dropdown
528
- (also z-index 2), which otherwise paints under the table
529
- header by DOM-order tie-break. */
551
+ /* Keep the table's internal layering (sticky header, pinned
552
+ columns and their hover/shadow layers, z-index 1-6) inside
553
+ its own stacking context so those values can only order
554
+ against each other and never interleave with page-level
555
+ layers like floating windows or open dropdowns. Without it
556
+ the frame is z-index auto, so the table's 1-6 would join the
557
+ nearest ancestor stacking context — normally the root — and
558
+ tie against page chrome using comparably small values. Note
559
+ this also caps this list's own label dropdown, which renders
560
+ inside the frame and so cannot escape it. */
530
561
  isolation: isolate;
531
562
  }
532
563
  .table-scroll {
@@ -767,6 +798,52 @@ export class ContentList<T = any> extends RapidElement {
767
798
  cursor: col-resize !important;
768
799
  }
769
800
 
801
+ /* Column reorder. A reorderable header is a drag surface: past a
802
+ small pointer threshold the label lifts into a floating ghost,
803
+ the origin column dims, and an accent insertion bar tracks the
804
+ drop slot. touch-action: none lets the same drag work on touch
805
+ — the 36px sticky header row is a negligible scroll-start
806
+ surface, so claiming its gestures (including the vertical
807
+ swipe that would otherwise scroll the list) costs little. */
808
+ .head-cell.reorderable {
809
+ touch-action: none;
810
+ }
811
+ /* Advertise the drag while idle. The th prefix out-specifies
812
+ .head-cell.sortable's pointer cursor, which is declared later
813
+ in this sheet and would otherwise win on a sortable column. */
814
+ th.head-cell.reorderable {
815
+ cursor: grab;
816
+ }
817
+ .head-cell.dragging {
818
+ opacity: 0.35;
819
+ }
820
+ /* The insertion bar sits flush inside the boundary it marks
821
+ rather than straddling it — an overhanging bar would be painted
822
+ over by the neighbouring header cell, which is opaque and wins
823
+ the stacking order. The header cell is the positioning context
824
+ (it's position: sticky). */
825
+ .head-cell.drop-before::before,
826
+ .head-cell.drop-after::after {
827
+ content: '';
828
+ position: absolute;
829
+ top: 4px;
830
+ bottom: 4px;
831
+ width: 3px;
832
+ border-radius: 2px;
833
+ background: var(--accent-400);
834
+ z-index: 5;
835
+ }
836
+ .head-cell.drop-before::before {
837
+ left: 0;
838
+ }
839
+ .head-cell.drop-after::after {
840
+ right: 0;
841
+ }
842
+ :host([column-dragging]),
843
+ :host([column-dragging]) * {
844
+ cursor: grabbing !important;
845
+ }
846
+
770
847
  /* Pinned columns stay fixed against their edge while the rest
771
848
  of the table scrolls under them. The frozen-region look —
772
849
  the tint and the divider — only kicks in once the table
@@ -1488,6 +1565,43 @@ export class ContentList<T = any> extends RapidElement {
1488
1565
  }
1489
1566
  | undefined;
1490
1567
  private previousBodyUserSelect = '';
1568
+
1569
+ /** Active pointer-driven header drag-reorder. The drag only "starts"
1570
+ * once the pointer travels past a small threshold, so a plain click
1571
+ * on a reorderable (and typically sortable) header still sorts. */
1572
+ private columnDrag:
1573
+ | {
1574
+ key: string;
1575
+ pointerId: number;
1576
+ startX: number;
1577
+ grabOffsetX: number;
1578
+ started: boolean;
1579
+ header: HTMLElement;
1580
+ ghost?: HTMLElement;
1581
+ }
1582
+ | undefined;
1583
+
1584
+ /** The drag keeps its own copy of body user-select: a resize started
1585
+ * mid-drag cancels the drag, and sharing one field would let whichever
1586
+ * finished last restore the other's (already overwritten) value. */
1587
+ private previousBodyUserSelectDrag = '';
1588
+
1589
+ /** Pointer travel, in px, that separates a click (sort) from a drag
1590
+ * (reorder). Only horizontal travel counts — that's the axis the
1591
+ * reorder happens on. */
1592
+ private static readonly DRAG_DEAD_ZONE = 5;
1593
+
1594
+ /** Key of the column being drag-reordered, '' when idle — drives the
1595
+ * dimmed treatment on the origin header. */
1596
+ @state()
1597
+ private draggingColumnKey = '';
1598
+
1599
+ /** Insertion slot the drag is currently over: the `columns` index
1600
+ * the dragged column would be inserted before (one past the
1601
+ * reorderable run for an end drop), or -1 when idle. */
1602
+ @state()
1603
+ private columnDropSlot = -1;
1604
+
1491
1605
  private columnWidths: Record<string, number> = {};
1492
1606
  private columnWidthSaveTimeout: ReturnType<typeof setTimeout> = null;
1493
1607
  private suppressHeaderClick = false;
@@ -1610,6 +1724,7 @@ export class ContentList<T = any> extends RapidElement {
1610
1724
  window.removeEventListener('resize', this.resizeHandler);
1611
1725
  }
1612
1726
  this.stopColumnResize();
1727
+ this.cancelColumnDrag();
1613
1728
  if (this.columnWidthSaveTimeout) {
1614
1729
  clearTimeout(this.columnWidthSaveTimeout);
1615
1730
  this.columnWidthSaveTimeout = null;
@@ -2896,9 +3011,11 @@ export class ContentList<T = any> extends RapidElement {
2896
3011
  column: ContentListColumn
2897
3012
  ): void {
2898
3013
  if (event.pointerType === 'mouse' && event.button !== 0) return;
2899
- // A second pointer should replace, not overlap, an active drag and
2900
- // must preserve the body's original selection style for cleanup.
3014
+ // A second pointer should replace, not overlap, an active resize or
3015
+ // reorder both stash the body's selection style, so leaving one
3016
+ // running would strand `user-select: none` on the body.
2901
3017
  this.stopColumnResize();
3018
+ this.cancelColumnDrag();
2902
3019
  event.preventDefault();
2903
3020
  event.stopPropagation();
2904
3021
  const handle = event.currentTarget as HTMLElement;
@@ -2985,6 +3102,212 @@ export class ContentList<T = any> extends RapidElement {
2985
3102
  if (changed) this.scheduleColumnWidthSave();
2986
3103
  };
2987
3104
 
3105
+ /** The contiguous run of reorderable columns as [first, last]
3106
+ * indexes, or null when fewer than two columns opt in. Mirroring the
3107
+ * pinning contract, reorderable columns must be contiguous — drops
3108
+ * are constrained to slots inside this run. */
3109
+ private reorderableRange(): [number, number] | null {
3110
+ const first = this.columns.findIndex((c) => c.reorderable);
3111
+ if (first === -1) return null;
3112
+ let last = first;
3113
+ while (
3114
+ last + 1 < this.columns.length &&
3115
+ this.columns[last + 1].reorderable
3116
+ ) {
3117
+ last++;
3118
+ }
3119
+ return last > first ? [first, last] : null;
3120
+ }
3121
+
3122
+ private headerCellForColumn(key: string): HTMLElement | null {
3123
+ return this.shadowRoot?.querySelector(
3124
+ `th.head-cell[data-key="${CSS.escape(key)}"]`
3125
+ );
3126
+ }
3127
+
3128
+ private startColumnDrag(
3129
+ event: PointerEvent,
3130
+ column: ContentListColumn
3131
+ ): void {
3132
+ if (event.pointerType === 'mouse' && event.button !== 0) return;
3133
+ if (this.columnResize || this.columnDrag) return;
3134
+ // No range check here: this only ever runs from the @pointerdown
3135
+ // render binds on headers inside the reorderable run.
3136
+ const header = event.currentTarget as HTMLElement;
3137
+ // No preventDefault and no pointer capture yet — until the pointer
3138
+ // clears the dead zone this may still be a plain sort click, which
3139
+ // must reach the header untouched.
3140
+ this.columnDrag = {
3141
+ key: column.key,
3142
+ pointerId: event.pointerId,
3143
+ startX: event.clientX,
3144
+ grabOffsetX: event.clientX - header.getBoundingClientRect().left,
3145
+ started: false,
3146
+ header
3147
+ };
3148
+ window.addEventListener('pointermove', this.handleColumnDragMove);
3149
+ window.addEventListener('pointerup', this.stopColumnDrag);
3150
+ window.addEventListener('pointercancel', this.cancelColumnDrag);
3151
+ }
3152
+
3153
+ private handleColumnDragMove = (event: PointerEvent): void => {
3154
+ const drag = this.columnDrag;
3155
+ if (!drag) return;
3156
+ // A second (e.g. touch) pointer must not drive a drag it didn't
3157
+ // start.
3158
+ if (event.pointerId !== drag.pointerId) return;
3159
+ if (!drag.started) {
3160
+ if (Math.abs(event.clientX - drag.startX) < ContentList.DRAG_DEAD_ZONE) {
3161
+ return;
3162
+ }
3163
+ drag.started = true;
3164
+ // Capture so the drag survives excursions outside the window and
3165
+ // the release's synthesized click retargets to the origin header,
3166
+ // where the post-drag suppression can consume it.
3167
+ try {
3168
+ drag.header.setPointerCapture(drag.pointerId);
3169
+ } catch {
3170
+ // synthetic pointers (tests) have no active pointer to capture
3171
+ }
3172
+ drag.ghost = this.createColumnDragGhost(drag.header);
3173
+ this.draggingColumnKey = drag.key;
3174
+ this.previousBodyUserSelectDrag = document.body.style.userSelect;
3175
+ document.body.style.userSelect = 'none';
3176
+ this.toggleAttribute('column-dragging', true);
3177
+ }
3178
+ event.preventDefault();
3179
+ if (drag.ghost) {
3180
+ drag.ghost.style.left = `${event.clientX - drag.grabOffsetX}px`;
3181
+ }
3182
+ this.columnDropSlot = this.computeColumnDropSlot(event.clientX);
3183
+ };
3184
+
3185
+ /** Where a drop at clientX would insert the dragged column: the
3186
+ * `columns` index to insert before, clamped to the reorderable run. */
3187
+ private computeColumnDropSlot(clientX: number): number {
3188
+ const range = this.reorderableRange();
3189
+ if (!range) return -1;
3190
+ const [first, last] = range;
3191
+ let slot = first;
3192
+ for (let i = first; i <= last; i++) {
3193
+ const header = this.headerCellForColumn(this.columns[i].key);
3194
+ if (!header) continue;
3195
+ const rect = header.getBoundingClientRect();
3196
+ if (clientX > rect.left + rect.width / 2) slot = i + 1;
3197
+ }
3198
+ return slot;
3199
+ }
3200
+
3201
+ private stopColumnDrag = (event?: Event): void => {
3202
+ const drag = this.columnDrag;
3203
+ if (!drag) return;
3204
+ // Ignore the release of any pointer other than the one that started
3205
+ // the drag. Teardown paths without an event (disconnect) still run.
3206
+ if (event instanceof PointerEvent && event.pointerId !== drag.pointerId) {
3207
+ return;
3208
+ }
3209
+ const slot = this.columnDropSlot;
3210
+ this.teardownColumnDrag();
3211
+ if (!drag.started) return;
3212
+ // The release retargets a synthesized click at the captured origin
3213
+ // header; consume it so completing a reorder never also re-sorts.
3214
+ if (event?.type === 'pointerup') {
3215
+ this.suppressHeaderClick = true;
3216
+ clearTimeout(this.headerClickSuppressionTimeout);
3217
+ this.headerClickSuppressionTimeout = setTimeout(() => {
3218
+ this.suppressHeaderClick = false;
3219
+ this.headerClickSuppressionTimeout = null;
3220
+ }, 0);
3221
+ }
3222
+ const from = this.columns.findIndex((c) => c.key === drag.key);
3223
+ if (from === -1 || slot < 0) return;
3224
+ const to = slot > from ? slot - 1 : slot;
3225
+ if (to === from) return;
3226
+ const columns = [...this.columns];
3227
+ const [moved] = columns.splice(from, 1);
3228
+ columns.splice(to, 0, moved);
3229
+ this.columns = columns;
3230
+ // Its own event type: the generic OrderChanged is a bubbling,
3231
+ // composed event whose other producers carry different payloads
3232
+ // (`ids`, `swap`), and its listeners would choke on this one.
3233
+ this.fireCustomEvent(CustomEventType.ColumnOrderChanged, {
3234
+ keys: columns.map((c) => c.key),
3235
+ from,
3236
+ to
3237
+ });
3238
+ this.onColumnOrderChanged(columns);
3239
+ };
3240
+
3241
+ /** Abandons the drag without committing a drop. Doubles as the
3242
+ * pointercancel listener (where it only answers to its own pointer)
3243
+ * and as the unconditional cleanup hook for disconnect / resize. */
3244
+ private cancelColumnDrag = (event?: Event): void => {
3245
+ const drag = this.columnDrag;
3246
+ if (!drag) return;
3247
+ if (event instanceof PointerEvent && event.pointerId !== drag.pointerId) {
3248
+ return;
3249
+ }
3250
+ this.teardownColumnDrag();
3251
+ };
3252
+
3253
+ private teardownColumnDrag(): void {
3254
+ const drag = this.columnDrag;
3255
+ this.columnDrag = undefined;
3256
+ this.columnDropSlot = -1;
3257
+ this.draggingColumnKey = '';
3258
+ window.removeEventListener('pointermove', this.handleColumnDragMove);
3259
+ window.removeEventListener('pointerup', this.stopColumnDrag);
3260
+ window.removeEventListener('pointercancel', this.cancelColumnDrag);
3261
+ if (drag?.started) {
3262
+ document.body.style.userSelect = this.previousBodyUserSelectDrag;
3263
+ this.toggleAttribute('column-dragging', false);
3264
+ }
3265
+ drag?.ghost?.remove();
3266
+ }
3267
+
3268
+ /** Called after a header drag commits a new column order. Subclasses
3269
+ * persist it (e.g. the contact list saves featured-field priorities). */
3270
+ protected onColumnOrderChanged(_columns: ContentListColumn[]): void {}
3271
+
3272
+ /** A floating copy of the header label that tracks the pointer during
3273
+ * a reorder. It lives on document.body — outside the shadow root —
3274
+ * so every style is inlined, with literal fallbacks for the design
3275
+ * tokens in case the host page doesn't define them globally. */
3276
+ private createColumnDragGhost(header: HTMLElement): HTMLElement {
3277
+ const rect = header.getBoundingClientRect();
3278
+ const style = getComputedStyle(header);
3279
+ const ghost = document.createElement('div');
3280
+ ghost.className = 'column-drag-ghost';
3281
+ // Purely decorative — it duplicates the header it was lifted from.
3282
+ ghost.setAttribute('aria-hidden', 'true');
3283
+ ghost.textContent =
3284
+ header.querySelector('.label')?.textContent?.trim() ?? '';
3285
+ Object.assign(ghost.style, {
3286
+ position: 'fixed',
3287
+ top: `${rect.top}px`,
3288
+ left: `${rect.left}px`,
3289
+ height: `${rect.height}px`,
3290
+ minWidth: `${rect.width}px`,
3291
+ display: 'flex',
3292
+ alignItems: 'center',
3293
+ boxSizing: 'border-box',
3294
+ padding: '0 8px',
3295
+ font: style.font,
3296
+ letterSpacing: style.letterSpacing,
3297
+ textTransform: style.textTransform,
3298
+ color: style.color,
3299
+ background: 'var(--surface, #fff)',
3300
+ border: '1px solid var(--border, #e4e7ec)',
3301
+ borderRadius: 'var(--curvature, 6px)',
3302
+ boxShadow: 'var(--shadow-1, 0 2px 6px rgba(0, 0, 0, 0.12))',
3303
+ pointerEvents: 'none',
3304
+ whiteSpace: 'nowrap',
3305
+ zIndex: '10000'
3306
+ });
3307
+ document.body.appendChild(ghost);
3308
+ return ghost;
3309
+ }
3310
+
2988
3311
  /** Arrow keys resize in 10px steps (25px with Shift), providing an
2989
3312
  * accessible equivalent to dragging the separator. */
2990
3313
  private handleColumnResizeKeydown(
@@ -3244,6 +3567,12 @@ export class ContentList<T = any> extends RapidElement {
3244
3567
  const allSelected =
3245
3568
  allIds.length > 0 && allIds.every((id) => this.selectedIds.has(id));
3246
3569
  const someSelected = !allSelected && this.selectedIds.size > 0;
3570
+ // Computed once for the whole row rather than per cell — every
3571
+ // header cell needs both to resolve its reorder affordances.
3572
+ const reorderableRange = this.reorderableRange();
3573
+ const dragFromIndex = this.draggingColumnKey
3574
+ ? this.columns.findIndex((c) => c.key === this.draggingColumnKey)
3575
+ : -1;
3247
3576
 
3248
3577
  return html`
3249
3578
  <thead>
@@ -3281,6 +3610,9 @@ export class ContentList<T = any> extends RapidElement {
3281
3610
  const outerResize = index === this.columns.length - 1;
3282
3611
  return html`${this.renderHeaderCell(
3283
3612
  column,
3613
+ index,
3614
+ reorderableRange,
3615
+ dragFromIndex,
3284
3616
  leadingResizeColumn,
3285
3617
  trailingResize,
3286
3618
  outerResize
@@ -3328,16 +3660,39 @@ export class ContentList<T = any> extends RapidElement {
3328
3660
 
3329
3661
  private renderHeaderCell(
3330
3662
  column: ContentListColumn,
3663
+ index: number,
3664
+ range: [number, number] | null,
3665
+ fromIndex: number,
3331
3666
  leadingResizeColumn?: ContentListColumn,
3332
3667
  trailingResize = false,
3333
3668
  outerResize = false
3334
3669
  ): TemplateResult {
3335
3670
  const active = this.sort === column.key || this.sort === '-' + column.key;
3336
3671
  const desc = this.sort === '-' + column.key;
3672
+ // Reorder affordances: a column is only draggable when it sits in
3673
+ // the reorderable run of two or more — a stray `reorderable` column
3674
+ // outside that run has nowhere to go, so it gets no drag. During a
3675
+ // drag the origin header dims and an insertion bar marks the current
3676
+ // drop slot — suppressed when the slot would put the column right
3677
+ // back where it already is (a no-op drop needs no affordance).
3678
+ const reorderable = !!(
3679
+ column.reorderable &&
3680
+ range &&
3681
+ index >= range[0] &&
3682
+ index <= range[1]
3683
+ );
3684
+ const dragging = this.draggingColumnKey === column.key;
3685
+ const dropSlot = fromIndex === -1 ? -1 : this.columnDropSlot;
3686
+ const noopSlot = dropSlot === fromIndex || dropSlot === fromIndex + 1;
3687
+ const dropBefore = !noopSlot && dropSlot === index && reorderable;
3688
+ const dropAfter =
3689
+ !noopSlot && !!range && dropSlot === range[1] + 1 && index === range[1];
3337
3690
  const cls = `head-cell ${column.align || ''} ${
3338
3691
  column.sortable ? 'sortable' : ''
3339
- } ${active ? 'active' : ''} ${
3340
- column.grow ? 'grow' : ''
3692
+ } ${active ? 'active' : ''} ${column.grow ? 'grow' : ''} ${
3693
+ reorderable ? 'reorderable' : ''
3694
+ } ${dragging ? 'dragging' : ''} ${dropBefore ? 'drop-before' : ''} ${
3695
+ dropAfter ? 'drop-after' : ''
3341
3696
  } ${this.columnPinClass(column)}`;
3342
3697
  // The sort arrow sits on the inboard side of the label — left of
3343
3698
  // it for right-aligned columns, right of it otherwise — so the
@@ -3371,10 +3726,14 @@ export class ContentList<T = any> extends RapidElement {
3371
3726
  return html`
3372
3727
  <th
3373
3728
  class=${cls}
3729
+ data-key=${column.key}
3374
3730
  style="${this.columnPinStyle(column)} ${widthStyle}"
3375
3731
  @click=${column.sortable
3376
3732
  ? (event: MouseEvent) => this.handleColumnHeaderClick(event, column)
3377
3733
  : null}
3734
+ @pointerdown=${reorderable
3735
+ ? (event: PointerEvent) => this.startColumnDrag(event, column)
3736
+ : null}
3378
3737
  >
3379
3738
  ${this.renderResizeHandle(leadingResizeColumn, true)}
3380
3739
  <div class="head-inner" style=${this.cellWidthStyle(column)}>