@nyaruka/temba-components 0.169.0 → 0.170.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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;
@@ -523,10 +532,16 @@ export class ContentList<T = any> extends RapidElement {
523
532
  within the panel padding so row dividers don't bleed full-
524
533
  width. */
525
534
  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. */
535
+ /* Keep the table's internal layering (sticky header, pinned
536
+ columns and their hover/shadow layers, z-index 1-6) inside
537
+ its own stacking context so those values can only order
538
+ against each other and never interleave with page-level
539
+ layers like floating windows or open dropdowns. Without it
540
+ the frame is z-index auto, so the table's 1-6 would join the
541
+ nearest ancestor stacking context — normally the root — and
542
+ tie against page chrome using comparably small values. Note
543
+ this also caps this list's own label dropdown, which renders
544
+ inside the frame and so cannot escape it. */
530
545
  isolation: isolate;
531
546
  }
532
547
  .table-scroll {
@@ -767,6 +782,52 @@ export class ContentList<T = any> extends RapidElement {
767
782
  cursor: col-resize !important;
768
783
  }
769
784
 
785
+ /* Column reorder. A reorderable header is a drag surface: past a
786
+ small pointer threshold the label lifts into a floating ghost,
787
+ the origin column dims, and an accent insertion bar tracks the
788
+ drop slot. touch-action: none lets the same drag work on touch
789
+ — the 36px sticky header row is a negligible scroll-start
790
+ surface, so claiming its gestures (including the vertical
791
+ swipe that would otherwise scroll the list) costs little. */
792
+ .head-cell.reorderable {
793
+ touch-action: none;
794
+ }
795
+ /* Advertise the drag while idle. The th prefix out-specifies
796
+ .head-cell.sortable's pointer cursor, which is declared later
797
+ in this sheet and would otherwise win on a sortable column. */
798
+ th.head-cell.reorderable {
799
+ cursor: grab;
800
+ }
801
+ .head-cell.dragging {
802
+ opacity: 0.35;
803
+ }
804
+ /* The insertion bar sits flush inside the boundary it marks
805
+ rather than straddling it — an overhanging bar would be painted
806
+ over by the neighbouring header cell, which is opaque and wins
807
+ the stacking order. The header cell is the positioning context
808
+ (it's position: sticky). */
809
+ .head-cell.drop-before::before,
810
+ .head-cell.drop-after::after {
811
+ content: '';
812
+ position: absolute;
813
+ top: 4px;
814
+ bottom: 4px;
815
+ width: 3px;
816
+ border-radius: 2px;
817
+ background: var(--accent-400);
818
+ z-index: 5;
819
+ }
820
+ .head-cell.drop-before::before {
821
+ left: 0;
822
+ }
823
+ .head-cell.drop-after::after {
824
+ right: 0;
825
+ }
826
+ :host([column-dragging]),
827
+ :host([column-dragging]) * {
828
+ cursor: grabbing !important;
829
+ }
830
+
770
831
  /* Pinned columns stay fixed against their edge while the rest
771
832
  of the table scrolls under them. The frozen-region look —
772
833
  the tint and the divider — only kicks in once the table
@@ -1488,6 +1549,43 @@ export class ContentList<T = any> extends RapidElement {
1488
1549
  }
1489
1550
  | undefined;
1490
1551
  private previousBodyUserSelect = '';
1552
+
1553
+ /** Active pointer-driven header drag-reorder. The drag only "starts"
1554
+ * once the pointer travels past a small threshold, so a plain click
1555
+ * on a reorderable (and typically sortable) header still sorts. */
1556
+ private columnDrag:
1557
+ | {
1558
+ key: string;
1559
+ pointerId: number;
1560
+ startX: number;
1561
+ grabOffsetX: number;
1562
+ started: boolean;
1563
+ header: HTMLElement;
1564
+ ghost?: HTMLElement;
1565
+ }
1566
+ | undefined;
1567
+
1568
+ /** The drag keeps its own copy of body user-select: a resize started
1569
+ * mid-drag cancels the drag, and sharing one field would let whichever
1570
+ * finished last restore the other's (already overwritten) value. */
1571
+ private previousBodyUserSelectDrag = '';
1572
+
1573
+ /** Pointer travel, in px, that separates a click (sort) from a drag
1574
+ * (reorder). Only horizontal travel counts — that's the axis the
1575
+ * reorder happens on. */
1576
+ private static readonly DRAG_DEAD_ZONE = 5;
1577
+
1578
+ /** Key of the column being drag-reordered, '' when idle — drives the
1579
+ * dimmed treatment on the origin header. */
1580
+ @state()
1581
+ private draggingColumnKey = '';
1582
+
1583
+ /** Insertion slot the drag is currently over: the `columns` index
1584
+ * the dragged column would be inserted before (one past the
1585
+ * reorderable run for an end drop), or -1 when idle. */
1586
+ @state()
1587
+ private columnDropSlot = -1;
1588
+
1491
1589
  private columnWidths: Record<string, number> = {};
1492
1590
  private columnWidthSaveTimeout: ReturnType<typeof setTimeout> = null;
1493
1591
  private suppressHeaderClick = false;
@@ -1610,6 +1708,7 @@ export class ContentList<T = any> extends RapidElement {
1610
1708
  window.removeEventListener('resize', this.resizeHandler);
1611
1709
  }
1612
1710
  this.stopColumnResize();
1711
+ this.cancelColumnDrag();
1613
1712
  if (this.columnWidthSaveTimeout) {
1614
1713
  clearTimeout(this.columnWidthSaveTimeout);
1615
1714
  this.columnWidthSaveTimeout = null;
@@ -2896,9 +2995,11 @@ export class ContentList<T = any> extends RapidElement {
2896
2995
  column: ContentListColumn
2897
2996
  ): void {
2898
2997
  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.
2998
+ // A second pointer should replace, not overlap, an active resize or
2999
+ // reorder both stash the body's selection style, so leaving one
3000
+ // running would strand `user-select: none` on the body.
2901
3001
  this.stopColumnResize();
3002
+ this.cancelColumnDrag();
2902
3003
  event.preventDefault();
2903
3004
  event.stopPropagation();
2904
3005
  const handle = event.currentTarget as HTMLElement;
@@ -2985,6 +3086,212 @@ export class ContentList<T = any> extends RapidElement {
2985
3086
  if (changed) this.scheduleColumnWidthSave();
2986
3087
  };
2987
3088
 
3089
+ /** The contiguous run of reorderable columns as [first, last]
3090
+ * indexes, or null when fewer than two columns opt in. Mirroring the
3091
+ * pinning contract, reorderable columns must be contiguous — drops
3092
+ * are constrained to slots inside this run. */
3093
+ private reorderableRange(): [number, number] | null {
3094
+ const first = this.columns.findIndex((c) => c.reorderable);
3095
+ if (first === -1) return null;
3096
+ let last = first;
3097
+ while (
3098
+ last + 1 < this.columns.length &&
3099
+ this.columns[last + 1].reorderable
3100
+ ) {
3101
+ last++;
3102
+ }
3103
+ return last > first ? [first, last] : null;
3104
+ }
3105
+
3106
+ private headerCellForColumn(key: string): HTMLElement | null {
3107
+ return this.shadowRoot?.querySelector(
3108
+ `th.head-cell[data-key="${CSS.escape(key)}"]`
3109
+ );
3110
+ }
3111
+
3112
+ private startColumnDrag(
3113
+ event: PointerEvent,
3114
+ column: ContentListColumn
3115
+ ): void {
3116
+ if (event.pointerType === 'mouse' && event.button !== 0) return;
3117
+ if (this.columnResize || this.columnDrag) return;
3118
+ // No range check here: this only ever runs from the @pointerdown
3119
+ // render binds on headers inside the reorderable run.
3120
+ const header = event.currentTarget as HTMLElement;
3121
+ // No preventDefault and no pointer capture yet — until the pointer
3122
+ // clears the dead zone this may still be a plain sort click, which
3123
+ // must reach the header untouched.
3124
+ this.columnDrag = {
3125
+ key: column.key,
3126
+ pointerId: event.pointerId,
3127
+ startX: event.clientX,
3128
+ grabOffsetX: event.clientX - header.getBoundingClientRect().left,
3129
+ started: false,
3130
+ header
3131
+ };
3132
+ window.addEventListener('pointermove', this.handleColumnDragMove);
3133
+ window.addEventListener('pointerup', this.stopColumnDrag);
3134
+ window.addEventListener('pointercancel', this.cancelColumnDrag);
3135
+ }
3136
+
3137
+ private handleColumnDragMove = (event: PointerEvent): void => {
3138
+ const drag = this.columnDrag;
3139
+ if (!drag) return;
3140
+ // A second (e.g. touch) pointer must not drive a drag it didn't
3141
+ // start.
3142
+ if (event.pointerId !== drag.pointerId) return;
3143
+ if (!drag.started) {
3144
+ if (Math.abs(event.clientX - drag.startX) < ContentList.DRAG_DEAD_ZONE) {
3145
+ return;
3146
+ }
3147
+ drag.started = true;
3148
+ // Capture so the drag survives excursions outside the window and
3149
+ // the release's synthesized click retargets to the origin header,
3150
+ // where the post-drag suppression can consume it.
3151
+ try {
3152
+ drag.header.setPointerCapture(drag.pointerId);
3153
+ } catch {
3154
+ // synthetic pointers (tests) have no active pointer to capture
3155
+ }
3156
+ drag.ghost = this.createColumnDragGhost(drag.header);
3157
+ this.draggingColumnKey = drag.key;
3158
+ this.previousBodyUserSelectDrag = document.body.style.userSelect;
3159
+ document.body.style.userSelect = 'none';
3160
+ this.toggleAttribute('column-dragging', true);
3161
+ }
3162
+ event.preventDefault();
3163
+ if (drag.ghost) {
3164
+ drag.ghost.style.left = `${event.clientX - drag.grabOffsetX}px`;
3165
+ }
3166
+ this.columnDropSlot = this.computeColumnDropSlot(event.clientX);
3167
+ };
3168
+
3169
+ /** Where a drop at clientX would insert the dragged column: the
3170
+ * `columns` index to insert before, clamped to the reorderable run. */
3171
+ private computeColumnDropSlot(clientX: number): number {
3172
+ const range = this.reorderableRange();
3173
+ if (!range) return -1;
3174
+ const [first, last] = range;
3175
+ let slot = first;
3176
+ for (let i = first; i <= last; i++) {
3177
+ const header = this.headerCellForColumn(this.columns[i].key);
3178
+ if (!header) continue;
3179
+ const rect = header.getBoundingClientRect();
3180
+ if (clientX > rect.left + rect.width / 2) slot = i + 1;
3181
+ }
3182
+ return slot;
3183
+ }
3184
+
3185
+ private stopColumnDrag = (event?: Event): void => {
3186
+ const drag = this.columnDrag;
3187
+ if (!drag) return;
3188
+ // Ignore the release of any pointer other than the one that started
3189
+ // the drag. Teardown paths without an event (disconnect) still run.
3190
+ if (event instanceof PointerEvent && event.pointerId !== drag.pointerId) {
3191
+ return;
3192
+ }
3193
+ const slot = this.columnDropSlot;
3194
+ this.teardownColumnDrag();
3195
+ if (!drag.started) return;
3196
+ // The release retargets a synthesized click at the captured origin
3197
+ // header; consume it so completing a reorder never also re-sorts.
3198
+ if (event?.type === 'pointerup') {
3199
+ this.suppressHeaderClick = true;
3200
+ clearTimeout(this.headerClickSuppressionTimeout);
3201
+ this.headerClickSuppressionTimeout = setTimeout(() => {
3202
+ this.suppressHeaderClick = false;
3203
+ this.headerClickSuppressionTimeout = null;
3204
+ }, 0);
3205
+ }
3206
+ const from = this.columns.findIndex((c) => c.key === drag.key);
3207
+ if (from === -1 || slot < 0) return;
3208
+ const to = slot > from ? slot - 1 : slot;
3209
+ if (to === from) return;
3210
+ const columns = [...this.columns];
3211
+ const [moved] = columns.splice(from, 1);
3212
+ columns.splice(to, 0, moved);
3213
+ this.columns = columns;
3214
+ // Its own event type: the generic OrderChanged is a bubbling,
3215
+ // composed event whose other producers carry different payloads
3216
+ // (`ids`, `swap`), and its listeners would choke on this one.
3217
+ this.fireCustomEvent(CustomEventType.ColumnOrderChanged, {
3218
+ keys: columns.map((c) => c.key),
3219
+ from,
3220
+ to
3221
+ });
3222
+ this.onColumnOrderChanged(columns);
3223
+ };
3224
+
3225
+ /** Abandons the drag without committing a drop. Doubles as the
3226
+ * pointercancel listener (where it only answers to its own pointer)
3227
+ * and as the unconditional cleanup hook for disconnect / resize. */
3228
+ private cancelColumnDrag = (event?: Event): void => {
3229
+ const drag = this.columnDrag;
3230
+ if (!drag) return;
3231
+ if (event instanceof PointerEvent && event.pointerId !== drag.pointerId) {
3232
+ return;
3233
+ }
3234
+ this.teardownColumnDrag();
3235
+ };
3236
+
3237
+ private teardownColumnDrag(): void {
3238
+ const drag = this.columnDrag;
3239
+ this.columnDrag = undefined;
3240
+ this.columnDropSlot = -1;
3241
+ this.draggingColumnKey = '';
3242
+ window.removeEventListener('pointermove', this.handleColumnDragMove);
3243
+ window.removeEventListener('pointerup', this.stopColumnDrag);
3244
+ window.removeEventListener('pointercancel', this.cancelColumnDrag);
3245
+ if (drag?.started) {
3246
+ document.body.style.userSelect = this.previousBodyUserSelectDrag;
3247
+ this.toggleAttribute('column-dragging', false);
3248
+ }
3249
+ drag?.ghost?.remove();
3250
+ }
3251
+
3252
+ /** Called after a header drag commits a new column order. Subclasses
3253
+ * persist it (e.g. the contact list saves featured-field priorities). */
3254
+ protected onColumnOrderChanged(_columns: ContentListColumn[]): void {}
3255
+
3256
+ /** A floating copy of the header label that tracks the pointer during
3257
+ * a reorder. It lives on document.body — outside the shadow root —
3258
+ * so every style is inlined, with literal fallbacks for the design
3259
+ * tokens in case the host page doesn't define them globally. */
3260
+ private createColumnDragGhost(header: HTMLElement): HTMLElement {
3261
+ const rect = header.getBoundingClientRect();
3262
+ const style = getComputedStyle(header);
3263
+ const ghost = document.createElement('div');
3264
+ ghost.className = 'column-drag-ghost';
3265
+ // Purely decorative — it duplicates the header it was lifted from.
3266
+ ghost.setAttribute('aria-hidden', 'true');
3267
+ ghost.textContent =
3268
+ header.querySelector('.label')?.textContent?.trim() ?? '';
3269
+ Object.assign(ghost.style, {
3270
+ position: 'fixed',
3271
+ top: `${rect.top}px`,
3272
+ left: `${rect.left}px`,
3273
+ height: `${rect.height}px`,
3274
+ minWidth: `${rect.width}px`,
3275
+ display: 'flex',
3276
+ alignItems: 'center',
3277
+ boxSizing: 'border-box',
3278
+ padding: '0 8px',
3279
+ font: style.font,
3280
+ letterSpacing: style.letterSpacing,
3281
+ textTransform: style.textTransform,
3282
+ color: style.color,
3283
+ background: 'var(--surface, #fff)',
3284
+ border: '1px solid var(--border, #e4e7ec)',
3285
+ borderRadius: 'var(--curvature, 6px)',
3286
+ boxShadow: 'var(--shadow-1, 0 2px 6px rgba(0, 0, 0, 0.12))',
3287
+ pointerEvents: 'none',
3288
+ whiteSpace: 'nowrap',
3289
+ zIndex: '10000'
3290
+ });
3291
+ document.body.appendChild(ghost);
3292
+ return ghost;
3293
+ }
3294
+
2988
3295
  /** Arrow keys resize in 10px steps (25px with Shift), providing an
2989
3296
  * accessible equivalent to dragging the separator. */
2990
3297
  private handleColumnResizeKeydown(
@@ -3244,6 +3551,12 @@ export class ContentList<T = any> extends RapidElement {
3244
3551
  const allSelected =
3245
3552
  allIds.length > 0 && allIds.every((id) => this.selectedIds.has(id));
3246
3553
  const someSelected = !allSelected && this.selectedIds.size > 0;
3554
+ // Computed once for the whole row rather than per cell — every
3555
+ // header cell needs both to resolve its reorder affordances.
3556
+ const reorderableRange = this.reorderableRange();
3557
+ const dragFromIndex = this.draggingColumnKey
3558
+ ? this.columns.findIndex((c) => c.key === this.draggingColumnKey)
3559
+ : -1;
3247
3560
 
3248
3561
  return html`
3249
3562
  <thead>
@@ -3281,6 +3594,9 @@ export class ContentList<T = any> extends RapidElement {
3281
3594
  const outerResize = index === this.columns.length - 1;
3282
3595
  return html`${this.renderHeaderCell(
3283
3596
  column,
3597
+ index,
3598
+ reorderableRange,
3599
+ dragFromIndex,
3284
3600
  leadingResizeColumn,
3285
3601
  trailingResize,
3286
3602
  outerResize
@@ -3328,16 +3644,39 @@ export class ContentList<T = any> extends RapidElement {
3328
3644
 
3329
3645
  private renderHeaderCell(
3330
3646
  column: ContentListColumn,
3647
+ index: number,
3648
+ range: [number, number] | null,
3649
+ fromIndex: number,
3331
3650
  leadingResizeColumn?: ContentListColumn,
3332
3651
  trailingResize = false,
3333
3652
  outerResize = false
3334
3653
  ): TemplateResult {
3335
3654
  const active = this.sort === column.key || this.sort === '-' + column.key;
3336
3655
  const desc = this.sort === '-' + column.key;
3656
+ // Reorder affordances: a column is only draggable when it sits in
3657
+ // the reorderable run of two or more — a stray `reorderable` column
3658
+ // outside that run has nowhere to go, so it gets no drag. During a
3659
+ // drag the origin header dims and an insertion bar marks the current
3660
+ // drop slot — suppressed when the slot would put the column right
3661
+ // back where it already is (a no-op drop needs no affordance).
3662
+ const reorderable = !!(
3663
+ column.reorderable &&
3664
+ range &&
3665
+ index >= range[0] &&
3666
+ index <= range[1]
3667
+ );
3668
+ const dragging = this.draggingColumnKey === column.key;
3669
+ const dropSlot = fromIndex === -1 ? -1 : this.columnDropSlot;
3670
+ const noopSlot = dropSlot === fromIndex || dropSlot === fromIndex + 1;
3671
+ const dropBefore = !noopSlot && dropSlot === index && reorderable;
3672
+ const dropAfter =
3673
+ !noopSlot && !!range && dropSlot === range[1] + 1 && index === range[1];
3337
3674
  const cls = `head-cell ${column.align || ''} ${
3338
3675
  column.sortable ? 'sortable' : ''
3339
- } ${active ? 'active' : ''} ${
3340
- column.grow ? 'grow' : ''
3676
+ } ${active ? 'active' : ''} ${column.grow ? 'grow' : ''} ${
3677
+ reorderable ? 'reorderable' : ''
3678
+ } ${dragging ? 'dragging' : ''} ${dropBefore ? 'drop-before' : ''} ${
3679
+ dropAfter ? 'drop-after' : ''
3341
3680
  } ${this.columnPinClass(column)}`;
3342
3681
  // The sort arrow sits on the inboard side of the label — left of
3343
3682
  // it for right-aligned columns, right of it otherwise — so the
@@ -3371,10 +3710,14 @@ export class ContentList<T = any> extends RapidElement {
3371
3710
  return html`
3372
3711
  <th
3373
3712
  class=${cls}
3713
+ data-key=${column.key}
3374
3714
  style="${this.columnPinStyle(column)} ${widthStyle}"
3375
3715
  @click=${column.sortable
3376
3716
  ? (event: MouseEvent) => this.handleColumnHeaderClick(event, column)
3377
3717
  : null}
3718
+ @pointerdown=${reorderable
3719
+ ? (event: PointerEvent) => this.startColumnDrag(event, column)
3720
+ : null}
3378
3721
  >
3379
3722
  ${this.renderResizeHandle(leadingResizeColumn, true)}
3380
3723
  <div class="head-inner" style=${this.cellWidthStyle(column)}>
@@ -34,15 +34,23 @@ export enum ContentMenuItemType {
34
34
  export class ContentMenu extends RapidElement {
35
35
  static get styles() {
36
36
  return css`
37
- :host {
38
- tabindex: 0;
39
- z-index: 5000;
40
- }
41
37
  .container {
42
38
  display: flex;
43
39
  align-items: center;
44
40
  }
45
41
 
42
+ temba-dropdown {
43
+ /* as a flex item of .container this z-index takes effect and
44
+ creates a stacking context — keep it in sync with the popup's
45
+ z-index inside temba-dropdown (9000) so the open menu clears
46
+ floating windows like the simulator (5000). This also keeps
47
+ the closed toggle above those windows, intentionally: the
48
+ menu has to stay clickable when a floating window overlaps
49
+ the header. Scoped here rather than on :host so the action
50
+ buttons rendered alongside keep their normal stacking. */
51
+ z-index: 9000;
52
+ }
53
+
46
54
  .button_item,
47
55
  .primary_button_item {
48
56
  margin-left: 1rem;
@@ -68,7 +76,6 @@ export class ContentMenu extends RapidElement {
68
76
  color: rgb(45, 45, 45);
69
77
  z-index: 50;
70
78
  min-width: 200px;
71
- tabindex: 0;
72
79
  }
73
80
 
74
81
  .divider {
@@ -82,7 +89,6 @@ export class ContentMenu extends RapidElement {
82
89
  font-size: 1.1rem;
83
90
  cursor: pointer;
84
91
  font-weight: 400;
85
- tabindex: 0;
86
92
  }
87
93
 
88
94
  .item:hover {
@@ -56,6 +56,9 @@ export class TembaList extends RapidElement {
56
56
  @property({ attribute: false })
57
57
  renderOption: (option: any, selected: boolean) => TemplateResult;
58
58
 
59
+ @property({ attribute: false })
60
+ renderDivider: (prev: any, option: any) => TemplateResult | null;
61
+
59
62
  @property({ attribute: false })
60
63
  renderOptionDetail: (option: any, selected: boolean) => TemplateResult;
61
64
 
@@ -68,6 +71,10 @@ export class TembaList extends RapidElement {
68
71
 
69
72
  reverseRefresh = true;
70
73
 
74
+ // subclasses can enforce a display order when refreshed items are merged in,
75
+ // otherwise new items simply land at the top of the list
76
+ protected compareItems: (a: any, b: any) => number = null;
77
+
71
78
  // subclasses that get realtime updates can opt out of interval polling
72
79
  protected pollingEnabled = true;
73
80
 
@@ -251,6 +258,24 @@ export class TembaList extends RapidElement {
251
258
  return Promise.resolve(results);
252
259
  }
253
260
 
261
+ /**
262
+ * Finds where the item our cursor was pinned to ended up after a merge
263
+ * re-ordered our list. Returns null if the cursor is still on the right item,
264
+ * otherwise the index it moved to (-1 if it is no longer in the list).
265
+ */
266
+ private findRepinnedCursorIndex(prevItem: any, newItems: any[]): number {
267
+ if (!prevItem) {
268
+ return null;
269
+ }
270
+
271
+ const prevValue = this.getValue(prevItem);
272
+ if (prevValue === this.getValue(newItems[this.cursorIndex])) {
273
+ return null;
274
+ }
275
+
276
+ return newItems.findIndex((option) => this.getValue(option) === prevValue);
277
+ }
278
+
254
279
  /**
255
280
  * Refreshes the first page, updating any found items in our list
256
281
  */
@@ -303,7 +328,15 @@ export class TembaList extends RapidElement {
303
328
  }
304
329
  const newItems = [...results, ...items];
305
330
 
331
+ // capture the top item before any display sort - it means "the newest
332
+ // item we just fetched, or the previous top if we fetched nothing" and
333
+ // drives the Refreshed event
306
334
  const topItem = newItems[0];
335
+
336
+ if (this.compareItems) {
337
+ newItems.sort(this.compareItems);
338
+ }
339
+
307
340
  if (
308
341
  !this.mostRecentItem ||
309
342
  JSON.stringify(this.mostRecentItem) !== JSON.stringify(topItem)
@@ -311,27 +344,21 @@ export class TembaList extends RapidElement {
311
344
  this.mostRecentItem = topItem;
312
345
  }
313
346
 
314
- if (prevItem) {
315
- const newItem = newItems[this.cursorIndex];
316
- const prevValue = this.getValue(prevItem);
317
- if (prevValue !== this.getValue(newItem)) {
318
- const newIndex = newItems.findIndex(
319
- (option) => this.getValue(option) === prevValue
320
- );
321
- this.cursorIndex = newIndex;
322
-
323
- // make sure our focused item is visible
324
- window.setTimeout(() => {
325
- const options = this.shadowRoot.querySelector('temba-options');
326
- if (options) {
327
- const option =
328
- options.shadowRoot.querySelector('.option.focused');
329
- if (option) {
330
- option.scrollIntoView({ block: 'end', inline: 'nearest' });
331
- }
347
+ const newIndex = this.findRepinnedCursorIndex(prevItem, newItems);
348
+ if (newIndex !== null && newIndex > -1) {
349
+ this.cursorIndex = newIndex;
350
+
351
+ // make sure our focused item is visible
352
+ window.setTimeout(() => {
353
+ const options = this.shadowRoot.querySelector('temba-options');
354
+ if (options) {
355
+ const option =
356
+ options.shadowRoot.querySelector('.option.focused');
357
+ if (option) {
358
+ option.scrollIntoView({ block: 'end', inline: 'nearest' });
332
359
  }
333
- }, 0);
334
- }
360
+ }
361
+ }, 0);
335
362
  }
336
363
 
337
364
  this.items = newItems;
@@ -457,18 +484,60 @@ export class TembaList extends RapidElement {
457
484
  private handleScrollThreshold() {
458
485
  if (this.nextPage && !this.loading) {
459
486
  this.loading = true;
460
- fetchResultsPage(this.nextPage).then((page: ResultsPage) => {
461
- this.sanitizeResults(page.results).then((sanitizedResults) => {
462
- if (this.sanitizeOption) {
463
- sanitizedResults.forEach(this.sanitizeOption);
464
- }
487
+ fetchResultsPage(this.nextPage)
488
+ .then((page: ResultsPage) => {
489
+ return this.sanitizeResults(page.results).then(
490
+ (sanitizedResults: any[]) => {
491
+ if (this.sanitizeOption) {
492
+ sanitizedResults.forEach(this.sanitizeOption);
493
+ }
494
+
495
+ // the item our cursor is pinned to, sorting can move it
496
+ const prevItem = this.items[this.cursorIndex];
497
+
498
+ // drop anything we already have, a poll can pull an item from the
499
+ // next page up into our loaded window before we fetch it
500
+ const seen = new Set(
501
+ this.items.map((option) => this.getValue(option))
502
+ );
503
+ const appended = (sanitizedResults || []).filter(
504
+ (option: any) => {
505
+ const value = this.getValue(option);
506
+ if (seen.has(value)) {
507
+ return false;
508
+ }
509
+ seen.add(value);
510
+ return true;
511
+ }
512
+ );
465
513
 
466
- this.items = [...this.items, ...sanitizedResults];
467
- this.nextPage = page.next;
468
- this.pages++;
514
+ const items = [...this.items, ...appended];
515
+
516
+ // the server pages in the same total order we display in, so this is
517
+ // normally a no-op, but a poll can re-sort an item (say a ticket that
518
+ // just closed) into the loaded window - without this the appended
519
+ // page would land below it
520
+ if (this.compareItems) {
521
+ items.sort(this.compareItems);
522
+ }
523
+
524
+ // the sort can shift our selection, keep the cursor on it
525
+ const newIndex = this.findRepinnedCursorIndex(prevItem, items);
526
+ if (newIndex !== null && newIndex > -1) {
527
+ this.cursorIndex = newIndex;
528
+ }
529
+
530
+ this.items = items;
531
+ this.nextPage = page.next;
532
+ this.pages++;
533
+ this.loading = false;
534
+ }
535
+ );
536
+ })
537
+ .catch(() => {
538
+ // let the next scroll try again instead of wedging on loading
469
539
  this.loading = false;
470
540
  });
471
- });
472
541
  }
473
542
  }
474
543
 
@@ -512,6 +581,7 @@ export class TembaList extends RapidElement {
512
581
  ?loading=${this.loading}
513
582
  ?internalFocusDisabled=${this.internalFocusDisabled}
514
583
  .renderOption=${this.renderOption}
584
+ .renderDivider=${this.renderDivider}
515
585
  .renderOptionDetail=${this.renderOptionDetail}
516
586
  @temba-scroll-threshold=${this.handleScrollThreshold}
517
587
  @temba-selection=${this.handleSelection.bind(this)}