@nyaruka/temba-components 0.163.0 → 0.164.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.
@@ -70,6 +70,8 @@ export class PageHeader extends RapidElement {
70
70
  }
71
71
  .title {
72
72
  font-size: 15.5px;
73
+ /* slotted contact names render at the title's size */
74
+ --contact-name-font-size: 15.5px;
73
75
  font-weight: var(--w-semibold);
74
76
  color: var(--text-1);
75
77
  line-height: 1.25;
@@ -139,10 +141,6 @@ export class PageHeader extends RapidElement {
139
141
  .menu-button.primary:hover {
140
142
  background: var(--accent-700);
141
143
  }
142
- .menu-button[disabled] {
143
- opacity: 0.45;
144
- cursor: not-allowed;
145
- }
146
144
 
147
145
  /* Overflow toggle — the trailing ⋮ that opens the rest of the
148
146
  menu items. Plain icon button, matches the list's Search. */
@@ -282,7 +280,9 @@ export class PageHeader extends RapidElement {
282
280
  * origin — same payload the standalone content menu emits, so the
283
281
  * host's existing menu handling keeps working. */
284
282
  private handleItemClicked(item: ContentMenuItem, event: MouseEvent): void {
285
- if (item.disabled) return;
283
+ // note: item.disabled does NOT make the item inert — in the content
284
+ // menu contract it means the opened modal starts with its submit
285
+ // disabled (hosts pass it through to showModax)
286
286
  const el = event.currentTarget as Element;
287
287
  const rect = el?.getBoundingClientRect();
288
288
  this.fireCustomEvent(CustomEventType.Selection, {
@@ -300,7 +300,6 @@ export class PageHeader extends RapidElement {
300
300
  (button) => html`
301
301
  <div
302
302
  class="menu-button ${button.primary ? 'primary' : ''}"
303
- ?disabled=${button.disabled}
304
303
  @click=${(e: MouseEvent) => this.handleItemClicked(button, e)}
305
304
  >
306
305
  ${button.label}
@@ -10,6 +10,9 @@ export class Resizer extends ResizeElement {
10
10
  display: block;
11
11
  position: relative;
12
12
  width: var(--box-width, 200px);
13
+ /* hold the drag-clamp floor against flex compression too, so
14
+ shrinking the browser can't squeeze us below minWidth */
15
+ min-width: var(--box-min-width, 200px);
13
16
  --resizer-handle-size: 15px;
14
17
  }
15
18
 
@@ -69,6 +72,20 @@ export class Resizer extends ResizeElement {
69
72
  this.stopResize = this.stopResize.bind(this);
70
73
  }
71
74
 
75
+ protected willUpdate(
76
+ changes: PropertyValueMap<any> | Map<PropertyKey, unknown>
77
+ ): void {
78
+ super.willUpdate(changes);
79
+ // clamp programmatic width sets (e.g. a stored width restored on
80
+ // load) the same way drags are clamped
81
+ if (changes.has('currentWidth') && this.currentWidth != null) {
82
+ this.currentWidth = Math.min(
83
+ Math.max(this.currentWidth, this.minWidth),
84
+ this.maxWidth
85
+ );
86
+ }
87
+ }
88
+
72
89
  protected updated(
73
90
  _changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>
74
91
  ): void {
@@ -76,6 +93,9 @@ export class Resizer extends ResizeElement {
76
93
  if (_changedProperties.has('currentWidth')) {
77
94
  this.style.setProperty('--box-width', `${this.currentWidth}px`);
78
95
  }
96
+ if (_changedProperties.has('minWidth')) {
97
+ this.style.setProperty('--box-min-width', `${this.minWidth}px`);
98
+ }
79
99
  }
80
100
 
81
101
  public setWidth(width: number) {
@@ -0,0 +1,144 @@
1
+ import { css, html, TemplateResult } from 'lit';
2
+ import { ContentList, ContentListColumn } from './ContentList';
3
+ import { Icon } from '../Icons';
4
+ import { Campaign, CustomEventType, ObjectReference } from '../interfaces';
5
+
6
+ /**
7
+ * Campaign CRUDL list — drop-in replacement for the rapidpro
8
+ * `campaigns/campaign_list.html` table. Every row leads with the
9
+ * campaign clock icon (from the styleguide's campaign list), then
10
+ * name, the group pill the campaign schedules against, contact /
11
+ * event counts, and the last-updated date.
12
+ */
13
+ export class CampaignList extends ContentList<Campaign> {
14
+ static get styles() {
15
+ return css`
16
+ ${ContentList.styles}
17
+ .campaign-name {
18
+ color: inherit;
19
+ font-weight: var(--w-medium);
20
+ overflow: hidden;
21
+ text-overflow: ellipsis;
22
+ white-space: nowrap;
23
+ }
24
+ .num {
25
+ font-variant-numeric: tabular-nums;
26
+ color: inherit;
27
+ }
28
+ `;
29
+ }
30
+
31
+ constructor() {
32
+ super();
33
+ this.valueKey = 'uuid';
34
+ this.emptyMessage = 'No campaigns';
35
+ this.searchPlaceholder = 'Search campaigns';
36
+ this.columns = [
37
+ {
38
+ key: 'name',
39
+ label: 'Name',
40
+ sortable: true,
41
+ minWidth: '160px',
42
+ maxWidth: '280px',
43
+ pinned: true
44
+ },
45
+ {
46
+ key: 'group',
47
+ label: 'Group',
48
+ minWidth: '120px',
49
+ maxWidth: '220px'
50
+ },
51
+ {
52
+ key: 'contacts',
53
+ label: 'Contacts',
54
+ sortable: true,
55
+ minWidth: '72px',
56
+ align: 'right'
57
+ },
58
+ {
59
+ key: 'events',
60
+ label: 'Events',
61
+ sortable: true,
62
+ minWidth: '64px',
63
+ align: 'right'
64
+ },
65
+ {
66
+ key: 'modified_on',
67
+ label: 'Updated',
68
+ sortable: true,
69
+ minWidth: '96px',
70
+ maxWidth: '150px',
71
+ align: 'right'
72
+ }
73
+ ];
74
+ this.bulkActions = [
75
+ { key: 'archive', label: 'Archive', icon: Icon.archive }
76
+ ];
77
+ }
78
+
79
+ protected getRowIcon(_item: Campaign): string | null {
80
+ return Icon.campaign;
81
+ }
82
+
83
+ protected getRowHref(item: Campaign): string | null {
84
+ return item?.uuid ? `/campaign/read/${item.uuid}/` : null;
85
+ }
86
+
87
+ /** Clicking the group pill opens that group's contact list rather
88
+ * than falling through to the row click (which opens the campaign). */
89
+ private handleGroupClick(group: ObjectReference, event: MouseEvent): void {
90
+ // Stop the click from bubbling to the row's navigation handler.
91
+ event.stopPropagation();
92
+ if (!group?.uuid) return;
93
+ const href = `/contact/group/${group.uuid}/`;
94
+ // Guard the JSON-driven href against open-redirect, same as the
95
+ // row-click path in ContentList.handleRowClick.
96
+ if (!this.isSafeHref(href)) return;
97
+ // Meta/ctrl-click opens a new tab, matching ordinary links and the
98
+ // row-click behavior.
99
+ if (event.metaKey || event.ctrlKey) {
100
+ window.open(href, '_blank');
101
+ return;
102
+ }
103
+ this.fireCustomEvent(CustomEventType.Redirected, { url: href });
104
+ }
105
+
106
+ protected renderCell(
107
+ item: Campaign,
108
+ column: ContentListColumn
109
+ ): TemplateResult | string {
110
+ switch (column.key) {
111
+ case 'name':
112
+ return html`<span class="campaign-name" title=${item.name || ''}
113
+ >${item.name || ''}</span
114
+ >`;
115
+ case 'group':
116
+ return item.group
117
+ ? html`<temba-label
118
+ type="group"
119
+ icon=${Icon.group}
120
+ clickable
121
+ @click=${(e: MouseEvent) => this.handleGroupClick(item.group, e)}
122
+ >${item.group.name}</temba-label
123
+ >`
124
+ : '';
125
+ case 'events':
126
+ return html`<span class="num"
127
+ >${(item.events ?? 0).toLocaleString()}</span
128
+ >`;
129
+ case 'contacts':
130
+ return html`<span class="num"
131
+ >${(item.contacts ?? 0).toLocaleString()}</span
132
+ >`;
133
+ case 'modified_on':
134
+ return item.modified_on
135
+ ? html`<temba-date
136
+ value=${item.modified_on}
137
+ display="timedate"
138
+ ></temba-date>`
139
+ : '';
140
+ default:
141
+ return super.renderCell(item, column);
142
+ }
143
+ }
144
+ }
@@ -284,7 +284,9 @@ export class ContactList extends ContentList<Contact> {
284
284
  if (i.urn) return i.urn;
285
285
  if (Array.isArray(i.urns) && i.urns.length > 0) {
286
286
  const u = i.urns[0];
287
- return typeof u === 'string' ? u.split(':')[1] || u : u?.display || '';
287
+ return typeof u === 'string'
288
+ ? u.split(':')[1] || u
289
+ : u?.display || u?.path || '';
288
290
  }
289
291
  return '';
290
292
  }
@@ -14,23 +14,38 @@ export class FlowList extends ContentList<Flow> {
14
14
  static get styles() {
15
15
  return css`
16
16
  ${ContentList.styles}
17
+ /* The name cell is a flex row (name text, issue icon, label
18
+ chips) that fills the grow column. When space runs out the
19
+ text spans ellipsize — the flex items shrink instead of the
20
+ cell hard-clipping them mid-glyph, which is what happens to
21
+ an inline-flex atomic inline under .cell-inner's ellipsis. */
17
22
  .flow-name {
18
23
  color: inherit;
19
24
  font-weight: var(--w-medium);
25
+ display: flex;
26
+ align-items: center;
27
+ gap: 6px;
28
+ min-width: 0;
29
+ }
30
+ .flow-name .name {
20
31
  overflow: hidden;
21
32
  text-overflow: ellipsis;
22
33
  white-space: nowrap;
23
- display: inline-flex;
24
- align-items: center;
25
- gap: 6px;
26
34
  }
27
35
  .flow-labels {
28
- display: inline-flex;
36
+ display: flex;
29
37
  align-items: center;
30
38
  gap: 4px;
31
- margin-left: 4px;
39
+ min-width: 0;
40
+ }
41
+ /* Chips shrink with the row and ellipsize internally (the
42
+ label's own slot handles that) rather than being clipped
43
+ mid-chip at the column edge. */
44
+ .flow-labels temba-label {
45
+ min-width: 0;
32
46
  }
33
47
  .issue-icon {
48
+ flex: none;
34
49
  --icon-color: var(--warning);
35
50
  }
36
51
  .num {
@@ -84,13 +99,20 @@ export class FlowList extends ContentList<Flow> {
84
99
  this.valueKey = 'uuid';
85
100
  this.emptyMessage = 'No flows';
86
101
  this.searchPlaceholder = 'Search flows';
102
+ // The name column is a `grow` column — it pools the table's
103
+ // leftover width so long names (and their label chips) get all
104
+ // available space before truncating, instead of clipping against
105
+ // a fixed cap while slack sits in the spacer. minTableWidth arms
106
+ // a horizontal scroll once the container is too narrow to keep
107
+ // the columns usable, rather than clipping anything.
108
+ this.minTableWidth = '640px';
87
109
  this.columns = [
88
110
  {
89
111
  key: 'name',
90
112
  label: 'Name',
91
113
  sortable: true,
92
114
  minWidth: '160px',
93
- maxWidth: '280px',
115
+ grow: true,
94
116
  pinned: true
95
117
  },
96
118
  {
@@ -174,7 +196,9 @@ export class FlowList extends ContentList<Flow> {
174
196
  case 'name': {
175
197
  const labels = item.labels || [];
176
198
  return html`<span class="flow-name"
177
- >${item.name || ''}
199
+ ><span class="name" title="${item.name || ''}"
200
+ >${item.name || ''}</span
201
+ >
178
202
  ${item.has_issues
179
203
  ? html`<temba-icon
180
204
  class="issue-icon"
@@ -2,6 +2,7 @@ import { css, html, TemplateResult } from 'lit';
2
2
  import { ContentList, ContentListColumn } from './ContentList';
3
3
  import { Icon } from '../Icons';
4
4
  import { Msg } from '../interfaces';
5
+ import { attachmentAsString } from '../utils';
5
6
 
6
7
  /**
7
8
  * Message CRUDL list — drop-in replacement for the rapidpro
@@ -14,9 +15,9 @@ export class MsgList extends ContentList<Msg> {
14
15
  static get styles() {
15
16
  return css`
16
17
  ${ContentList.styles}
17
- /* The message cell holds the body text with its attachment
18
- thumbnails right after it, and the flow / label pills pushed
19
- to the trailing edge. The text sizes to content and
18
+ /* The message cell holds the body text on the leading edge, with
19
+ its attachment thumbnails and the flow / label pills grouped
20
+ and pushed to the trailing edge. The text sizes to content and
20
21
  ellipsizes when squeezed — resolved per row, so a busy row
21
22
  doesn't widen a column for all of them. */
22
23
  .msg-cell {
@@ -34,7 +35,7 @@ export class MsgList extends ContentList<Msg> {
34
35
  text-overflow: ellipsis;
35
36
  white-space: nowrap;
36
37
  }
37
- /* Attachment thumbnails sit immediately after the text. */
38
+ /* Attachment thumbnails, immediately after the message text. */
38
39
  .msg-attachments {
39
40
  flex: 0 0 auto;
40
41
  display: flex;
@@ -44,10 +45,10 @@ export class MsgList extends ContentList<Msg> {
44
45
  /* Flow + label pills, pushed to the trailing edge of the cell. */
45
46
  .cell-pills {
46
47
  flex: 0 0 auto;
48
+ margin-left: auto;
47
49
  display: flex;
48
50
  align-items: center;
49
51
  gap: 6px;
50
- margin-left: auto;
51
52
  }
52
53
  /* Attachment thumbnails are sized well below the 44px row so
53
54
  they never grow its height — a small square preview rather
@@ -160,13 +161,18 @@ export class MsgList extends ContentList<Msg> {
160
161
  }
161
162
  }
162
163
 
163
- /** The message cell — body text, its attachment thumbnails, then
164
- * the trailing flow / label pills. Each piece sizes to content, so
165
- * the split is resolved independently for every row. */
164
+ /** The message cell — body text on the leading edge with its
165
+ * attachment thumbnails immediately after it, and the flow / label
166
+ * pills pushed to the trailing edge. Each piece sizes to content, so
167
+ * the split is resolved independently for every row. A message with
168
+ * no text renders no text span at all — otherwise its min-width
169
+ * would strand an attachment-only row's thumbnails away from the
170
+ * leading edge. */
166
171
  private renderMessageCell(item: Msg): TemplateResult {
172
+ const text = this.renderMessageText(item);
167
173
  return html`
168
174
  <div class="msg-cell">
169
- <span class="msg-text">${this.renderMessageText(item)}</span>
175
+ ${text ? html`<span class="msg-text">${text}</span>` : ''}
170
176
  ${this.renderAttachments(item)}${this.renderPills(item)}
171
177
  </div>
172
178
  `;
@@ -208,8 +214,8 @@ export class MsgList extends ContentList<Msg> {
208
214
  return item.text || '';
209
215
  }
210
216
 
211
- /** Attachment thumbnails for a row, sitting immediately after the
212
- * message text, or '' when the row carries none. */
217
+ /** Attachment thumbnails for a row, immediately after the message
218
+ * text, or '' when the row carries none. */
213
219
  private renderAttachments(item: Msg): TemplateResult | string {
214
220
  const attachments = item.attachments || [];
215
221
  if (!attachments.length) return '';
@@ -219,7 +225,7 @@ export class MsgList extends ContentList<Msg> {
219
225
  (a) => html`
220
226
  <temba-thumbnail
221
227
  class="msg-thumb"
222
- attachment=${a}
228
+ attachment=${attachmentAsString(a)}
223
229
  ></temba-thumbnail>
224
230
  `
225
231
  )}
@@ -111,9 +111,11 @@ export class SortableList extends RapidElement {
111
111
  }
112
112
 
113
113
  private getSortableElements(): Element[] {
114
+ // flatten so wrapper components can forward their host's children
115
+ // through a nested <slot>
114
116
  const eles = this.shadowRoot
115
117
  .querySelector('slot')
116
- .assignedElements()
118
+ .assignedElements({ flatten: true })
117
119
  .filter(
118
120
  (ele) =>
119
121
  ele.classList.contains('sortable') &&
@@ -539,11 +541,38 @@ export class SortableList extends RapidElement {
539
541
  private beginDrag(
540
542
  target: HTMLElement,
541
543
  clientX: number,
542
- clientY: number
544
+ clientY: number,
545
+ path: EventTarget[] = []
543
546
  ): boolean {
544
- // if we have a drag handle, only allow dragging from that element
547
+ // if we have a drag handle, only allow dragging from that element. The
548
+ // handle may live inside a sortable child's shadow DOM (where event
549
+ // retargeting hides it from event.target), so walk the composed path up
550
+ // to the first .sortable host looking for it — and require that host to
551
+ // actually be one of ours, so a same-named class elsewhere in the path
552
+ // can't start a drag.
545
553
  if (this.dragHandle) {
546
- if (!target.classList.contains(this.dragHandle)) {
554
+ const searchPath = path.length > 0 ? path : [target];
555
+ let onHandle = false;
556
+ let handleHost: Element = null;
557
+ for (const hop of searchPath) {
558
+ // shadow roots appear as non-Element hops mid-path (e.g. an icon
559
+ // inside the handle) — skip them rather than giving up
560
+ if (!(hop instanceof Element)) {
561
+ continue;
562
+ }
563
+ if (!onHandle && hop.classList.contains(this.dragHandle)) {
564
+ onHandle = true;
565
+ }
566
+ if (hop.classList.contains('sortable')) {
567
+ handleHost = onHandle ? hop : null;
568
+ break;
569
+ }
570
+ }
571
+ if (
572
+ !onHandle ||
573
+ !handleHost ||
574
+ !this.getSortableElements().includes(handleHost)
575
+ ) {
547
576
  return false;
548
577
  }
549
578
  }
@@ -572,7 +601,12 @@ export class SortableList extends RapidElement {
572
601
 
573
602
  private handleMouseDown(event: MouseEvent) {
574
603
  if (
575
- this.beginDrag(event.target as HTMLElement, event.clientX, event.clientY)
604
+ this.beginDrag(
605
+ event.target as HTMLElement,
606
+ event.clientX,
607
+ event.clientY,
608
+ event.composedPath()
609
+ )
576
610
  ) {
577
611
  event.preventDefault();
578
612
  event.stopPropagation();
@@ -586,7 +620,12 @@ export class SortableList extends RapidElement {
586
620
  const touch = event.touches[0];
587
621
  if (!touch) return;
588
622
  if (
589
- this.beginDrag(event.target as HTMLElement, touch.clientX, touch.clientY)
623
+ this.beginDrag(
624
+ event.target as HTMLElement,
625
+ touch.clientX,
626
+ touch.clientY,
627
+ event.composedPath()
628
+ )
590
629
  ) {
591
630
  event.stopPropagation();
592
631
  document.addEventListener('touchmove', this.handleTouchMove, {