@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.
- package/CHANGELOG.md +27 -0
- package/DEV_DATA.md +11 -11
- package/README.md +4 -4
- package/TEST_OPTIMIZATION.md +8 -11
- package/dist/locales/es.js +4 -0
- package/dist/locales/es.js.map +1 -1
- package/dist/locales/fr.js +4 -0
- package/dist/locales/fr.js.map +1 -1
- package/dist/locales/pt.js +4 -0
- package/dist/locales/pt.js.map +1 -1
- package/dist/static/svg/index.svg +1 -1
- package/dist/temba-components.js +5266 -3031
- package/dist/temba-components.js.map +1 -1
- package/orca/setup.sh +2 -2
- package/package.json +13 -18
- package/scripts/dev-data-sync.mjs +4 -4
- package/src/Icons.ts +3 -2
- package/src/display/Chat.ts +3 -2
- package/src/display/Lightbox.ts +57 -109
- package/src/display/ProgressBar.ts +15 -2
- package/src/display/Thumbnail.ts +34 -7
- package/src/form/Compose.ts +31 -1
- package/src/form/ContactSearch.ts +229 -126
- package/src/interfaces.ts +131 -2
- package/src/layout/Card.ts +336 -0
- package/src/layout/CardLayout.ts +425 -0
- package/src/layout/CardStack.ts +131 -0
- package/src/layout/Dialog.ts +33 -24
- package/src/layout/HeaderBar.ts +41 -0
- package/src/layout/PageHeader.ts +5 -6
- package/src/layout/Resizer.ts +20 -0
- package/src/list/BroadcastList.ts +912 -0
- package/src/list/CampaignList.ts +144 -0
- package/src/list/ContactList.ts +3 -1
- package/src/list/ContentList.ts +46 -10
- package/src/list/FieldList.ts +1057 -0
- package/src/list/FlowList.ts +31 -7
- package/src/list/MsgList.ts +18 -12
- package/src/list/SortableList.ts +52 -8
- package/src/list/TembaList.ts +8 -0
- package/src/list/TembaMenu.ts +5 -38
- package/src/list/TriggerList.ts +538 -0
- package/src/live/CampaignEvents.ts +1108 -0
- package/src/live/ContactChat.ts +7 -1
- package/src/live/ContactDetails.ts +20 -14
- package/src/live/ContactFieldEditor.ts +7 -3
- package/src/live/ContactFields.ts +1 -1
- package/src/live/ContactNameFetch.ts +5 -2
- package/src/live/ContactNotepad.ts +88 -2
- package/src/live/ContactStoreElement.ts +15 -3
- package/src/live/ContactTimeline.ts +28 -7
- package/src/locales/es.ts +4 -0
- package/src/locales/fr.ts +4 -0
- package/src/locales/pt.ts +4 -0
- package/src/utils.ts +19 -0
- package/static/svg/index.svg +1 -1
- package/static/svg/packs/2-temba/star-filled.svg +3 -0
- package/static/svg/work/traced/star-filled.svg +1 -0
- package/static/svg/work/used/star-filled.svg +3 -0
- package/stress-test.js +3 -3
- package/temba-modules.ts +18 -0
- package/web-dev-server.config.mjs +54 -0
- package/web-test-runner.config.mjs +1 -1
- package/xliff/es.xlf +12 -0
- package/xliff/fr.xlf +12 -0
- 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
|
+
}
|
package/src/layout/Dialog.ts
CHANGED
|
@@ -575,9 +575,10 @@ export class Dialog extends ResizeElement {
|
|
|
575
575
|
</div>
|
|
576
576
|
|
|
577
577
|
<div class="flex">
|
|
578
|
-
<div
|
|
579
|
-
|
|
580
|
-
|
|
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
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
+
}
|