@nyaruka/temba-components 0.167.0 → 0.168.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +27 -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 +2842 -1715
  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 +24 -17
  42. package/src/list/ContentList.ts +640 -33
  43. package/src/list/MsgList.ts +7 -1
  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
@@ -3,7 +3,7 @@ import { property, state } from 'lit/decorators.js';
3
3
  import { RapidElement } from '../RapidElement';
4
4
  import { Icon } from '../Icons';
5
5
  import { CustomEventType } from '../interfaces';
6
- import { getUrl, postUrl } from '../utils';
6
+ import { getUrl, postJSON, postUrl } from '../utils';
7
7
  import { designTokens } from '../styles/designTokens';
8
8
 
9
9
  /** A single column in the list. Subclasses typically define a static
@@ -41,6 +41,12 @@ export interface ContentListColumn {
41
41
  * columns must be contiguous from the first column; right-pinned
42
42
  * columns contiguous to the last. */
43
43
  pinned?: boolean | 'left' | 'right';
44
+ /** Whether the column can be resized from its trailing edge. Resizing is
45
+ * opt-in so list types can expose it only where the interaction is useful. */
46
+ resizable?: boolean;
47
+ /** Optional resize-only floor. Unlike minWidth, this does not affect the
48
+ * table's native layout before the user resizes the column. */
49
+ resizeMinWidth?: string;
44
50
  }
45
51
 
46
52
  /** A bulk action surfaced in the toolbar when one or more rows are
@@ -208,7 +214,9 @@ export class ContentList<T = any> extends RapidElement {
208
214
  the select-all checkbox — rather than replacing the page header.
209
215
  Positioned in the table-frame (not the scrolling table) so it
210
216
  stays put horizontally, with an opaque header-tint background to
211
- hide the labels underneath, above the sticky header (z 2/3). */
217
+ hide the labels underneath, above every header layer pinned
218
+ header cells reach z 5 (see tr.header th.pinned), so the bar
219
+ sits at 6 to cover them too. */
212
220
  .bulk-bar {
213
221
  position: absolute;
214
222
  top: 0;
@@ -219,7 +227,7 @@ export class ContentList<T = any> extends RapidElement {
219
227
  left: var(--cl-firstcol-left, 44px);
220
228
  right: var(--cl-scrollbar-w, 0px);
221
229
  height: var(--cl-header-height, 36px);
222
- z-index: 4;
230
+ z-index: 6;
223
231
  display: flex;
224
232
  align-items: center;
225
233
  gap: 4px;
@@ -561,8 +569,10 @@ export class ContentList<T = any> extends RapidElement {
561
569
 
562
570
  /* The header row sticks to the top of the scroll frame so the
563
571
  column labels stay put while the rows scroll under them.
564
- z-index 2 lifts it above the body; a pinned header cell
565
- needs 3 to also clear the body's pinned column (z-index 1). */
572
+ z-index 2 lifts it above the body; a pinned header cell needs
573
+ more to also clear the body's pinned column (z-index 1) and the
574
+ resize-handle layers (z 4) — but must stay under the bulk-action
575
+ bar (z 6), which overlays the whole header row on selection. */
566
576
  tr.header th {
567
577
  height: 36px;
568
578
  vertical-align: middle;
@@ -585,7 +595,7 @@ export class ContentList<T = any> extends RapidElement {
585
595
  z-index: 2;
586
596
  }
587
597
  tr.header th.pinned {
588
- z-index: 3;
598
+ z-index: 5;
589
599
  }
590
600
 
591
601
  tr.row td {
@@ -699,6 +709,64 @@ export class ContentList<T = any> extends RapidElement {
699
709
  justify-content: center;
700
710
  }
701
711
 
712
+ /* Column resize affordance. The hit target straddles the column
713
+ boundary from the leading edge of the following header, which
714
+ keeps both halves clickable above that cell's stacking context.
715
+ Its rule remains visible so column boundaries are
716
+ discoverable without hovering; interaction strengthens it.
717
+ A real element (rather than a pseudo-element) gives keyboard and
718
+ screen-reader users the same control. */
719
+ .resize-handle {
720
+ position: absolute;
721
+ top: 0;
722
+ left: -5px;
723
+ bottom: 0;
724
+ z-index: 4;
725
+ width: 10px;
726
+ cursor: col-resize;
727
+ touch-action: none;
728
+ outline: none;
729
+ }
730
+ .resize-handle::after {
731
+ content: '';
732
+ position: absolute;
733
+ top: 7px;
734
+ left: 50%;
735
+ bottom: 7px;
736
+ width: 1px;
737
+ border-radius: 1px;
738
+ background: var(--border);
739
+ transform: translateX(-50%);
740
+ transition:
741
+ width 0.12s ease,
742
+ background 0.12s ease;
743
+ }
744
+ :host(:not([column-resizing])) .resize-handle:hover::after,
745
+ :host(:not([column-resizing])) .resize-handle:focus-visible::after,
746
+ .resize-handle.resizing::after {
747
+ width: 3px;
748
+ background: var(--border-strong);
749
+ }
750
+ /* A header hosting the preceding column's handle must paint above
751
+ its neighbour so the target stays centered across the boundary. */
752
+ tr.header th:has(> .resize-handle.leading) {
753
+ z-index: 4;
754
+ }
755
+ /* Keep the final column's target inside the table so it doesn't
756
+ create a few pixels of horizontal overflow. Its rule stays in
757
+ the target's center, just inboard of the table edge. */
758
+ .resize-handle.trailing {
759
+ left: auto;
760
+ right: -5px;
761
+ }
762
+ .resize-handle.trailing.outer {
763
+ right: 0;
764
+ }
765
+ :host([column-resizing]),
766
+ :host([column-resizing]) * {
767
+ cursor: col-resize !important;
768
+ }
769
+
702
770
  /* Pinned columns stay fixed against their edge while the rest
703
771
  of the table scrolls under them. The frozen-region look —
704
772
  the tint and the divider — only kicks in once the table
@@ -725,31 +793,17 @@ export class ContentList<T = any> extends RapidElement {
725
793
  .table-frame.overflowing tr.row.selected td.pinned {
726
794
  background: var(--cl-selected);
727
795
  }
728
- /* A subtle vertical rule marks where the pinned section ends.
729
- It is an inset shadow rather than a border so it stays put
730
- with the sticky cell under border-collapse. */
731
- .table-frame.overflowing th.pin-last,
796
+ /* Body cells retain a subtle vertical rule where the pinned section
797
+ ends. Header boundaries are already represented by resize
798
+ separators, so they only keep the standard bottom rule. */
732
799
  .table-frame.overflowing td.pin-last {
733
800
  box-shadow: inset -1px 0 0 0 var(--border);
734
801
  }
735
802
  /* Mirror of the rule for the right-pinned group — the divider
736
803
  sits on the inboard (left) edge of its first cell. */
737
- .table-frame.overflowing th.pin-first,
738
804
  .table-frame.overflowing td.pin-first {
739
805
  box-shadow: inset 1px 0 0 0 var(--border);
740
806
  }
741
- /* A pinned header cell keeps the header's bottom-border shadow
742
- alongside the pin-edge rule. */
743
- .table-frame.overflowing tr.header th.pin-last {
744
- box-shadow:
745
- inset -1px 0 0 0 var(--border),
746
- inset 0 -1px 0 0 var(--border);
747
- }
748
- .table-frame.overflowing tr.header th.pin-first {
749
- box-shadow:
750
- inset 1px 0 0 0 var(--border),
751
- inset 0 -1px 0 0 var(--border);
752
- }
753
807
  /* Once scrolled, the frozen edge casts the same soft scroll
754
808
  shadow as the sticky header — a gradient (matching th::after
755
809
  and .scroll-shadow) drawn just outside the pinned edge, over
@@ -1261,6 +1315,22 @@ export class ContentList<T = any> extends RapidElement {
1261
1315
  @property({ type: String, attribute: 'history-state-key' })
1262
1316
  historyStateKey = '';
1263
1317
 
1318
+ /** Saved widths for every list, keyed first by the list's
1319
+ * {@link historyStateKey}, then by column key. The host can seed this
1320
+ * from user settings so each list remembers its own layout. This is
1321
+ * the same `list_columns` value that {@link settingsEndpoint} saves —
1322
+ * the attribute is the read side of that round trip. */
1323
+ @property({ type: Object, attribute: 'column-width-settings' })
1324
+ columnWidthSettings: Record<string, Record<string, number>> = {};
1325
+
1326
+ /** Endpoint that accepts user-setting updates. Width persistence is
1327
+ * disabled when this or {@link historyStateKey} is unset. */
1328
+ @property({ type: String, attribute: 'settings-endpoint' })
1329
+ settingsEndpoint = '';
1330
+
1331
+ /** Debounce window for settings saves. Public so tests can shorten it. */
1332
+ saveDelay = 500;
1333
+
1264
1334
  /** Placeholder for the search input. */
1265
1335
  @property({ type: String })
1266
1336
  searchPlaceholder = 'Search';
@@ -1403,6 +1473,26 @@ export class ContentList<T = any> extends RapidElement {
1403
1473
  private popstateHandler: () => void;
1404
1474
  private resizeHandler: () => void;
1405
1475
 
1476
+ /** Active pointer-driven column resize. Window listeners keep the drag
1477
+ * alive when the pointer outruns the narrow header handle. */
1478
+ private columnResize:
1479
+ | {
1480
+ key: string;
1481
+ startX: number;
1482
+ startWidth: number;
1483
+ minWidth: number;
1484
+ nativeFloor: boolean;
1485
+ initialSavedWidth: number | undefined;
1486
+ changed: boolean;
1487
+ handle: HTMLElement;
1488
+ }
1489
+ | undefined;
1490
+ private previousBodyUserSelect = '';
1491
+ private columnWidths: Record<string, number> = {};
1492
+ private columnWidthSaveTimeout: ReturnType<typeof setTimeout> = null;
1493
+ private suppressHeaderClick = false;
1494
+ private headerClickSuppressionTimeout: ReturnType<typeof setTimeout> = null;
1495
+
1406
1496
  /** Pin index assigned to each left-pinned column / leading cell,
1407
1497
  * used to resolve its sticky `left`. Recomputed each render. */
1408
1498
  private pinIndexByColumn = new Map<ContentListColumn, number>();
@@ -1427,6 +1517,42 @@ export class ContentList<T = any> extends RapidElement {
1427
1517
  super();
1428
1518
  }
1429
1519
 
1520
+ protected willUpdate(changes: PropertyValues): void {
1521
+ super.willUpdate(changes);
1522
+ if (
1523
+ changes.has('columnWidthSettings') ||
1524
+ changes.has('historyStateKey') ||
1525
+ changes.has('columns')
1526
+ ) {
1527
+ const saved = this.columnWidthSettings?.[this.historyStateKey];
1528
+ const widths: Record<string, number> = {};
1529
+ if (saved && typeof saved === 'object') {
1530
+ Object.entries(saved).forEach(([key, value]) => {
1531
+ if (typeof value === 'number' && Number.isFinite(value)) {
1532
+ const column = this.columns.find(
1533
+ (candidate) => candidate.key === key
1534
+ );
1535
+ widths[key] = this.clampColumnWidth(
1536
+ value,
1537
+ column
1538
+ ? this.minimumColumnWidth(column)
1539
+ : ContentList.MIN_COLUMN_WIDTH
1540
+ );
1541
+ }
1542
+ });
1543
+ }
1544
+ // A columns rebuild (custom fields arriving, anon flipping) must
1545
+ // not discard widths dragged this session that haven't round-tripped
1546
+ // through the settings attribute — only a new settings payload or a
1547
+ // different list replaces them outright.
1548
+ const preserved =
1549
+ changes.has('columnWidthSettings') || changes.has('historyStateKey')
1550
+ ? {}
1551
+ : this.columnWidths;
1552
+ this.columnWidths = { ...widths, ...preserved };
1553
+ }
1554
+ }
1555
+
1430
1556
  public connectedCallback(): void {
1431
1557
  super.connectedCallback();
1432
1558
  if (this.urlState) {
@@ -1483,6 +1609,16 @@ export class ContentList<T = any> extends RapidElement {
1483
1609
  if (this.resizeHandler) {
1484
1610
  window.removeEventListener('resize', this.resizeHandler);
1485
1611
  }
1612
+ this.stopColumnResize();
1613
+ if (this.columnWidthSaveTimeout) {
1614
+ clearTimeout(this.columnWidthSaveTimeout);
1615
+ this.columnWidthSaveTimeout = null;
1616
+ this.saveColumnWidths();
1617
+ }
1618
+ if (this.headerClickSuppressionTimeout) {
1619
+ clearTimeout(this.headerClickSuppressionTimeout);
1620
+ this.headerClickSuppressionTimeout = null;
1621
+ }
1486
1622
  if (this.bulkCollapseFrame) {
1487
1623
  cancelAnimationFrame(this.bulkCollapseFrame);
1488
1624
  this.bulkCollapseFrame = 0;
@@ -1947,6 +2083,23 @@ export class ContentList<T = any> extends RapidElement {
1947
2083
  this.writeUrlState();
1948
2084
  }
1949
2085
 
2086
+ private handleColumnHeaderClick(
2087
+ event: MouseEvent,
2088
+ column: ContentListColumn
2089
+ ): void {
2090
+ // Releasing a resize over the header label can synthesize a click on
2091
+ // the shared <th>. Consume that click so resizing never changes sort.
2092
+ if (this.suppressHeaderClick) {
2093
+ event.preventDefault();
2094
+ event.stopPropagation();
2095
+ this.suppressHeaderClick = false;
2096
+ clearTimeout(this.headerClickSuppressionTimeout);
2097
+ this.headerClickSuppressionTimeout = null;
2098
+ return;
2099
+ }
2100
+ this.handleSortClick(column);
2101
+ }
2102
+
1950
2103
  private handleRowClick(item: T, event: MouseEvent): void {
1951
2104
  // Ignore clicks originating from the checkbox cell so toggling
1952
2105
  // selection doesn't double as navigation.
@@ -2577,10 +2730,12 @@ export class ContentList<T = any> extends RapidElement {
2577
2730
  * the column simply sizes to its content (header label or widest
2578
2731
  * value) via the table's auto layout. */
2579
2732
  private cellWidthStyle(column: ContentListColumn): string {
2733
+ const savedWidth = this.savedColumnWidth(column);
2580
2734
  // Under fixed layout the column widths are set on the cells
2581
2735
  // themselves (see {@link renderHeaderCell}); the inner wrapper
2582
2736
  // just fills its cell and ellipsis-truncates against it.
2583
2737
  if (this.fixedLayout) return '';
2738
+ if (savedWidth != null) return `width: ${savedWidth}px;`;
2584
2739
  if (column.width) return `width: ${column.width};`;
2585
2740
  const parts: string[] = [];
2586
2741
  if (column.minWidth) parts.push(`min-width: ${column.minWidth};`);
@@ -2590,6 +2745,391 @@ export class ContentList<T = any> extends RapidElement {
2590
2745
  return parts.join(' ');
2591
2746
  }
2592
2747
 
2748
+ /** Numeric pixel value for a CSS width used by the list's column
2749
+ * contracts. Resizable columns use pixel widths because pointer movement
2750
+ * is measured in pixels; non-pixel bounds fall back to the defaults. */
2751
+ private columnWidthPixels(
2752
+ value: string | undefined,
2753
+ fallback: number
2754
+ ): number {
2755
+ if (!value || !value.trim().endsWith('px')) return fallback;
2756
+ const parsed = Number.parseFloat(value);
2757
+ return Number.isFinite(parsed) ? parsed : fallback;
2758
+ }
2759
+
2760
+ private static readonly MIN_COLUMN_WIDTH = 80;
2761
+ private static readonly MAX_COLUMN_WIDTH = 600;
2762
+
2763
+ private clampColumnWidth(
2764
+ width: number,
2765
+ minWidth = ContentList.MIN_COLUMN_WIDTH
2766
+ ): number {
2767
+ const floor = Math.min(minWidth, ContentList.MAX_COLUMN_WIDTH);
2768
+ return Math.round(
2769
+ Math.min(Math.max(width, floor), ContentList.MAX_COLUMN_WIDTH)
2770
+ );
2771
+ }
2772
+
2773
+ private isColumnResizable(column: ContentListColumn): boolean {
2774
+ return column.resizable === true && !column.grow;
2775
+ }
2776
+
2777
+ private savedColumnWidth(column: ContentListColumn): number | undefined {
2778
+ return this.isColumnResizable(column)
2779
+ ? this.columnWidths[column.key]
2780
+ : undefined;
2781
+ }
2782
+
2783
+ private effectiveColumnWidth(column: ContentListColumn): number {
2784
+ return (
2785
+ this.savedColumnWidth(column) ??
2786
+ this.columnWidthPixels(column.width, ContentList.MIN_COLUMN_WIDTH)
2787
+ );
2788
+ }
2789
+
2790
+ private minimumColumnWidth(column: ContentListColumn): number {
2791
+ return this.columnWidthPixels(
2792
+ column.resizeMinWidth ?? column.minWidth,
2793
+ ContentList.MIN_COLUMN_WIDTH
2794
+ );
2795
+ }
2796
+
2797
+ private maximumColumnWidth(column: ContentListColumn): number {
2798
+ return this.columnWidthPixels(
2799
+ column.maxWidth,
2800
+ ContentList.MAX_COLUMN_WIDTH
2801
+ );
2802
+ }
2803
+
2804
+ private hasPrescribedColumnWidth(column: ContentListColumn): boolean {
2805
+ return this.savedColumnWidth(column) != null || column.width != null;
2806
+ }
2807
+
2808
+ private hasColumnWidthSlackSink(): boolean {
2809
+ return (
2810
+ this.columns.some((column) => column.grow) || this.spacerAfterIndex >= 0
2811
+ );
2812
+ }
2813
+
2814
+ private usesNativeWidthFloor(
2815
+ column: ContentListColumn,
2816
+ renderedWidth: number,
2817
+ prescribedWidth: number
2818
+ ): boolean {
2819
+ return (
2820
+ !this.hasColumnWidthSlackSink() &&
2821
+ this.hasPrescribedColumnWidth(column) &&
2822
+ renderedWidth > prescribedWidth + 1
2823
+ );
2824
+ }
2825
+
2826
+ /** Store a user's width separately from the static column definition.
2827
+ * This keeps overrides intact when a list rebuilds dynamic columns. */
2828
+ private setColumnWidth(
2829
+ key: string,
2830
+ width: number,
2831
+ minWidth = ContentList.MIN_COLUMN_WIDTH
2832
+ ): number {
2833
+ const nextWidth = this.clampColumnWidth(width, minWidth);
2834
+ if (this.columnWidths[key] === nextWidth) return nextWidth;
2835
+ this.columnWidths = { ...this.columnWidths, [key]: nextWidth };
2836
+ this.requestUpdate();
2837
+ return nextWidth;
2838
+ }
2839
+
2840
+ private restoreColumnWidth(key: string, width: number | undefined): void {
2841
+ if (this.columnWidths[key] === width) return;
2842
+ const widths = { ...this.columnWidths };
2843
+ if (width == null) delete widths[key];
2844
+ else widths[key] = width;
2845
+ this.columnWidths = widths;
2846
+ this.requestUpdate();
2847
+ }
2848
+
2849
+ /** The width contract is carried by the inner wrapper, while the resize
2850
+ * handle sits on the outer table-cell boundary. Auto table layout can
2851
+ * distribute spare room to that cell, so derive the matching content
2852
+ * width from the rendered cell rather than trusting its prescribed width. */
2853
+ private renderedColumnWidth(
2854
+ header: HTMLElement | null,
2855
+ column: ContentListColumn
2856
+ ): number {
2857
+ if (!header) return this.effectiveColumnWidth(column);
2858
+ const style = getComputedStyle(header);
2859
+ const padding =
2860
+ Number.parseFloat(style.paddingLeft) +
2861
+ Number.parseFloat(style.paddingRight);
2862
+ const width = header.getBoundingClientRect().width - padding;
2863
+ return Number.isFinite(width) && width > 0
2864
+ ? width
2865
+ : this.effectiveColumnWidth(column);
2866
+ }
2867
+
2868
+ private scheduleColumnWidthSave(): void {
2869
+ if (!this.settingsEndpoint || !this.historyStateKey) return;
2870
+ clearTimeout(this.columnWidthSaveTimeout);
2871
+ this.columnWidthSaveTimeout = setTimeout(() => {
2872
+ this.columnWidthSaveTimeout = null;
2873
+ this.saveColumnWidths();
2874
+ }, this.saveDelay);
2875
+ }
2876
+
2877
+ private saveColumnWidths(): void {
2878
+ if (!this.settingsEndpoint || !this.historyStateKey) return;
2879
+ // This posts only the current list's widths, nested under the same
2880
+ // historyStateKey used to read them back out of columnWidthSettings.
2881
+ // The endpoint must merge list_columns at both the view and column
2882
+ // level (as temba's user-settings endpoint does) — replacing it
2883
+ // wholesale would wipe the widths saved for every other list.
2884
+ postJSON(this.settingsEndpoint, {
2885
+ list_columns: {
2886
+ [this.historyStateKey]: this.columnWidths
2887
+ }
2888
+ }).catch(() => {
2889
+ // Width persistence is a convenience; a failed save should not
2890
+ // interrupt list interaction. A later resize will retry it.
2891
+ });
2892
+ }
2893
+
2894
+ private startColumnResize(
2895
+ event: PointerEvent,
2896
+ column: ContentListColumn
2897
+ ): void {
2898
+ if (event.pointerType === 'mouse' && event.button !== 0) return;
2899
+ // A second pointer should replace, not overlap, an active drag and
2900
+ // must preserve the body's original selection style for cleanup.
2901
+ this.stopColumnResize();
2902
+ event.preventDefault();
2903
+ event.stopPropagation();
2904
+ const handle = event.currentTarget as HTMLElement;
2905
+ // Capture the pointer so the drag survives excursions outside the
2906
+ // window and its release retargets to the handle — the synthesized
2907
+ // click then lands on the handle (which swallows clicks) instead of
2908
+ // whichever header happens to be under the pointer.
2909
+ try {
2910
+ handle.setPointerCapture(event.pointerId);
2911
+ } catch {
2912
+ // synthetic pointers (tests) have no active pointer to capture
2913
+ }
2914
+ const header = this.resizeHeaderForHandle(handle);
2915
+ const prescribedWidth = this.effectiveColumnWidth(column);
2916
+ const startWidth = this.renderedColumnWidth(header, column);
2917
+ const resizeFloor = this.minimumRenderedColumnWidth(header, column);
2918
+ const nativeFloor = this.usesNativeWidthFloor(
2919
+ column,
2920
+ startWidth,
2921
+ prescribedWidth
2922
+ );
2923
+ this.columnResize = {
2924
+ key: column.key,
2925
+ startX: event.clientX,
2926
+ startWidth,
2927
+ minWidth: nativeFloor ? startWidth : resizeFloor,
2928
+ nativeFloor,
2929
+ initialSavedWidth: this.columnWidths[column.key],
2930
+ changed: false,
2931
+ handle
2932
+ };
2933
+ handle.setAttribute(
2934
+ 'aria-valuemin',
2935
+ `${Math.round(nativeFloor ? startWidth : resizeFloor)}`
2936
+ );
2937
+ handle.classList.add('resizing');
2938
+ this.previousBodyUserSelect = document.body.style.userSelect;
2939
+ document.body.style.userSelect = 'none';
2940
+ this.toggleAttribute('column-resizing', true);
2941
+ window.addEventListener('pointermove', this.handleColumnResizeMove);
2942
+ window.addEventListener('pointerup', this.stopColumnResize);
2943
+ window.addEventListener('pointercancel', this.stopColumnResize);
2944
+ }
2945
+
2946
+ private handleColumnResizeMove = (event: PointerEvent): void => {
2947
+ if (!this.columnResize) return;
2948
+ event.preventDefault();
2949
+ const resize = this.columnResize;
2950
+ const requestedWidth = resize.startWidth + event.clientX - resize.startX;
2951
+ if (
2952
+ resize.nativeFloor &&
2953
+ (requestedWidth <= resize.minWidth ||
2954
+ resize.minWidth >= ContentList.MAX_COLUMN_WIDTH)
2955
+ ) {
2956
+ // The table is already rendering wider than the CSS contract. Treat
2957
+ // that native allocation as the floor without writing it back: doing
2958
+ // so would feed the spare width into the next auto-layout pass and
2959
+ // move the boundary even though the user dragged left.
2960
+ this.restoreColumnWidth(resize.key, resize.initialSavedWidth);
2961
+ } else {
2962
+ this.setColumnWidth(resize.key, requestedWidth, resize.minWidth);
2963
+ }
2964
+ resize.changed = this.columnWidths[resize.key] !== resize.initialSavedWidth;
2965
+ };
2966
+
2967
+ private stopColumnResize = (event?: Event): void => {
2968
+ if (!this.columnResize) return;
2969
+ const { changed, handle } = this.columnResize;
2970
+ handle.classList.remove('resizing');
2971
+ this.columnResize = undefined;
2972
+ document.body.style.userSelect = this.previousBodyUserSelect;
2973
+ this.toggleAttribute('column-resizing', false);
2974
+ window.removeEventListener('pointermove', this.handleColumnResizeMove);
2975
+ window.removeEventListener('pointerup', this.stopColumnResize);
2976
+ window.removeEventListener('pointercancel', this.stopColumnResize);
2977
+ if (event?.type === 'pointerup') {
2978
+ this.suppressHeaderClick = true;
2979
+ clearTimeout(this.headerClickSuppressionTimeout);
2980
+ this.headerClickSuppressionTimeout = setTimeout(() => {
2981
+ this.suppressHeaderClick = false;
2982
+ this.headerClickSuppressionTimeout = null;
2983
+ }, 0);
2984
+ }
2985
+ if (changed) this.scheduleColumnWidthSave();
2986
+ };
2987
+
2988
+ /** Arrow keys resize in 10px steps (25px with Shift), providing an
2989
+ * accessible equivalent to dragging the separator. */
2990
+ private handleColumnResizeKeydown(
2991
+ event: KeyboardEvent,
2992
+ column: ContentListColumn
2993
+ ): void {
2994
+ if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
2995
+ event.preventDefault();
2996
+ event.stopPropagation();
2997
+ const handle = event.currentTarget as HTMLElement;
2998
+ const header = this.resizeHeaderForHandle(handle);
2999
+ const prescribedWidth = this.effectiveColumnWidth(column);
3000
+ const currentWidth = this.renderedColumnWidth(header, column);
3001
+ const resizeFloor = this.minimumRenderedColumnWidth(header, column);
3002
+ const nativeFloor = this.usesNativeWidthFloor(
3003
+ column,
3004
+ currentWidth,
3005
+ prescribedWidth
3006
+ );
3007
+ const direction = event.key === 'ArrowRight' ? 1 : -1;
3008
+ if (
3009
+ nativeFloor &&
3010
+ (direction < 0 || currentWidth >= ContentList.MAX_COLUMN_WIDTH)
3011
+ ) {
3012
+ handle.setAttribute('aria-valuenow', `${Math.round(currentWidth)}`);
3013
+ return;
3014
+ }
3015
+ const width = this.setColumnWidth(
3016
+ column.key,
3017
+ currentWidth + direction * (event.shiftKey ? 25 : 10),
3018
+ nativeFloor ? currentWidth : resizeFloor
3019
+ );
3020
+ handle.setAttribute(
3021
+ 'aria-valuemin',
3022
+ `${Math.round(nativeFloor ? currentWidth : resizeFloor)}`
3023
+ );
3024
+ handle.setAttribute('aria-valuenow', `${width}`);
3025
+ this.scheduleColumnWidthSave();
3026
+ }
3027
+
3028
+ /** An auto-sized column has no saved or prescribed width at render
3029
+ * time, so the template's aria-valuenow is only a floor. Measure the
3030
+ * real rendered width when the separator gains focus so screen
3031
+ * readers announce the width the user actually sees. */
3032
+ private handleColumnResizeFocus(
3033
+ event: FocusEvent,
3034
+ column: ContentListColumn
3035
+ ): void {
3036
+ const handle = event.currentTarget as HTMLElement;
3037
+ const header = this.resizeHeaderForHandle(handle);
3038
+ handle.setAttribute(
3039
+ 'aria-valuenow',
3040
+ `${Math.round(this.renderedColumnWidth(header, column))}`
3041
+ );
3042
+ }
3043
+
3044
+ /** Fit the column to the widest rendered header/body value. Cell
3045
+ * contents are measured at max-content width without painting a layout
3046
+ * change; the configured column bounds keep outliers manageable. */
3047
+ private intrinsicWidth(element: HTMLElement): number {
3048
+ const previousStyle = element.getAttribute('style');
3049
+ element.style.width = 'max-content';
3050
+ element.style.minWidth = '0';
3051
+ element.style.maxWidth = 'none';
3052
+ element.style.position = 'absolute';
3053
+ const width = element.getBoundingClientRect().width;
3054
+ if (previousStyle == null) element.removeAttribute('style');
3055
+ else element.setAttribute('style', previousStyle);
3056
+ return width;
3057
+ }
3058
+
3059
+ private minimumRenderedColumnWidth(
3060
+ header: HTMLElement | null,
3061
+ column: ContentListColumn
3062
+ ): number {
3063
+ const configuredFloor = this.minimumColumnWidth(column);
3064
+ const headerInner = header?.querySelector<HTMLElement>('.head-inner');
3065
+ return headerInner
3066
+ ? Math.max(configuredFloor, this.headerContentWidth(headerInner))
3067
+ : configuredFloor;
3068
+ }
3069
+
3070
+ private headerContentWidth(headerInner: HTMLElement): number {
3071
+ return Math.ceil(
3072
+ Array.from(headerInner.children).reduce(
3073
+ (width, child) =>
3074
+ width + (child as HTMLElement).getBoundingClientRect().width,
3075
+ 0
3076
+ )
3077
+ );
3078
+ }
3079
+
3080
+ private handleColumnAutoFit(
3081
+ event: MouseEvent,
3082
+ column: ContentListColumn
3083
+ ): void {
3084
+ event.preventDefault();
3085
+ event.stopPropagation();
3086
+ const handle = event.currentTarget as HTMLElement;
3087
+ const header = this.resizeHeaderForHandle(handle);
3088
+ if (!header) return;
3089
+
3090
+ const widths: number[] = [];
3091
+ const headerInner = header.querySelector<HTMLElement>('.head-inner');
3092
+ if (headerInner) widths.push(this.headerContentWidth(headerInner));
3093
+
3094
+ const columnIndex = (header as HTMLTableCellElement).cellIndex;
3095
+ this.shadowRoot
3096
+ ?.querySelectorAll<HTMLTableRowElement>('tr.row')
3097
+ .forEach((row) => {
3098
+ const cell = row.cells.item(columnIndex);
3099
+ const inner = cell?.querySelector<HTMLElement>('.cell-inner');
3100
+ if (!inner) return;
3101
+ let contentWidth = this.intrinsicWidth(inner);
3102
+ const icon = cell.querySelector<HTMLElement>('.lead-icon');
3103
+ if (icon) {
3104
+ const iconStyle = getComputedStyle(icon);
3105
+ contentWidth +=
3106
+ icon.offsetWidth + Number.parseFloat(iconStyle.marginRight || '0');
3107
+ }
3108
+ widths.push(contentWidth);
3109
+ });
3110
+
3111
+ const minWidth = this.minimumRenderedColumnWidth(header, column);
3112
+ const maxWidth = Math.max(this.maximumColumnWidth(column), minWidth);
3113
+ const contentWidth = widths.length ? Math.max(...widths) : minWidth;
3114
+ const width = this.setColumnWidth(
3115
+ column.key,
3116
+ Math.min(Math.max(contentWidth, minWidth), maxWidth),
3117
+ minWidth
3118
+ );
3119
+ handle.setAttribute('aria-valuenow', `${width}`);
3120
+ this.scheduleColumnWidthSave();
3121
+ }
3122
+
3123
+ /** Leading handles live in the next header so that header owns both
3124
+ * clickable halves of the boundary. Pinned and outer-edge handles stay
3125
+ * in their own header so they follow that sticky/table boundary. */
3126
+ private resizeHeaderForHandle(handle: HTMLElement): HTMLElement | null {
3127
+ const host = handle.parentElement;
3128
+ return handle.classList.contains('leading')
3129
+ ? (host?.previousElementSibling as HTMLElement | null)
3130
+ : host;
3131
+ }
3132
+
2593
3133
  /** Measure the header's pinned cells and publish a cumulative
2594
3134
  * `left` offset per pin index as a CSS var on the host. Pinned
2595
3135
  * cells (header + body) read these via {@link pinStyle}. Pinned
@@ -2725,18 +3265,73 @@ export class ContentList<T = any> extends RapidElement {
2725
3265
  </th>
2726
3266
  `
2727
3267
  : null}
2728
- ${this.columns.map((c, i) =>
2729
- i === this.spacerAfterIndex
2730
- ? html`${this.renderHeaderCell(c)}
2731
- <th class="spacer"></th>`
2732
- : this.renderHeaderCell(c)
2733
- )}
3268
+ ${this.columns.map((column, index) => {
3269
+ const previousColumn = this.columns[index - 1];
3270
+ const leadingResizeColumn =
3271
+ previousColumn &&
3272
+ !this.isLeftPinned(previousColumn) &&
3273
+ !this.isRightPinned(previousColumn) &&
3274
+ this.spacerAfterIndex !== index - 1
3275
+ ? previousColumn
3276
+ : undefined;
3277
+ const trailingResize =
3278
+ index === this.columns.length - 1 ||
3279
+ this.isLeftPinned(column) ||
3280
+ this.isRightPinned(column);
3281
+ const outerResize = index === this.columns.length - 1;
3282
+ return html`${this.renderHeaderCell(
3283
+ column,
3284
+ leadingResizeColumn,
3285
+ trailingResize,
3286
+ outerResize
3287
+ )}${index === this.spacerAfterIndex
3288
+ ? html`<th class="spacer">
3289
+ ${!this.isLeftPinned(column) && !this.isRightPinned(column)
3290
+ ? this.renderResizeHandle(column, true)
3291
+ : null}
3292
+ </th>`
3293
+ : null}`;
3294
+ })}
2734
3295
  </tr>
2735
3296
  </thead>
2736
3297
  `;
2737
3298
  }
2738
3299
 
2739
- private renderHeaderCell(column: ContentListColumn): TemplateResult {
3300
+ private renderResizeHandle(
3301
+ column: ContentListColumn | undefined,
3302
+ leading: boolean,
3303
+ outer = false
3304
+ ): TemplateResult | null {
3305
+ if (!column || !this.isColumnResizable(column)) return null;
3306
+ return html`<span
3307
+ class="resize-handle ${leading ? 'leading' : 'trailing'} ${outer
3308
+ ? 'outer'
3309
+ : ''} ${this.columnResize?.key === column.key ? 'resizing' : ''}"
3310
+ role="separator"
3311
+ aria-orientation="vertical"
3312
+ aria-label="Resize ${column.label ?? column.key} column"
3313
+ aria-valuemin=${this.minimumColumnWidth(column)}
3314
+ aria-valuemax=${ContentList.MAX_COLUMN_WIDTH}
3315
+ aria-valuenow=${this.effectiveColumnWidth(column)}
3316
+ tabindex="0"
3317
+ @pointerdown=${(event: PointerEvent) =>
3318
+ this.startColumnResize(event, column)}
3319
+ @keydown=${(event: KeyboardEvent) =>
3320
+ this.handleColumnResizeKeydown(event, column)}
3321
+ @focus=${(event: FocusEvent) =>
3322
+ this.handleColumnResizeFocus(event, column)}
3323
+ @click=${(event: MouseEvent) => event.stopPropagation()}
3324
+ @dblclick=${(event: MouseEvent) =>
3325
+ this.handleColumnAutoFit(event, column)}
3326
+ ></span>`;
3327
+ }
3328
+
3329
+ private renderHeaderCell(
3330
+ column: ContentListColumn,
3331
+ leadingResizeColumn?: ContentListColumn,
3332
+ trailingResize = false,
3333
+ outerResize = false
3334
+ ): TemplateResult {
2740
3335
  const active = this.sort === column.key || this.sort === '-' + column.key;
2741
3336
  const desc = this.sort === '-' + column.key;
2742
3337
  const cls = `head-cell ${column.align || ''} ${
@@ -2765,19 +3360,31 @@ export class ContentList<T = any> extends RapidElement {
2765
3360
  // Under fixed layout the header row drives the column widths, so
2766
3361
  // each `width`-set column carries its width on the cell itself;
2767
3362
  // the grow column is left unsized to claim the remainder.
2768
- const widthStyle =
2769
- this.fixedLayout && column.width ? `width: ${column.width};` : '';
3363
+ const savedWidth = this.savedColumnWidth(column);
3364
+ const widthStyle = this.fixedLayout
3365
+ ? savedWidth != null
3366
+ ? `width: ${savedWidth}px;`
3367
+ : column.width
3368
+ ? `width: ${column.width};`
3369
+ : ''
3370
+ : '';
2770
3371
  return html`
2771
3372
  <th
2772
3373
  class=${cls}
2773
3374
  style="${this.columnPinStyle(column)} ${widthStyle}"
2774
- @click=${column.sortable ? () => this.handleSortClick(column) : null}
3375
+ @click=${column.sortable
3376
+ ? (event: MouseEvent) => this.handleColumnHeaderClick(event, column)
3377
+ : null}
2775
3378
  >
3379
+ ${this.renderResizeHandle(leadingResizeColumn, true)}
2776
3380
  <div class="head-inner" style=${this.cellWidthStyle(column)}>
2777
3381
  ${column.align === 'right'
2778
3382
  ? html`${slot}${label}`
2779
3383
  : html`${label}${slot}`}
2780
3384
  </div>
3385
+ ${trailingResize
3386
+ ? this.renderResizeHandle(column, false, outerResize)
3387
+ : null}
2781
3388
  </th>
2782
3389
  `;
2783
3390
  }