@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.
- package/CHANGELOG.md +13 -0
- package/dist/locales/es.js +1 -0
- package/dist/locales/es.js.map +1 -1
- package/dist/locales/fr.js +1 -0
- package/dist/locales/fr.js.map +1 -1
- package/dist/locales/pt.js +1 -0
- package/dist/locales/pt.js.map +1 -1
- package/dist/temba-components.js +660 -562
- package/dist/temba-components.js.map +1 -1
- package/package.json +1 -1
- package/src/display/Chat.ts +2 -2
- package/src/display/Dropdown.ts +11 -7
- package/src/display/Options.ts +27 -14
- package/src/display/TembaDate.ts +9 -2
- package/src/form/Compose.ts +37 -28
- package/src/interfaces.ts +13 -3
- package/src/list/BroadcastList.ts +3 -1
- package/src/list/ContactList.ts +113 -9
- package/src/list/ContentList.ts +351 -8
- package/src/list/ContentMenu.ts +12 -6
- package/src/list/TembaList.ts +99 -29
- package/src/list/TicketList.ts +87 -18
- package/src/live/ContactChat.ts +7 -1
- package/src/locales/es.ts +1 -0
- package/src/locales/fr.ts +1 -0
- package/src/locales/pt.ts +1 -0
- package/src/simulator/Simulator.ts +1 -0
- package/xliff/es.xlf +3 -0
- package/xliff/fr.xlf +3 -0
- package/xliff/pt.xlf +3 -0
package/src/list/ContentList.ts
CHANGED
|
@@ -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
|
-
/*
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
|
2900
|
-
//
|
|
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
|
-
|
|
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)}>
|
package/src/list/ContentMenu.ts
CHANGED
|
@@ -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 {
|
package/src/list/TembaList.ts
CHANGED
|
@@ -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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
);
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
}
|
|
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)
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
sanitizedResults
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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)}
|