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