@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.
Files changed (59) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/locales/es.js +3 -1
  3. package/dist/locales/es.js.map +1 -1
  4. package/dist/locales/fr.js +3 -1
  5. package/dist/locales/fr.js.map +1 -1
  6. package/dist/locales/pt.js +3 -1
  7. package/dist/locales/pt.js.map +1 -1
  8. package/dist/temba-components.js +2791 -1668
  9. package/dist/temba-components.js.map +1 -1
  10. package/package.json +1 -1
  11. package/src/Icons.ts +2 -0
  12. package/src/display/Button.ts +8 -6
  13. package/src/display/Chat.ts +541 -191
  14. package/src/display/Dropdown.ts +40 -17
  15. package/src/display/Label.ts +25 -4
  16. package/src/display/TembaDate.ts +11 -2
  17. package/src/display/Tip.ts +157 -18
  18. package/src/events/eventRenderers.ts +678 -268
  19. package/src/events.ts +16 -1
  20. package/src/flow/NodeEditor.ts +82 -9
  21. package/src/flow/actions/add_contact_urn.ts +1 -0
  22. package/src/flow/actions/play_audio.ts +1 -0
  23. package/src/flow/actions/send_broadcast.ts +17 -1
  24. package/src/flow/actions/send_msg.ts +12 -1
  25. package/src/flow/actions/set_run_result.ts +1 -0
  26. package/src/flow/actions/start_session.ts +10 -1
  27. package/src/flow/nodes/shared-rules.ts +2 -0
  28. package/src/flow/nodes/split_by_airtime.ts +6 -0
  29. package/src/flow/nodes/split_by_expression.ts +1 -0
  30. package/src/flow/nodes/split_by_llm.ts +2 -0
  31. package/src/flow/nodes/split_by_llm_categorize.ts +3 -1
  32. package/src/flow/nodes/split_by_random.ts +2 -1
  33. package/src/flow/nodes/split_by_resthook.ts +4 -1
  34. package/src/flow/nodes/split_by_webhook.ts +9 -1
  35. package/src/flow/types.ts +3 -0
  36. package/src/flow/utils.ts +49 -0
  37. package/src/form/DatePicker.ts +2 -1
  38. package/src/layout/Card.ts +9 -0
  39. package/src/layout/CardLayout.ts +426 -21
  40. package/src/layout/CardStack.ts +4 -1
  41. package/src/list/ContactList.ts +22 -17
  42. package/src/list/ContentList.ts +632 -29
  43. package/src/list/MsgList.ts +4 -2
  44. package/src/list/SortableList.ts +5 -1
  45. package/src/list/TembaMenu.ts +67 -6
  46. package/src/live/ContactChat.ts +655 -336
  47. package/src/live/ContactDetails.ts +961 -88
  48. package/src/live/ContactFieldEditor.ts +46 -11
  49. package/src/live/ContactFields.ts +57 -16
  50. package/src/live/ContactStoreElement.ts +15 -8
  51. package/src/locales/es.ts +3 -2
  52. package/src/locales/fr.ts +3 -2
  53. package/src/locales/pt.ts +3 -2
  54. package/src/simulator/Simulator.ts +28 -0
  55. package/src/styles/pillVariants.ts +13 -1
  56. package/web-test-runner.config.mjs +2 -0
  57. package/xliff/es.xlf +6 -3
  58. package/xliff/fr.xlf +6 -3
  59. package/xliff/pt.xlf +6 -3
@@ -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 and collapsed state itself: seed it
26
- * with `settings` and point `settings-endpoint` at a POST endpoint that
27
- * merges top-level keys (rapidpro's user settings view). Saves are
28
- * debounced and posted as `{[settingsKey]: {order, collapsed}}`. The saved
29
- * lists are the union across pages — a page rendering only a subset of the
30
- * cards merges its relative order into the full saved order rather than
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
- private getFlipWidth(): number {
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"><slot name="main"></slot></div>
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>
@@ -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(--gap, 14px)';
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
@@ -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. Every column sizes to
154
- * its content between min/max bounds none are hard-fixed — so the
155
- * table stays compact and overflows into a horizontal scroll only
156
- * when the field set is genuinely wide. Only Name is pinned to the
157
- * left edge, so identity stays anchored while everything else
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, and
165
- // set no minWidth their natural floor is the column header
166
- // label, which the table's auto layout already honours; maxWidth
167
- // just caps a runaway value.
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
- maxWidth: '200px'
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
- maxWidth: '150px',
215
- align: 'right'
219
+ align: 'right',
220
+ grow: true
216
221
  }
217
222
  ];
218
223
  }