@nyaruka/temba-components 0.163.0 → 0.165.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 (66) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/DEV_DATA.md +11 -11
  3. package/README.md +4 -4
  4. package/TEST_OPTIMIZATION.md +8 -11
  5. package/dist/locales/es.js +4 -0
  6. package/dist/locales/es.js.map +1 -1
  7. package/dist/locales/fr.js +4 -0
  8. package/dist/locales/fr.js.map +1 -1
  9. package/dist/locales/pt.js +4 -0
  10. package/dist/locales/pt.js.map +1 -1
  11. package/dist/static/svg/index.svg +1 -1
  12. package/dist/temba-components.js +5266 -3031
  13. package/dist/temba-components.js.map +1 -1
  14. package/orca/setup.sh +2 -2
  15. package/package.json +13 -18
  16. package/scripts/dev-data-sync.mjs +4 -4
  17. package/src/Icons.ts +3 -2
  18. package/src/display/Chat.ts +3 -2
  19. package/src/display/Lightbox.ts +57 -109
  20. package/src/display/ProgressBar.ts +15 -2
  21. package/src/display/Thumbnail.ts +34 -7
  22. package/src/form/Compose.ts +31 -1
  23. package/src/form/ContactSearch.ts +229 -126
  24. package/src/interfaces.ts +131 -2
  25. package/src/layout/Card.ts +336 -0
  26. package/src/layout/CardLayout.ts +425 -0
  27. package/src/layout/CardStack.ts +131 -0
  28. package/src/layout/Dialog.ts +33 -24
  29. package/src/layout/HeaderBar.ts +41 -0
  30. package/src/layout/PageHeader.ts +5 -6
  31. package/src/layout/Resizer.ts +20 -0
  32. package/src/list/BroadcastList.ts +912 -0
  33. package/src/list/CampaignList.ts +144 -0
  34. package/src/list/ContactList.ts +3 -1
  35. package/src/list/ContentList.ts +46 -10
  36. package/src/list/FieldList.ts +1057 -0
  37. package/src/list/FlowList.ts +31 -7
  38. package/src/list/MsgList.ts +18 -12
  39. package/src/list/SortableList.ts +52 -8
  40. package/src/list/TembaList.ts +8 -0
  41. package/src/list/TembaMenu.ts +5 -38
  42. package/src/list/TriggerList.ts +538 -0
  43. package/src/live/CampaignEvents.ts +1108 -0
  44. package/src/live/ContactChat.ts +7 -1
  45. package/src/live/ContactDetails.ts +20 -14
  46. package/src/live/ContactFieldEditor.ts +7 -3
  47. package/src/live/ContactFields.ts +1 -1
  48. package/src/live/ContactNameFetch.ts +5 -2
  49. package/src/live/ContactNotepad.ts +88 -2
  50. package/src/live/ContactStoreElement.ts +15 -3
  51. package/src/live/ContactTimeline.ts +28 -7
  52. package/src/locales/es.ts +4 -0
  53. package/src/locales/fr.ts +4 -0
  54. package/src/locales/pt.ts +4 -0
  55. package/src/utils.ts +19 -0
  56. package/static/svg/index.svg +1 -1
  57. package/static/svg/packs/2-temba/star-filled.svg +3 -0
  58. package/static/svg/work/traced/star-filled.svg +1 -0
  59. package/static/svg/work/used/star-filled.svg +3 -0
  60. package/stress-test.js +3 -3
  61. package/temba-modules.ts +18 -0
  62. package/web-dev-server.config.mjs +54 -0
  63. package/web-test-runner.config.mjs +1 -1
  64. package/xliff/es.xlf +12 -0
  65. package/xliff/fr.xlf +12 -0
  66. package/xliff/pt.xlf +12 -0
@@ -0,0 +1,425 @@
1
+ import { css, html, TemplateResult } from 'lit';
2
+ import { property } from 'lit/decorators.js';
3
+ import { RapidElement } from '../RapidElement';
4
+ import { CustomEventType } from '../interfaces';
5
+ import { postJSON } from '../utils';
6
+ import { Card } from './Card';
7
+
8
+ /** Saved card layout state — order and collapsed lists may reference cards
9
+ * beyond the ones the current page renders. */
10
+ export interface CardSettings {
11
+ order?: string[];
12
+ collapsed?: string[];
13
+ }
14
+
15
+ /**
16
+ * Responsive layout for a main view with supporting panels. When wide, the
17
+ * main slot fills the space and panels render as collapsible, drag-sortable
18
+ * cards in a right-hand column. Below the breakpoint the same panels are
19
+ * re-projected into a tab pane (main content first) — the slotted elements
20
+ * never disconnect, so their state (sockets, fetched data) survives switches.
21
+ *
22
+ * Panels are declared as slotted temba-cards with an id; the main view goes
23
+ * in slot="main".
24
+ *
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.
32
+ */
33
+ export class CardLayout extends RapidElement {
34
+ static get styles() {
35
+ return css`
36
+ :host {
37
+ display: flex;
38
+ flex-direction: column;
39
+ flex-grow: 1;
40
+ min-height: 0;
41
+ }
42
+
43
+ /* in tab view there is no card column running to the edge — the
44
+ layout itself keeps the content off the right edge */
45
+ :host([narrow]) {
46
+ padding-right: var(--layout-spacing, 8px);
47
+ }
48
+
49
+ /* One spacing unit everywhere: left of the main view (host padding,
50
+ supplied by the page), above it, between it and the cards, and
51
+ between the cards and the scrollbar. */
52
+ .body {
53
+ flex-grow: 1;
54
+ min-height: 0;
55
+ display: flex;
56
+ }
57
+
58
+ .main {
59
+ /* the main view keeps a comfortable reading width — extra space
60
+ goes to the card column, not the main view */
61
+ flex: 0 1 var(--main-width, 650px);
62
+ min-width: 0;
63
+ display: flex;
64
+ flex-direction: column;
65
+ padding-top: var(--layout-spacing, 8px);
66
+ padding-bottom: var(--layout-spacing, 8px);
67
+ }
68
+
69
+ slot[name='main']::slotted(*) {
70
+ flex-grow: 1;
71
+ min-width: 0;
72
+ min-height: 0;
73
+ display: flex;
74
+ flex-direction: column;
75
+ /* the layout owns spacing — strip any host margin the slotted
76
+ main view carries for standalone use */
77
+ margin: 0;
78
+ }
79
+
80
+ /* in tab view the main pane gets the same gap below the tab strip
81
+ and above the bottom edge that the cards have */
82
+ :host([narrow]) slot[name='main']::slotted(*) {
83
+ margin-top: var(--layout-spacing, 8px);
84
+ margin-bottom: var(--layout-spacing, 8px);
85
+ }
86
+
87
+ .column {
88
+ /* the column soaks up whatever the main view doesn't take. Its
89
+ scrollport runs the full height (the host supplies no vertical
90
+ padding) so the scrollbar bleeds top to bottom, while the inner
91
+ padding aligns the cards with the main view — including below,
92
+ so the last card scrolls to rest level with the bottom of the
93
+ main view — and doubles as room for card shadows. */
94
+ flex: 1 0 var(--card-column-width, 360px);
95
+ min-height: 0;
96
+ overflow-y: auto;
97
+ padding: var(--layout-spacing, 8px);
98
+ }
99
+
100
+ temba-tabs {
101
+ margin-top: var(--layout-spacing, 8px);
102
+ flex-grow: 1;
103
+ min-height: 0;
104
+ }
105
+ `;
106
+ }
107
+
108
+ // explicit width below which the tab view is used — when unset (0), the
109
+ // flip point is computed from what the layout actually needs: the card
110
+ // column's footprint plus a comfortable minimum for the main view
111
+ @property({ type: Number })
112
+ breakpoint = 0;
113
+
114
+ // minimum comfortable main-view width before flipping to tabs — lean
115
+ // toward card mode: the main view may get snug before we give up on it
116
+ @property({ type: Number, attribute: 'main-min-width' })
117
+ mainMinWidth = 420;
118
+
119
+ // column basis (360) plus its horizontal spacing (2 x 8px) — keep in
120
+ // sync with the .column CSS defaults
121
+ static COLUMN_FOOTPRINT = 376;
122
+
123
+ private getFlipWidth(): number {
124
+ return this.breakpoint > 0
125
+ ? this.breakpoint
126
+ : this.mainMinWidth + CardLayout.COLUMN_FOOTPRINT;
127
+ }
128
+
129
+ @property({ type: Array })
130
+ order: string[] = [];
131
+
132
+ // saved order + collapsed state to seed from (JSON attribute)
133
+ @property({ type: Object })
134
+ settings: CardSettings = null;
135
+
136
+ // where to POST settings changes; persistence is off when unset
137
+ @property({ type: String, attribute: 'settings-endpoint' })
138
+ settingsEndpoint = '';
139
+
140
+ // top-level key the settings are posted (and saved) under
141
+ @property({ type: String, attribute: 'settings-key' })
142
+ settingsKey = 'contact_cards';
143
+
144
+ // debounce window for settings saves (ms) — tests shrink it
145
+ saveDelay = 500;
146
+
147
+ // the full saved lists — unlike `order`, these keep ids for cards other
148
+ // pages show, so a save from this page can't clobber their state
149
+ private savedOrder: string[] = [];
150
+ private savedCollapsed: string[] = [];
151
+
152
+ // cards whose collapsed state has been seeded from settings — seed once
153
+ // so a later mutation can't undo the user's toggles
154
+ private seeded = new Set<string>();
155
+
156
+ private saveTimeout: ReturnType<typeof setTimeout> = null;
157
+
158
+ @property({ type: String, attribute: 'main-name' })
159
+ mainName = 'Chat';
160
+
161
+ @property({ type: String, attribute: 'main-icon' })
162
+ mainIcon = 'message';
163
+
164
+ @property({ type: Boolean, reflect: true })
165
+ narrow = false;
166
+
167
+ // in tab view, drop tab labels (icons only, selected keeps its name)
168
+ // once the pane is too tight to show them all
169
+ @property({ type: Boolean })
170
+ compactTabs = false;
171
+
172
+ static COMPACT_TABS_WIDTH = 560;
173
+
174
+ private resizer: ResizeObserver;
175
+ private mutations: MutationObserver;
176
+
177
+ private handleDetailsChanged = () => {
178
+ // tab entries render card metadata (count/activity) by value — refresh
179
+ // them when a projected panel reports new details
180
+ if (this.narrow) {
181
+ this.requestUpdate();
182
+ }
183
+ };
184
+
185
+ private handleToggle = (event: Event) => {
186
+ // only collapse toggles from our own cards should trigger a save
187
+ if ((event.target as Element).parentElement === this) {
188
+ this.scheduleSave();
189
+ }
190
+ };
191
+
192
+ public connectedCallback(): void {
193
+ super.connectedCallback();
194
+ this.addEventListener(
195
+ CustomEventType.DetailsChanged,
196
+ this.handleDetailsChanged
197
+ );
198
+ this.addEventListener('toggle', this.handleToggle);
199
+
200
+ // the wide render reprojects via its slotchange, but the narrow render
201
+ // has no default slot — watch for cards added/removed in either mode
202
+ this.mutations = new MutationObserver(() => {
203
+ this.applyProjection();
204
+ this.applyOrder();
205
+ this.applyCollapsed();
206
+ this.requestUpdate();
207
+ });
208
+ this.mutations.observe(this, { childList: true });
209
+
210
+ this.resizer = new ResizeObserver(() => {
211
+ // defer out of the observer callback — flipping modes re-renders and
212
+ // 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
+ });
220
+ });
221
+ this.resizer.observe(this);
222
+ }
223
+
224
+ public disconnectedCallback(): void {
225
+ this.removeEventListener(
226
+ CustomEventType.DetailsChanged,
227
+ this.handleDetailsChanged
228
+ );
229
+ this.removeEventListener('toggle', this.handleToggle);
230
+ this.resizer?.disconnect();
231
+ this.mutations?.disconnect();
232
+
233
+ // flush a pending save so navigating away doesn't drop it
234
+ if (this.saveTimeout) {
235
+ clearTimeout(this.saveTimeout);
236
+ this.saveTimeout = null;
237
+ this.saveSettings();
238
+ }
239
+ super.disconnectedCallback();
240
+ }
241
+
242
+ public getCards(): Card[] {
243
+ return Array.from(
244
+ this.querySelectorAll(':scope > temba-card[id]')
245
+ ) as Card[];
246
+ }
247
+
248
+ public getIds(): string[] {
249
+ return this.getCards().map((card) => card.id);
250
+ }
251
+
252
+ /**
253
+ * Route each card to the right place for the current mode: the default
254
+ * slot (forwarded into the card stack) when wide, or its own named slot
255
+ * inside a chromeless tab when narrow.
256
+ */
257
+ private applyProjection() {
258
+ this.getCards().forEach((card) => {
259
+ if (this.narrow) {
260
+ card.setAttribute('slot', `panel-${card.id}`);
261
+ card.setAttribute('plain', '');
262
+ } else {
263
+ card.removeAttribute('slot');
264
+ card.removeAttribute('plain');
265
+ }
266
+ });
267
+ }
268
+
269
+ private applyOrder() {
270
+ if (!this.order || this.order.length === 0) {
271
+ return;
272
+ }
273
+
274
+ const cards = this.getCards();
275
+ const listed = this.order
276
+ .map((id) => cards.find((card) => card.id === id))
277
+ .filter(Boolean) as Card[];
278
+ const unlisted = cards.filter((card) => !listed.includes(card));
279
+ const desired = [...listed, ...unlisted];
280
+
281
+ if (desired.every((card, idx) => card === cards[idx])) {
282
+ return;
283
+ }
284
+
285
+ desired.forEach((card) => this.appendChild(card));
286
+ this.requestUpdate();
287
+ }
288
+
289
+ /** Seed collapsed state from the saved settings — once per card, so
290
+ * later slot churn can't undo a toggle the user has since made. */
291
+ private applyCollapsed() {
292
+ if (!this.settings) {
293
+ return;
294
+ }
295
+ const collapsed = this.settings.collapsed || [];
296
+ this.getCards().forEach((card) => {
297
+ if (!this.seeded.has(card.id)) {
298
+ this.seeded.add(card.id);
299
+ if (collapsed.includes(card.id)) {
300
+ card.collapsed = true;
301
+ }
302
+ }
303
+ });
304
+ }
305
+
306
+ private handleSlotChange() {
307
+ this.applyProjection();
308
+ this.applyOrder();
309
+ this.applyCollapsed();
310
+ // tab entries render from the slotted cards
311
+ this.requestUpdate();
312
+ }
313
+
314
+ private handleOrderChanged(event: CustomEvent) {
315
+ // keep our order prop in sync so a mode switch and back doesn't undo
316
+ // a drag; the event continues up to the host
317
+ this.order = event.detail.ids;
318
+ this.scheduleSave();
319
+ }
320
+
321
+ private scheduleSave() {
322
+ if (!this.settingsEndpoint) {
323
+ return;
324
+ }
325
+ clearTimeout(this.saveTimeout);
326
+ this.saveTimeout = setTimeout(() => {
327
+ this.saveTimeout = null;
328
+ this.saveSettings();
329
+ }, this.saveDelay);
330
+ }
331
+
332
+ /** Slot this page's cards into the full saved order without disturbing
333
+ * the position of cards this page doesn't show. */
334
+ private mergeOrder(existing: string[], present: string[]): string[] {
335
+ const queue = present.slice();
336
+ const result = existing.map((id) =>
337
+ present.includes(id) ? queue.shift() : id
338
+ );
339
+ return result.concat(queue);
340
+ }
341
+
342
+ private saveSettings() {
343
+ if (!this.settingsEndpoint) {
344
+ return;
345
+ }
346
+
347
+ const present = this.getIds();
348
+ const collapsed = this.getCards()
349
+ .filter((card) => card.collapsed)
350
+ .map((card) => card.id);
351
+
352
+ this.savedOrder = this.mergeOrder(this.savedOrder, present);
353
+ this.savedCollapsed = this.savedCollapsed
354
+ .filter((id) => !present.includes(id))
355
+ .concat(collapsed);
356
+
357
+ postJSON(this.settingsEndpoint, {
358
+ [this.settingsKey]: {
359
+ order: this.savedOrder,
360
+ collapsed: this.savedCollapsed
361
+ }
362
+ }).catch(() => {
363
+ // a failed save isn't worth interrupting the user over — the next
364
+ // change will retry with the same merged state
365
+ });
366
+ }
367
+
368
+ protected willUpdate(changes: Map<PropertyKey, unknown>): void {
369
+ super.willUpdate(changes);
370
+ // seed here so setting `order` rides the same update cycle
371
+ if (changes.has('settings') && this.settings) {
372
+ this.savedOrder = this.settings.order || [];
373
+ this.savedCollapsed = this.settings.collapsed || [];
374
+ if (this.savedOrder.length > 0) {
375
+ this.order = this.savedOrder;
376
+ }
377
+ this.applyCollapsed();
378
+ }
379
+ }
380
+
381
+ protected updated(changes: Map<PropertyKey, unknown>): void {
382
+ super.updated(changes);
383
+ if (changes.has('narrow')) {
384
+ this.applyProjection();
385
+ }
386
+ if (changes.has('order')) {
387
+ this.applyOrder();
388
+ }
389
+ }
390
+
391
+ public render(): TemplateResult {
392
+ if (this.narrow) {
393
+ return html`
394
+ <temba-tabs .focusedName=${this.compactTabs}>
395
+ <temba-tab name=${this.mainName} icon=${this.mainIcon}>
396
+ <slot name="main"></slot>
397
+ </temba-tab>
398
+ ${this.getCards().map(
399
+ (card) => html`
400
+ <temba-tab
401
+ name=${card.label}
402
+ icon=${card.icon}
403
+ count=${card.count}
404
+ ?activity=${card.activity}
405
+ >
406
+ <slot name="panel-${card.id}"></slot>
407
+ </temba-tab>
408
+ `
409
+ )}
410
+ </temba-tabs>
411
+ `;
412
+ }
413
+
414
+ return html`
415
+ <div class="body">
416
+ <div class="main"><slot name="main"></slot></div>
417
+ <div class="column">
418
+ <temba-card-stack @temba-order-changed=${this.handleOrderChanged}>
419
+ <slot @slotchange=${this.handleSlotChange}></slot>
420
+ </temba-card-stack>
421
+ </div>
422
+ </div>
423
+ `;
424
+ }
425
+ }
@@ -0,0 +1,131 @@
1
+ import { css, html, TemplateResult } from 'lit';
2
+ import { property } from 'lit/decorators.js';
3
+ import { RapidElement } from '../RapidElement';
4
+ import { CustomEventType } from '../interfaces';
5
+
6
+ /**
7
+ * A vertical stack of temba-cards that can be reordered by dragging their
8
+ * headers. Owns the DOM move on drop and re-emits temba-order-changed with
9
+ * the full id list so hosts only deal in ids. An initial order can be
10
+ * passed via the `order` attribute (JSON array of child ids).
11
+ */
12
+ export class CardStack extends RapidElement {
13
+ static get styles() {
14
+ return css`
15
+ :host {
16
+ display: block;
17
+ }
18
+ `;
19
+ }
20
+
21
+ @property({ type: Array })
22
+ order: string[] = [];
23
+
24
+ @property({ type: String })
25
+ gap = 'var(--gap, 14px)';
26
+
27
+ private getAssigned(): Element[] {
28
+ // resolve through the shadow slot so cards forwarded from a wrapper
29
+ // component's slot (e.g. temba-card-layout) are seen too
30
+ const slot = this.shadowRoot?.querySelector('slot');
31
+ return slot
32
+ ? slot.assignedElements({ flatten: true })
33
+ : Array.from(this.children);
34
+ }
35
+
36
+ private getCards(): HTMLElement[] {
37
+ return this.getAssigned().filter(
38
+ (ele) => ele.classList.contains('sortable') && ele.id
39
+ ) as HTMLElement[];
40
+ }
41
+
42
+ public getIds(): string[] {
43
+ return this.getCards().map((ele) => ele.id);
44
+ }
45
+
46
+ private handleSlotChange() {
47
+ // cards are expected to carry .sortable for SortableList; add it so
48
+ // consumers don't have to remember. Only actual cards — an incidental
49
+ // id'd element passing through the slot shouldn't become draggable.
50
+ this.getAssigned().forEach((ele) => {
51
+ if (ele.tagName === 'TEMBA-CARD' && ele.id) {
52
+ ele.classList.add('sortable');
53
+ }
54
+ });
55
+ this.applyOrder();
56
+ }
57
+
58
+ protected updated(changes: Map<PropertyKey, unknown>): void {
59
+ super.updated(changes);
60
+ if (changes.has('order')) {
61
+ this.applyOrder();
62
+ }
63
+ }
64
+
65
+ private applyOrder() {
66
+ if (!this.order || this.order.length === 0) {
67
+ return;
68
+ }
69
+
70
+ const cards = this.getCards();
71
+ const listed = this.order
72
+ .map((id) => cards.find((card) => card.id === id))
73
+ .filter(Boolean) as HTMLElement[];
74
+ const unlisted = cards.filter((card) => !listed.includes(card));
75
+ const desired = [...listed, ...unlisted];
76
+
77
+ if (desired.every((card, idx) => card === cards[idx])) {
78
+ return;
79
+ }
80
+
81
+ // move within whatever parent actually holds the cards (this element,
82
+ // or the wrapper host when cards arrive through a forwarded slot)
83
+ desired.forEach((card) => card.parentElement?.appendChild(card));
84
+ }
85
+
86
+ private handleOrderChanged(event: CustomEvent) {
87
+ // the inner sortable list reports a swap but leaves the DOM alone;
88
+ // perform the move here and surface the resulting id order instead
89
+ event.stopPropagation();
90
+
91
+ const [fromIdx, toIdx] = event.detail.swap;
92
+ const cards = this.getCards();
93
+ const moving = cards[fromIdx];
94
+ const target = cards[toIdx];
95
+ if (!moving || !target || moving === target) {
96
+ return;
97
+ }
98
+
99
+ if (fromIdx < toIdx) {
100
+ target.insertAdjacentElement('afterend', moving);
101
+ } else {
102
+ target.insertAdjacentElement('beforebegin', moving);
103
+ }
104
+
105
+ this.order = this.getIds();
106
+ this.fireCustomEvent(CustomEventType.OrderChanged, { ids: this.order });
107
+ }
108
+
109
+ /**
110
+ * The ghost is a deep clone appended to document.body, sized to the
111
+ * original card — so an open card drags as an open card. Cloned panels
112
+ * re-render from the store's cache; clip anything still settling.
113
+ */
114
+ private prepareGhost = (ghost: HTMLElement) => {
115
+ ghost.removeAttribute('id');
116
+ ghost.style.overflow = 'hidden';
117
+ };
118
+
119
+ public render(): TemplateResult {
120
+ return html`
121
+ <temba-sortable-list
122
+ dragHandle="card-header"
123
+ gap=${this.gap}
124
+ .prepareGhost=${this.prepareGhost}
125
+ @temba-order-changed=${this.handleOrderChanged}
126
+ >
127
+ <slot @slotchange=${this.handleSlotChange}></slot>
128
+ </temba-sortable-list>
129
+ `;
130
+ }
131
+ }
@@ -575,9 +575,10 @@ export class Dialog extends ResizeElement {
575
575
  </div>
576
576
 
577
577
  <div class="flex">
578
- <div class="grow-top" style="${
579
- this.isMobile() ? 'flex-grow:0' : ''
580
- }"></div>
578
+ <div
579
+ class="grow-top"
580
+ style="${this.isMobile() ? 'flex-grow:0' : ''}"
581
+ ></div>
581
582
  <div
582
583
  @keyup=${this.handleKeyUp}
583
584
  @keydown=${this.handleContentInteraction}
@@ -593,28 +594,36 @@ export class Dialog extends ResizeElement {
593
594
  <temba-loading units="6" size="8"></temba-loading>
594
595
  </div>
595
596
 
596
- <div class="dialog-footer">
597
- <div class="flex-grow">
598
- <slot name="gutter"></slot>
599
- </div>
600
- ${this.buttons.map(
601
- (button: DialogButton, index) => html`
602
- <temba-button
603
- name=${button.name}
604
- ?destructive=${button.type == 'primary' && this.destructive}
605
- ?primary=${button.type == 'primary' && !this.destructive}
606
- ?secondary=${button.type == 'secondary'}
607
- ?submitting=${this.submitting && button.type == 'primary'}
608
- ?disabled=${this.disabled && !button.closes}
609
- index=${index}
610
- @click=${this.handleClick}
611
- ></temba-button>
612
- `
613
- )}
614
- </div>
615
- </div>
616
- <div class="grow-bottom"></div>
597
+ ${
598
+ // the gutter check is render-time only (not reactive) - fine
599
+ // while consumers slot gutter content declaratively up front
600
+ this.buttons.length > 0 || this.querySelector('[slot="gutter"]')
601
+ ? html`<div class="dialog-footer">
602
+ <div class="flex-grow">
603
+ <slot name="gutter"></slot>
604
+ </div>
605
+ ${this.buttons.map(
606
+ (button: DialogButton, index) => html`
607
+ <temba-button
608
+ name=${button.name}
609
+ ?destructive=${button.type == 'primary' &&
610
+ this.destructive}
611
+ ?primary=${button.type == 'primary' &&
612
+ !this.destructive}
613
+ ?secondary=${button.type == 'secondary'}
614
+ ?submitting=${this.submitting &&
615
+ button.type == 'primary'}
616
+ ?disabled=${this.disabled && !button.closes}
617
+ index=${index}
618
+ @click=${this.handleClick}
619
+ ></temba-button>
620
+ `
621
+ )}
622
+ </div>`
623
+ : null
624
+ }
617
625
  </div>
626
+ <div class="grow-bottom"></div>
618
627
  </div>
619
628
  </div>
620
629
  `;
@@ -0,0 +1,41 @@
1
+ import { css, html, TemplateResult } from 'lit';
2
+ import { RapidElement } from '../RapidElement';
3
+ import { designTokens } from '../styles/designTokens';
4
+
5
+ /**
6
+ * A fixed-height header strip with a full-bleed rule under it, for the
7
+ * chat + cards pages (contact read, tickets). Pages can use several side
8
+ * by side (e.g. one over a list column, one over the chat) — the shared
9
+ * height and surface make them read as one continuous control.
10
+ */
11
+ export class HeaderBar extends RapidElement {
12
+ static get styles() {
13
+ return css`
14
+ ${designTokens}
15
+
16
+ :host {
17
+ flex: 0 0 auto;
18
+ display: flex;
19
+ align-items: center;
20
+ /* 52px strip plus the 1px rule */
21
+ height: 53px;
22
+ box-sizing: border-box;
23
+ padding: 0 8px;
24
+ background: var(--surface);
25
+ /* the rule is a box-shadow, not a border — host pages (tailwind
26
+ preflight) reset border-width on every element, and outer-scope
27
+ element styles beat :host */
28
+ box-shadow: inset 0 -1px 0 0 var(--border);
29
+ }
30
+
31
+ ::slotted(*) {
32
+ flex-grow: 1;
33
+ min-width: 0;
34
+ }
35
+ `;
36
+ }
37
+
38
+ public render(): TemplateResult {
39
+ return html`<slot></slot>`;
40
+ }
41
+ }