@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.
- package/CHANGELOG.md +19 -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 +501 -173
- package/dist/temba-components.js.map +1 -1
- package/package.json +1 -1
- package/src/display/Button.ts +14 -2
- package/src/display/Chat.ts +43 -24
- package/src/display/Dropdown.ts +11 -7
- package/src/display/Label.ts +18 -3
- package/src/display/Options.ts +27 -14
- package/src/display/TembaDate.ts +9 -2
- package/src/form/Compose.ts +37 -28
- package/src/form/select/Select.ts +15 -2
- package/src/interfaces.ts +13 -3
- package/src/list/BroadcastList.ts +20 -7
- package/src/list/ContactList.ts +113 -9
- package/src/list/ContentList.ts +380 -21
- package/src/list/ContentMenu.ts +12 -6
- package/src/list/FieldList.ts +8 -1
- package/src/list/TembaList.ts +99 -29
- package/src/list/TicketList.ts +87 -18
- package/src/live/CampaignEvents.ts +31 -11
- package/src/live/ContactChat.ts +36 -7
- package/src/live/ContactTimeline.ts +46 -24
- 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/src/styles/designTokens.ts +57 -17
- package/src/styles/pillVariants.ts +42 -10
- package/static/css/design-system.css +47 -12
- package/static/css/temba-components.css +31 -9
- package/xliff/es.xlf +3 -0
- package/xliff/fr.xlf +3 -0
- package/xliff/pt.xlf +3 -0
package/src/list/ContactList.ts
CHANGED
|
@@ -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
|
-
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
|
|
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
|
}
|
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;
|
|
@@ -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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
/*
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
|
2900
|
-
//
|
|
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
|
-
|
|
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)}>
|