@nyaruka/temba-components 0.167.0 → 0.168.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 +21 -0
- package/dist/locales/es.js +3 -1
- package/dist/locales/es.js.map +1 -1
- package/dist/locales/fr.js +3 -1
- package/dist/locales/fr.js.map +1 -1
- package/dist/locales/pt.js +3 -1
- package/dist/locales/pt.js.map +1 -1
- package/dist/temba-components.js +2791 -1668
- package/dist/temba-components.js.map +1 -1
- package/package.json +1 -1
- package/src/Icons.ts +2 -0
- package/src/display/Button.ts +8 -6
- package/src/display/Chat.ts +541 -191
- package/src/display/Dropdown.ts +40 -17
- package/src/display/Label.ts +25 -4
- package/src/display/TembaDate.ts +11 -2
- package/src/display/Tip.ts +157 -18
- package/src/events/eventRenderers.ts +678 -268
- package/src/events.ts +16 -1
- package/src/flow/NodeEditor.ts +82 -9
- package/src/flow/actions/add_contact_urn.ts +1 -0
- package/src/flow/actions/play_audio.ts +1 -0
- package/src/flow/actions/send_broadcast.ts +17 -1
- package/src/flow/actions/send_msg.ts +12 -1
- package/src/flow/actions/set_run_result.ts +1 -0
- package/src/flow/actions/start_session.ts +10 -1
- package/src/flow/nodes/shared-rules.ts +2 -0
- package/src/flow/nodes/split_by_airtime.ts +6 -0
- package/src/flow/nodes/split_by_expression.ts +1 -0
- package/src/flow/nodes/split_by_llm.ts +2 -0
- package/src/flow/nodes/split_by_llm_categorize.ts +3 -1
- package/src/flow/nodes/split_by_random.ts +2 -1
- package/src/flow/nodes/split_by_resthook.ts +4 -1
- package/src/flow/nodes/split_by_webhook.ts +9 -1
- package/src/flow/types.ts +3 -0
- package/src/flow/utils.ts +49 -0
- package/src/form/DatePicker.ts +2 -1
- package/src/layout/Card.ts +9 -0
- package/src/layout/CardLayout.ts +426 -21
- package/src/layout/CardStack.ts +4 -1
- package/src/list/ContactList.ts +22 -17
- package/src/list/ContentList.ts +632 -29
- package/src/list/MsgList.ts +4 -2
- package/src/list/SortableList.ts +5 -1
- package/src/list/TembaMenu.ts +67 -6
- package/src/live/ContactChat.ts +655 -336
- package/src/live/ContactDetails.ts +961 -88
- package/src/live/ContactFieldEditor.ts +46 -11
- package/src/live/ContactFields.ts +57 -16
- package/src/live/ContactStoreElement.ts +15 -8
- package/src/locales/es.ts +3 -2
- package/src/locales/fr.ts +3 -2
- package/src/locales/pt.ts +3 -2
- package/src/simulator/Simulator.ts +28 -0
- package/src/styles/pillVariants.ts +13 -1
- package/web-test-runner.config.mjs +2 -0
- package/xliff/es.xlf +6 -3
- package/xliff/fr.xlf +6 -3
- package/xliff/pt.xlf +6 -3
package/src/layout/CardLayout.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { css, html, TemplateResult } from 'lit';
|
|
|
2
2
|
import { property } from 'lit/decorators.js';
|
|
3
3
|
import { RapidElement } from '../RapidElement';
|
|
4
4
|
import { CustomEventType } from '../interfaces';
|
|
5
|
-
import { postJSON } from '../utils';
|
|
5
|
+
import { getClasses, postJSON } from '../utils';
|
|
6
6
|
import { Card } from './Card';
|
|
7
7
|
|
|
8
8
|
/** Saved card layout state — order and collapsed lists may reference cards
|
|
@@ -10,6 +10,7 @@ import { Card } from './Card';
|
|
|
10
10
|
export interface CardSettings {
|
|
11
11
|
order?: string[];
|
|
12
12
|
collapsed?: string[];
|
|
13
|
+
width?: number;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
/**
|
|
@@ -22,13 +23,16 @@ export interface CardSettings {
|
|
|
22
23
|
* Panels are declared as slotted temba-cards with an id; the main view goes
|
|
23
24
|
* in slot="main".
|
|
24
25
|
*
|
|
25
|
-
* The layout can persist card order
|
|
26
|
-
* with `settings` and point `settings-endpoint` at a
|
|
27
|
-
* merges top-level keys (rapidpro's user settings view).
|
|
28
|
-
* debounced and posted as `{[settingsKey]: {order, collapsed
|
|
29
|
-
*
|
|
30
|
-
* cards merges its relative order into the full saved
|
|
31
|
-
* clobbering the position of cards it doesn't show
|
|
26
|
+
* The layout can persist card order, collapsed state and the chosen main
|
|
27
|
+
* width itself: seed it with `settings` and point `settings-endpoint` at a
|
|
28
|
+
* POST endpoint that merges top-level keys (rapidpro's user settings view).
|
|
29
|
+
* Saves are debounced and posted as `{[settingsKey]: {order, collapsed,
|
|
30
|
+
* width}}`. The saved state is the union across pages — a page rendering
|
|
31
|
+
* only a subset of the cards merges its relative order into the full saved
|
|
32
|
+
* order rather than clobbering the position of cards it doesn't show, and a
|
|
33
|
+
* page that never sets a width re-posts the saved one rather than clearing
|
|
34
|
+
* it. Double-clicking the resize handle returns to the automatic width and
|
|
35
|
+
* clears the saved one.
|
|
32
36
|
*/
|
|
33
37
|
export class CardLayout extends RapidElement {
|
|
34
38
|
static get styles() {
|
|
@@ -38,6 +42,8 @@ export class CardLayout extends RapidElement {
|
|
|
38
42
|
flex-direction: column;
|
|
39
43
|
flex-grow: 1;
|
|
40
44
|
min-height: 0;
|
|
45
|
+
/* anchors the tab-view resize handle on the right inset */
|
|
46
|
+
position: relative;
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
/* in tab view there is no card column running to the edge — the
|
|
@@ -64,6 +70,46 @@ export class CardLayout extends RapidElement {
|
|
|
64
70
|
flex-direction: column;
|
|
65
71
|
padding-top: var(--layout-spacing, 8px);
|
|
66
72
|
padding-bottom: var(--layout-spacing, 8px);
|
|
73
|
+
/* anchors the card-view resize handle in the gap to our right */
|
|
74
|
+
position: relative;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* drag handle for resizing the main view — in card view it rides the
|
|
78
|
+
gap between the main view and the card column, in tab view (.edge)
|
|
79
|
+
it rides the layout's right inset. touch-action keeps the browser
|
|
80
|
+
from claiming the drag as a scroll gesture on touch pointers. */
|
|
81
|
+
.resize-handle {
|
|
82
|
+
position: absolute;
|
|
83
|
+
top: 0;
|
|
84
|
+
bottom: 0;
|
|
85
|
+
right: calc(var(--layout-spacing, 8px) * -1 - 1px);
|
|
86
|
+
width: calc(var(--layout-spacing, 8px) + 2px);
|
|
87
|
+
cursor: col-resize;
|
|
88
|
+
z-index: 1;
|
|
89
|
+
display: flex;
|
|
90
|
+
justify-content: center;
|
|
91
|
+
touch-action: none;
|
|
92
|
+
outline: none;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* in tab view the handle rides the layout's right inset — keep it
|
|
96
|
+
inside that inset so it doesn't overlay (and swallow pointer
|
|
97
|
+
events on) the edge of the tab content */
|
|
98
|
+
.resize-handle.edge {
|
|
99
|
+
right: 0;
|
|
100
|
+
width: var(--layout-spacing, 8px);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.resize-handle .grip {
|
|
104
|
+
width: 3px;
|
|
105
|
+
border-radius: 2px;
|
|
106
|
+
background: transparent;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.resize-handle:hover .grip,
|
|
110
|
+
.resize-handle:focus-visible .grip,
|
|
111
|
+
.resize-handle.active .grip {
|
|
112
|
+
background: var(--border-strong);
|
|
67
113
|
}
|
|
68
114
|
|
|
69
115
|
slot[name='main']::slotted(*) {
|
|
@@ -93,6 +139,12 @@ export class CardLayout extends RapidElement {
|
|
|
93
139
|
main view — and doubles as room for card shadows. */
|
|
94
140
|
flex: 1 0 var(--card-column-width, 360px);
|
|
95
141
|
min-height: 0;
|
|
142
|
+
/* without min-width:0 the column's automatic minimum is its
|
|
143
|
+
content's intrinsic width, so one long unbreakable line in
|
|
144
|
+
a card (e.g. a flow pill's name) widens the whole column
|
|
145
|
+
instead of ellipsizing — the flex basis still keeps it
|
|
146
|
+
from shrinking below the configured column width */
|
|
147
|
+
min-width: 0;
|
|
96
148
|
overflow-y: auto;
|
|
97
149
|
padding: var(--layout-spacing, 8px);
|
|
98
150
|
}
|
|
@@ -120,12 +172,99 @@ export class CardLayout extends RapidElement {
|
|
|
120
172
|
// sync with the .column CSS defaults
|
|
121
173
|
static COLUMN_FOOTPRINT = 376;
|
|
122
174
|
|
|
123
|
-
|
|
175
|
+
// user-chosen main-view width in px (0 = automatic). Dragging the resize
|
|
176
|
+
// handle sets it, and it participates in the flip: the cards always keep
|
|
177
|
+
// their footprint, so a wider chat flips to tabs sooner
|
|
178
|
+
@property({ type: Number, attribute: false })
|
|
179
|
+
mainWidth = 0;
|
|
180
|
+
|
|
181
|
+
// a drag on the resize handle is in progress
|
|
182
|
+
@property({ type: Boolean, attribute: false })
|
|
183
|
+
resizing = false;
|
|
184
|
+
|
|
185
|
+
// last observed layout width — the handles use it to decide whether a
|
|
186
|
+
// resize could reach card mode at all before offering themselves
|
|
187
|
+
@property({ type: Number, attribute: false })
|
|
188
|
+
hostWidth = 0;
|
|
189
|
+
|
|
190
|
+
// last measured width of the rendered main pane — the separator
|
|
191
|
+
// announces it while the width is still automatic (no chosen width)
|
|
192
|
+
@property({ type: Number, attribute: false })
|
|
193
|
+
mainPaneWidth = 0;
|
|
194
|
+
|
|
195
|
+
/** The narrowest layout that can show card mode at any main-view width. */
|
|
196
|
+
private getMinFlipWidth(): number {
|
|
124
197
|
return this.breakpoint > 0
|
|
125
198
|
? this.breakpoint
|
|
126
199
|
: this.mainMinWidth + CardLayout.COLUMN_FOOTPRINT;
|
|
127
200
|
}
|
|
128
201
|
|
|
202
|
+
/** The main width the layout actually renders. A width saved on a page
|
|
203
|
+
* with a lower floor still gets this page's minimum, but merely
|
|
204
|
+
* rendering at that floor never writes it back into `mainWidth` — that
|
|
205
|
+
* would clobber the user's chosen width for the page it was chosen on.
|
|
206
|
+
* An explicit resize here (drag or arrow key) does step from the floor
|
|
207
|
+
* and does commit: the user is choosing a width on this page, so this
|
|
208
|
+
* page's minimum is the right thing to start from. */
|
|
209
|
+
private getEffectiveMainWidth(): number {
|
|
210
|
+
return this.mainWidth > 0 ? Math.max(this.mainWidth, this.mainMinWidth) : 0;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private getFlipWidth(): number {
|
|
214
|
+
const min = this.getMinFlipWidth();
|
|
215
|
+
const width = this.getEffectiveMainWidth();
|
|
216
|
+
// a user-sized main view raises the bar — the cards keep their
|
|
217
|
+
// footprint rather than being squeezed beside it
|
|
218
|
+
return width > 0 ? Math.max(min, width + CardLayout.COLUMN_FOOTPRINT) : min;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Whether the layout is wide enough for a resize to land anywhere
|
|
222
|
+
* useful: the main view at its minimum beside the card column, and at
|
|
223
|
+
* or above any explicit breakpoint. Narrower than that a drag can only
|
|
224
|
+
* strand the layout in tab mode — the flip width it would need exceeds
|
|
225
|
+
* the layout — so no handle is offered. Note an explicit *low*
|
|
226
|
+
* breakpoint says "prefer cards", it doesn't make dragging work: the
|
|
227
|
+
* footprint floor still applies. */
|
|
228
|
+
private isResizable(): boolean {
|
|
229
|
+
return (
|
|
230
|
+
this.hostWidth >=
|
|
231
|
+
Math.max(
|
|
232
|
+
this.getMinFlipWidth(),
|
|
233
|
+
this.mainMinWidth + CardLayout.COLUMN_FOOTPRINT
|
|
234
|
+
)
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The widest main view that still leaves the cards their footprint —
|
|
239
|
+
* the separator's maximum. */
|
|
240
|
+
private getResizeMax(): number {
|
|
241
|
+
return this.hostWidth - CardLayout.COLUMN_FOOTPRINT;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Keep a width inside the range the separator advertises. In tab view
|
|
245
|
+
* the pane spans more than the widest card-mode fit, so an unclamped
|
|
246
|
+
* value would fall outside [valuemin, valuemax]. */
|
|
247
|
+
private clampResizeWidth(width: number): number {
|
|
248
|
+
return Math.min(Math.max(width, this.mainMinWidth), this.getResizeMax());
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** The value the separator announces: the chosen width when there is
|
|
252
|
+
* one, otherwise the last measured pane width. */
|
|
253
|
+
private getAnnouncedWidth(): number {
|
|
254
|
+
return Math.round(
|
|
255
|
+
this.clampResizeWidth(
|
|
256
|
+
this.getEffectiveMainWidth() || this.mainPaneWidth || this.mainMinWidth
|
|
257
|
+
)
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Width the main view occupies now — in tab view it fills the layout,
|
|
262
|
+
* so a resize from there starts at the full width. */
|
|
263
|
+
private getMainPaneWidth(): number {
|
|
264
|
+
const main = this.shadowRoot?.querySelector('.main') as HTMLElement;
|
|
265
|
+
return main ? main.offsetWidth : this.offsetWidth;
|
|
266
|
+
}
|
|
267
|
+
|
|
129
268
|
@property({ type: Array })
|
|
130
269
|
order: string[] = [];
|
|
131
270
|
|
|
@@ -148,6 +287,10 @@ export class CardLayout extends RapidElement {
|
|
|
148
287
|
// pages show, so a save from this page can't clobber their state
|
|
149
288
|
private savedOrder: string[] = [];
|
|
150
289
|
private savedCollapsed: string[] = [];
|
|
290
|
+
// the last width we know is stored — a page where the user never sets a
|
|
291
|
+
// width re-posts this rather than dropping the key and clearing the
|
|
292
|
+
// width chosen on another page
|
|
293
|
+
private savedWidth = 0;
|
|
151
294
|
|
|
152
295
|
// cards whose collapsed state has been seeded from settings — seed once
|
|
153
296
|
// so a later mutation can't undo the user's toggles
|
|
@@ -210,17 +353,27 @@ export class CardLayout extends RapidElement {
|
|
|
210
353
|
this.resizer = new ResizeObserver(() => {
|
|
211
354
|
// defer out of the observer callback — flipping modes re-renders and
|
|
212
355
|
// resizes us, which would otherwise trip the browser's RO loop guard
|
|
213
|
-
requestAnimationFrame(() =>
|
|
214
|
-
const width = this.offsetWidth;
|
|
215
|
-
if (width > 0) {
|
|
216
|
-
this.narrow = width < this.getFlipWidth();
|
|
217
|
-
this.compactTabs = width < CardLayout.COMPACT_TABS_WIDTH;
|
|
218
|
-
}
|
|
219
|
-
});
|
|
356
|
+
requestAnimationFrame(() => this.updateModes());
|
|
220
357
|
});
|
|
221
358
|
this.resizer.observe(this);
|
|
222
359
|
}
|
|
223
360
|
|
|
361
|
+
private updateModes() {
|
|
362
|
+
const width = this.offsetWidth;
|
|
363
|
+
if (width > 0) {
|
|
364
|
+
this.hostWidth = width;
|
|
365
|
+
this.narrow = width < this.getFlipWidth();
|
|
366
|
+
this.compactTabs = width < CardLayout.COMPACT_TABS_WIDTH;
|
|
367
|
+
// keep the separator's announced width current without measuring on
|
|
368
|
+
// every render — only while the width is automatic, since a chosen
|
|
369
|
+
// width is announced from state and measuring here would force a
|
|
370
|
+
// layout on every frame of a drag
|
|
371
|
+
if (this.mainWidth === 0) {
|
|
372
|
+
this.mainPaneWidth = this.getMainPaneWidth();
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
224
377
|
public disconnectedCallback(): void {
|
|
225
378
|
this.removeEventListener(
|
|
226
379
|
CustomEventType.DetailsChanged,
|
|
@@ -229,6 +382,7 @@ export class CardLayout extends RapidElement {
|
|
|
229
382
|
this.removeEventListener('toggle', this.handleToggle);
|
|
230
383
|
this.resizer?.disconnect();
|
|
231
384
|
this.mutations?.disconnect();
|
|
385
|
+
this.releaseResize();
|
|
232
386
|
|
|
233
387
|
// flush a pending save so navigating away doesn't drop it
|
|
234
388
|
if (this.saveTimeout) {
|
|
@@ -249,6 +403,192 @@ export class CardLayout extends RapidElement {
|
|
|
249
403
|
return this.getCards().map((card) => card.id);
|
|
250
404
|
}
|
|
251
405
|
|
|
406
|
+
private dragStartX = 0;
|
|
407
|
+
private dragStartWidth = 0;
|
|
408
|
+
// the chosen width when the drag began, restored if it is cancelled
|
|
409
|
+
private dragStartMainWidth = 0;
|
|
410
|
+
// whether the pointer has actually travelled — a click on the handle
|
|
411
|
+
// (a trackpad tap can fire a pointermove with a pixel or two of drift)
|
|
412
|
+
// must not commit a width, which would also turn an automatic width
|
|
413
|
+
// into a chosen one
|
|
414
|
+
private dragMoved = false;
|
|
415
|
+
// whether the pointer moved far enough to change the width — a click on
|
|
416
|
+
// the handle shouldn't announce a resize or post settings
|
|
417
|
+
private dragChanged = false;
|
|
418
|
+
// a keyboard step is pending — if it flips the mode, focus follows the
|
|
419
|
+
// rebuilt handle
|
|
420
|
+
private refocusHandle = false;
|
|
421
|
+
private previousBodyUserSelect = '';
|
|
422
|
+
private previousBodyCursor = '';
|
|
423
|
+
|
|
424
|
+
// dragging past the widest fit pins the chat there; the pointer must
|
|
425
|
+
// then travel this fraction of the card column before the layout flips
|
|
426
|
+
// to tabs — and come the complementary fraction back across it before
|
|
427
|
+
// the cards pop back out
|
|
428
|
+
static FLIP_DRAG_RATIO = 0.75;
|
|
429
|
+
|
|
430
|
+
// travel (px) before a press counts as a drag — a click on the handle
|
|
431
|
+
// carries a pixel or two of drift, which must not commit a width
|
|
432
|
+
static DRAG_THRESHOLD = 3;
|
|
433
|
+
|
|
434
|
+
private handleResizeStart = (event: PointerEvent) => {
|
|
435
|
+
if (event.pointerType === 'mouse' && event.button !== 0) return;
|
|
436
|
+
// a second pointer replaces rather than overlaps an active drag, so
|
|
437
|
+
// the captured body styles stay the ones we found before any drag
|
|
438
|
+
this.releaseResize();
|
|
439
|
+
event.preventDefault();
|
|
440
|
+
// in tab view the main pane fills the layout, so the drag starts from
|
|
441
|
+
// the full width — the cards return only once the pointer has come
|
|
442
|
+
// most of the way back across the column they will occupy
|
|
443
|
+
this.dragStartX = event.clientX;
|
|
444
|
+
this.dragStartWidth = this.getMainPaneWidth();
|
|
445
|
+
this.dragStartMainWidth = this.mainWidth;
|
|
446
|
+
this.dragMoved = false;
|
|
447
|
+
this.dragChanged = false;
|
|
448
|
+
this.resizing = true;
|
|
449
|
+
this.previousBodyUserSelect = document.body.style.userSelect;
|
|
450
|
+
this.previousBodyCursor = document.body.style.cursor;
|
|
451
|
+
document.body.style.userSelect = 'none';
|
|
452
|
+
document.body.style.cursor = 'col-resize';
|
|
453
|
+
// deliberately no setPointerCapture: a flip between card and tab mode
|
|
454
|
+
// mid-drag tears this handle out of the shadow DOM, which would end
|
|
455
|
+
// the capture and kill the drag — window listeners survive the flip
|
|
456
|
+
window.addEventListener('pointermove', this.handleResizeMove);
|
|
457
|
+
window.addEventListener('pointerup', this.handleResizeEnd);
|
|
458
|
+
window.addEventListener('pointercancel', this.handleResizeCancel);
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
private handleResizeMove = (event: PointerEvent) => {
|
|
462
|
+
event.preventDefault();
|
|
463
|
+
if (
|
|
464
|
+
Math.abs(event.clientX - this.dragStartX) >= CardLayout.DRAG_THRESHOLD
|
|
465
|
+
) {
|
|
466
|
+
this.dragMoved = true;
|
|
467
|
+
}
|
|
468
|
+
// a press that hasn't really travelled isn't a resize — leave the
|
|
469
|
+
// width alone (in particular, don't turn an automatic width into a
|
|
470
|
+
// chosen one, which would also move the flip point for good)
|
|
471
|
+
if (!this.dragMoved) {
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
// deliberately the live width rather than the lagged `hostWidth`: mid
|
|
475
|
+
// window-resize the pointer is working against the layout as it is
|
|
476
|
+
// now, not as the resize observer last saw it
|
|
477
|
+
const width = this.offsetWidth;
|
|
478
|
+
// the widest chat that still leaves the cards their footprint
|
|
479
|
+
const max = width - CardLayout.COLUMN_FOOTPRINT;
|
|
480
|
+
// where the pointer says the chat edge should be — it can run past
|
|
481
|
+
// the widest fit without the chat following
|
|
482
|
+
const dragged = Math.min(
|
|
483
|
+
Math.max(
|
|
484
|
+
this.dragStartWidth + event.clientX - this.dragStartX,
|
|
485
|
+
this.mainMinWidth
|
|
486
|
+
),
|
|
487
|
+
width
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
const flipZone = CardLayout.COLUMN_FOOTPRINT * CardLayout.FLIP_DRAG_RATIO;
|
|
491
|
+
const returnZone = CardLayout.COLUMN_FOOTPRINT - flipZone;
|
|
492
|
+
let next: number;
|
|
493
|
+
if (this.narrow) {
|
|
494
|
+
// in tabs the cards return only once the pointer has dragged far
|
|
495
|
+
// enough back; until then the width keeps tracking the pointer so
|
|
496
|
+
// a release leaves tab mode in place
|
|
497
|
+
next = dragged < max + returnZone ? Math.min(dragged, max) : dragged;
|
|
498
|
+
} else {
|
|
499
|
+
// in cards the chat stops growing at the widest fit — the flip to
|
|
500
|
+
// tabs waits until the pointer has crossed most of the card column
|
|
501
|
+
next = dragged > max + flipZone ? dragged : Math.min(dragged, max);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (next !== this.mainWidth) {
|
|
505
|
+
this.mainWidth = next;
|
|
506
|
+
this.dragChanged = true;
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
private handleResizeEnd = () => {
|
|
511
|
+
const changed = this.dragChanged;
|
|
512
|
+
this.releaseResize();
|
|
513
|
+
if (changed) {
|
|
514
|
+
this.fireCustomEvent(CustomEventType.Resized, { width: this.mainWidth });
|
|
515
|
+
this.scheduleSave();
|
|
516
|
+
}
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
/** A cancelled gesture (the browser or OS taking the pointer away) is
|
|
520
|
+
* not a resize — put the width back where the drag found it. This is a
|
|
521
|
+
* deliberate divergence from ContentList, which commits on cancel. */
|
|
522
|
+
private handleResizeCancel = () => {
|
|
523
|
+
const changed = this.dragChanged;
|
|
524
|
+
this.releaseResize();
|
|
525
|
+
if (changed) {
|
|
526
|
+
this.mainWidth = this.dragStartMainWidth;
|
|
527
|
+
}
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
/** Arrow keys resize in 10px steps (25px with Shift), the keyboard
|
|
531
|
+
* equivalent of dragging the separator. There is no hysteresis here —
|
|
532
|
+
* the width simply clamps to what fits, so a shrink press from tab mode
|
|
533
|
+
* lands in card mode at the widest width the cards leave room for. */
|
|
534
|
+
private handleResizeKeydown = (event: KeyboardEvent) => {
|
|
535
|
+
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
|
|
536
|
+
event.preventDefault();
|
|
537
|
+
event.stopPropagation();
|
|
538
|
+
const step = event.shiftKey ? 25 : 10;
|
|
539
|
+
const direction = event.key === 'ArrowRight' ? 1 : -1;
|
|
540
|
+
// step from state when a width is chosen — a measurement here would
|
|
541
|
+
// still read the previous step until its render has flushed, folding
|
|
542
|
+
// rapid same-task presses into one
|
|
543
|
+
const current = this.getEffectiveMainWidth() || this.getMainPaneWidth();
|
|
544
|
+
const width = this.clampResizeWidth(current + direction * step);
|
|
545
|
+
|
|
546
|
+
// in tab view the pane already spans more than the widest card-mode
|
|
547
|
+
// fit, so there is nothing to grow into — growing must not shrink the
|
|
548
|
+
// pane (and pop the cards back out) as a side effect of the clamp
|
|
549
|
+
if (direction > 0 && width < current) {
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (width !== this.mainWidth) {
|
|
554
|
+
this.mainWidth = width;
|
|
555
|
+
// if this press flips the mode, the handle is rebuilt from the
|
|
556
|
+
// other template and focus falls to the body — move it to the new
|
|
557
|
+
// handle so the keyboard user isn't stranded
|
|
558
|
+
this.refocusHandle = true;
|
|
559
|
+
this.fireCustomEvent(CustomEventType.Resized, { width });
|
|
560
|
+
this.scheduleSave();
|
|
561
|
+
}
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
/** Double-click returns the layout to the automatic width — the one
|
|
565
|
+
* escape hatch from a chosen width. An explicit reset also clears the
|
|
566
|
+
* stored width (unlike a passive save from a page rendering at the
|
|
567
|
+
* automatic width, which carries the stored value through). The two
|
|
568
|
+
* clicks that make up the double-click commit nothing on their own —
|
|
569
|
+
* a press with no travel is not a resize. */
|
|
570
|
+
private handleResizeReset = () => {
|
|
571
|
+
if (this.mainWidth === 0 && this.savedWidth === 0) {
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
this.mainWidth = 0;
|
|
575
|
+
this.savedWidth = 0;
|
|
576
|
+
this.fireCustomEvent(CustomEventType.Resized, { width: 0 });
|
|
577
|
+
this.scheduleSave();
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
private releaseResize() {
|
|
581
|
+
if (!this.resizing) {
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
this.resizing = false;
|
|
585
|
+
document.body.style.userSelect = this.previousBodyUserSelect;
|
|
586
|
+
document.body.style.cursor = this.previousBodyCursor;
|
|
587
|
+
window.removeEventListener('pointermove', this.handleResizeMove);
|
|
588
|
+
window.removeEventListener('pointerup', this.handleResizeEnd);
|
|
589
|
+
window.removeEventListener('pointercancel', this.handleResizeCancel);
|
|
590
|
+
}
|
|
591
|
+
|
|
252
592
|
/**
|
|
253
593
|
* Route each card to the right place for the current mode: the default
|
|
254
594
|
* slot (forwarded into the card stack) when wide, or its own named slot
|
|
@@ -354,11 +694,20 @@ export class CardLayout extends RapidElement {
|
|
|
354
694
|
.filter((id) => !present.includes(id))
|
|
355
695
|
.concat(collapsed);
|
|
356
696
|
|
|
697
|
+
const settings: CardSettings = {
|
|
698
|
+
order: this.savedOrder,
|
|
699
|
+
collapsed: this.savedCollapsed
|
|
700
|
+
};
|
|
701
|
+
// an automatic width on this page doesn't mean "no width anywhere" —
|
|
702
|
+
// carry the stored one through so a save from here doesn't clear it
|
|
703
|
+
const width = Math.round(this.mainWidth) || this.savedWidth;
|
|
704
|
+
if (width > 0) {
|
|
705
|
+
settings.width = width;
|
|
706
|
+
this.savedWidth = width;
|
|
707
|
+
}
|
|
708
|
+
|
|
357
709
|
postJSON(this.settingsEndpoint, {
|
|
358
|
-
[this.settingsKey]:
|
|
359
|
-
order: this.savedOrder,
|
|
360
|
-
collapsed: this.savedCollapsed
|
|
361
|
-
}
|
|
710
|
+
[this.settingsKey]: settings
|
|
362
711
|
}).catch(() => {
|
|
363
712
|
// a failed save isn't worth interrupting the user over — the next
|
|
364
713
|
// change will retry with the same merged state
|
|
@@ -374,8 +723,25 @@ export class CardLayout extends RapidElement {
|
|
|
374
723
|
if (this.savedOrder.length > 0) {
|
|
375
724
|
this.order = this.savedOrder;
|
|
376
725
|
}
|
|
726
|
+
this.savedWidth = this.settings.width > 0 ? this.settings.width : 0;
|
|
727
|
+
if (this.savedWidth > 0) {
|
|
728
|
+
this.mainWidth = this.savedWidth;
|
|
729
|
+
}
|
|
377
730
|
this.applyCollapsed();
|
|
378
731
|
}
|
|
732
|
+
if (changes.has('mainWidth')) {
|
|
733
|
+
// an undersized saved width (e.g. saved on a page with a lower
|
|
734
|
+
// floor) renders at this page's floor without being rewritten
|
|
735
|
+
const width = this.getEffectiveMainWidth();
|
|
736
|
+
if (width > 0) {
|
|
737
|
+
this.style.setProperty('--main-width', `${width}px`);
|
|
738
|
+
} else {
|
|
739
|
+
this.style.removeProperty('--main-width');
|
|
740
|
+
}
|
|
741
|
+
// the flip point tracks the user width — resizing wide enough to
|
|
742
|
+
// crowd out the cards flips to tabs, and back again
|
|
743
|
+
this.updateModes();
|
|
744
|
+
}
|
|
379
745
|
}
|
|
380
746
|
|
|
381
747
|
protected updated(changes: Map<PropertyKey, unknown>): void {
|
|
@@ -386,6 +752,41 @@ export class CardLayout extends RapidElement {
|
|
|
386
752
|
if (changes.has('order')) {
|
|
387
753
|
this.applyOrder();
|
|
388
754
|
}
|
|
755
|
+
if (this.refocusHandle) {
|
|
756
|
+
this.refocusHandle = false;
|
|
757
|
+
if (changes.has('narrow')) {
|
|
758
|
+
(
|
|
759
|
+
this.shadowRoot.querySelector('.resize-handle') as HTMLElement
|
|
760
|
+
)?.focus();
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
private renderResizeHandle(edge = false): TemplateResult | null {
|
|
766
|
+
if (!this.isResizable()) {
|
|
767
|
+
return null;
|
|
768
|
+
}
|
|
769
|
+
return html`
|
|
770
|
+
<div
|
|
771
|
+
class=${getClasses({
|
|
772
|
+
'resize-handle': true,
|
|
773
|
+
edge,
|
|
774
|
+
active: this.resizing
|
|
775
|
+
})}
|
|
776
|
+
role="separator"
|
|
777
|
+
aria-orientation="vertical"
|
|
778
|
+
aria-label="Resize ${this.mainName}"
|
|
779
|
+
aria-valuemin=${this.mainMinWidth}
|
|
780
|
+
aria-valuemax=${this.getResizeMax()}
|
|
781
|
+
aria-valuenow=${this.getAnnouncedWidth()}
|
|
782
|
+
tabindex="0"
|
|
783
|
+
@pointerdown=${this.handleResizeStart}
|
|
784
|
+
@dblclick=${this.handleResizeReset}
|
|
785
|
+
@keydown=${this.handleResizeKeydown}
|
|
786
|
+
>
|
|
787
|
+
<div class="grip"></div>
|
|
788
|
+
</div>
|
|
789
|
+
`;
|
|
389
790
|
}
|
|
390
791
|
|
|
391
792
|
public render(): TemplateResult {
|
|
@@ -408,12 +809,16 @@ export class CardLayout extends RapidElement {
|
|
|
408
809
|
`
|
|
409
810
|
)}
|
|
410
811
|
</temba-tabs>
|
|
812
|
+
${this.renderResizeHandle(true)}
|
|
411
813
|
`;
|
|
412
814
|
}
|
|
413
815
|
|
|
414
816
|
return html`
|
|
415
817
|
<div class="body">
|
|
416
|
-
<div class="main"
|
|
818
|
+
<div class="main">
|
|
819
|
+
<slot name="main"></slot>
|
|
820
|
+
${this.renderResizeHandle()}
|
|
821
|
+
</div>
|
|
417
822
|
<div class="column">
|
|
418
823
|
<temba-card-stack @temba-order-changed=${this.handleOrderChanged}>
|
|
419
824
|
<slot @slotchange=${this.handleSlotChange}></slot>
|
package/src/layout/CardStack.ts
CHANGED
|
@@ -21,8 +21,11 @@ export class CardStack extends RapidElement {
|
|
|
21
21
|
@property({ type: Array })
|
|
22
22
|
order: string[] = [];
|
|
23
23
|
|
|
24
|
+
/** Vertical gap between cards — the layout's one spacing unit, so
|
|
25
|
+
* card-to-card spacing matches the gap between the main view and the
|
|
26
|
+
* card column. */
|
|
24
27
|
@property({ type: String })
|
|
25
|
-
gap = 'var(--
|
|
28
|
+
gap = 'var(--layout-spacing, 8px)';
|
|
26
29
|
|
|
27
30
|
private getAssigned(): Element[] {
|
|
28
31
|
// resolve through the shadow slot so cards forwarded from a wrapper
|
package/src/list/ContactList.ts
CHANGED
|
@@ -150,27 +150,28 @@ export class ContactList extends ContentList<Contact> {
|
|
|
150
150
|
* created on.
|
|
151
151
|
*
|
|
152
152
|
* Name leads, with URN, the workspace's custom fields, and the
|
|
153
|
-
* last-seen / created-on dates trailing it.
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
* scrolls.
|
|
153
|
+
* last-seen / created-on dates trailing it. Columns through Last Seen
|
|
154
|
+
* size to their content between min/max bounds and can be resized;
|
|
155
|
+
* Created On grows to absorb any remaining browser width. Only Name is
|
|
156
|
+
* pinned to the left edge, so identity stays anchored while everything
|
|
157
|
+
* else scrolls.
|
|
159
158
|
*
|
|
160
159
|
* There is deliberately no group-membership column — contacts
|
|
161
160
|
* routinely belong to dozens of groups, so a groups cell is
|
|
162
161
|
* noise rather than signal in a list view. */
|
|
163
162
|
private buildColumns(): ContentListColumn[] {
|
|
164
|
-
// Custom-field columns are all left-aligned for simplicity
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
163
|
+
// Custom-field columns are all left-aligned for simplicity. Their
|
|
164
|
+
// resize floor is deliberately half the generic 80px floor so users
|
|
165
|
+
// can pack more workspace-specific fields into view; maxWidth caps a
|
|
166
|
+
// runaway value.
|
|
168
167
|
const fieldColumns: ContentListColumn[] = (this.featuredFields || []).map(
|
|
169
168
|
(f: any) => ({
|
|
170
169
|
key: FIELD_PREFIX + f.key,
|
|
171
170
|
label: f.name || f.label || f.key,
|
|
172
171
|
sortable: true,
|
|
173
|
-
|
|
172
|
+
resizeMinWidth: '40px',
|
|
173
|
+
maxWidth: '200px',
|
|
174
|
+
resizable: true
|
|
174
175
|
})
|
|
175
176
|
);
|
|
176
177
|
// Name + URN are the pinned identity columns and are not
|
|
@@ -182,20 +183,23 @@ export class ContactList extends ContentList<Contact> {
|
|
|
182
183
|
label: 'Name',
|
|
183
184
|
minWidth: '150px',
|
|
184
185
|
maxWidth: '260px',
|
|
185
|
-
pinned: true
|
|
186
|
+
pinned: true,
|
|
187
|
+
resizable: true
|
|
186
188
|
},
|
|
187
189
|
this.anon
|
|
188
190
|
? {
|
|
189
191
|
key: 'ref',
|
|
190
192
|
label: 'Ref',
|
|
191
193
|
minWidth: '120px',
|
|
192
|
-
maxWidth: '190px'
|
|
194
|
+
maxWidth: '190px',
|
|
195
|
+
resizable: true
|
|
193
196
|
}
|
|
194
197
|
: {
|
|
195
198
|
key: 'urn',
|
|
196
199
|
label: 'URN',
|
|
197
200
|
minWidth: '120px',
|
|
198
|
-
maxWidth: '190px'
|
|
201
|
+
maxWidth: '190px',
|
|
202
|
+
resizable: true
|
|
199
203
|
},
|
|
200
204
|
...fieldColumns,
|
|
201
205
|
{
|
|
@@ -204,15 +208,16 @@ export class ContactList extends ContentList<Contact> {
|
|
|
204
208
|
sortable: true,
|
|
205
209
|
minWidth: '96px',
|
|
206
210
|
maxWidth: '150px',
|
|
207
|
-
align: 'right'
|
|
211
|
+
align: 'right',
|
|
212
|
+
resizable: true
|
|
208
213
|
},
|
|
209
214
|
{
|
|
210
215
|
key: 'created_on',
|
|
211
216
|
label: 'Created on',
|
|
212
217
|
sortable: true,
|
|
213
218
|
minWidth: '96px',
|
|
214
|
-
|
|
215
|
-
|
|
219
|
+
align: 'right',
|
|
220
|
+
grow: true
|
|
216
221
|
}
|
|
217
222
|
];
|
|
218
223
|
}
|