@nyaruka/temba-components 0.162.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.
- package/CHANGELOG.md +24 -0
- package/dist/temba-components.js +1865 -686
- package/dist/temba-components.js.map +1 -1
- package/package.json +1 -1
- package/src/display/Chat.ts +29 -9
- package/src/display/Lightbox.ts +57 -109
- package/src/display/Thumbnail.ts +34 -7
- package/src/flow/FlowSearch.ts +66 -2
- package/src/flow/NodeEditor.ts +109 -1
- package/src/interfaces.ts +79 -2
- package/src/layout/Card.ts +311 -0
- package/src/layout/CardLayout.ts +425 -0
- package/src/layout/CardStack.ts +131 -0
- package/src/layout/Dialog.ts +84 -24
- package/src/layout/HeaderBar.ts +38 -0
- package/src/layout/PageHeader.ts +5 -6
- package/src/layout/Resizer.ts +20 -0
- package/src/list/CampaignList.ts +144 -0
- package/src/list/ContactList.ts +3 -1
- package/src/list/FlowList.ts +31 -7
- package/src/list/MsgList.ts +18 -12
- package/src/list/SortableList.ts +45 -6
- package/src/list/TriggerList.ts +538 -0
- package/src/live/CampaignEvents.ts +1094 -0
- package/src/live/ContactDetails.ts +3 -4
- 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 +12 -2
- package/src/live/ContactTimeline.ts +5 -1
- package/src/utils.ts +19 -0
- package/temba-modules.ts +14 -0
- package/web-dev-server.config.mjs +54 -0
package/src/flow/NodeEditor.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
SPLIT_GROUP_METADATA
|
|
21
21
|
} from './types';
|
|
22
22
|
import { CustomEventType } from '../interfaces';
|
|
23
|
+
import { Dialog } from '../layout/Dialog';
|
|
23
24
|
import { generateUUID } from '../utils';
|
|
24
25
|
import {
|
|
25
26
|
formatIssueMessage,
|
|
@@ -477,6 +478,11 @@ export class NodeEditor extends RapidElement {
|
|
|
477
478
|
@state()
|
|
478
479
|
private originalFormData: FormData = {};
|
|
479
480
|
|
|
481
|
+
// snapshot of getComparableFormData(), refreshed on any non-user mutation
|
|
482
|
+
// (init, async resolve) — the baseline for detecting unsaved edits when
|
|
483
|
+
// dismissing via escape
|
|
484
|
+
private pristineFormData = '';
|
|
485
|
+
|
|
480
486
|
@state()
|
|
481
487
|
private errors: { [key: string]: string } = {};
|
|
482
488
|
|
|
@@ -515,24 +521,112 @@ export class NodeEditor extends RapidElement {
|
|
|
515
521
|
private issuesByAction!: Map<string, FlowIssue[]>;
|
|
516
522
|
|
|
517
523
|
private boundHandleEscape = this.handleEscapeKey.bind(this);
|
|
524
|
+
private boundSwallowEscapeUp = this.swallowEscapeUp.bind(this);
|
|
518
525
|
|
|
519
526
|
connectedCallback(): void {
|
|
520
527
|
super.connectedCallback();
|
|
521
528
|
this.initializeFormData();
|
|
522
529
|
document.addEventListener('keydown', this.boundHandleEscape);
|
|
530
|
+
document.addEventListener('keyup', this.boundSwallowEscapeUp, true);
|
|
523
531
|
}
|
|
524
532
|
|
|
525
533
|
disconnectedCallback(): void {
|
|
526
534
|
super.disconnectedCallback();
|
|
527
535
|
document.removeEventListener('keydown', this.boundHandleEscape);
|
|
536
|
+
document.removeEventListener('keyup', this.boundSwallowEscapeUp, true);
|
|
528
537
|
}
|
|
529
538
|
|
|
530
539
|
private handleEscapeKey(event: KeyboardEvent): void {
|
|
531
540
|
if (event.key === 'Escape' && this.isOpen) {
|
|
541
|
+
const path = event.composedPath();
|
|
542
|
+
|
|
543
|
+
// an escape aimed at an open select dropdown just closes the dropdown
|
|
544
|
+
if (
|
|
545
|
+
path.some(
|
|
546
|
+
(el) =>
|
|
547
|
+
el instanceof Element &&
|
|
548
|
+
el.tagName === 'TEMBA-SELECT' &&
|
|
549
|
+
(el as any).isOpen?.()
|
|
550
|
+
)
|
|
551
|
+
) {
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (!this.ownsEscape(path)) {
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (
|
|
560
|
+
this.hasUnsavedChanges() &&
|
|
561
|
+
!window.confirm(Dialog.UNSAVED_CHANGES_MESSAGE)
|
|
562
|
+
) {
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
532
565
|
this.handleCancel();
|
|
533
566
|
}
|
|
534
567
|
}
|
|
535
568
|
|
|
569
|
+
// an escape routed through a dialog other than our own (e.g. a nested
|
|
570
|
+
// dialog or modax opened from a field) belongs to that dialog; an escape
|
|
571
|
+
// with no dialog in its path (canvas or body focus) is ours to handle
|
|
572
|
+
private ownsEscape(path: EventTarget[]): boolean {
|
|
573
|
+
for (const el of path) {
|
|
574
|
+
if (el instanceof Element && el.tagName === 'TEMBA-DIALOG') {
|
|
575
|
+
return el === this.shadowRoot?.querySelector('temba-dialog');
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
return true;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// temba-dialog handles escape itself on KEYUP reaching its container, which
|
|
582
|
+
// would prompt a second time after the keydown handler above has already
|
|
583
|
+
// asked (or dismiss unprompted if its keyup ever arrived first). While the
|
|
584
|
+
// editor is open, escape within our own dialog is owned entirely by the
|
|
585
|
+
// keydown handler, so stop those keyups before the dialog sees them —
|
|
586
|
+
// but leave escapes belonging to a dialog layered above us alone.
|
|
587
|
+
private swallowEscapeUp(event: KeyboardEvent): void {
|
|
588
|
+
if (
|
|
589
|
+
event.key === 'Escape' &&
|
|
590
|
+
this.isOpen &&
|
|
591
|
+
this.ownsEscape(event.composedPath())
|
|
592
|
+
) {
|
|
593
|
+
event.stopPropagation();
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
private hasUnsavedChanges(): boolean {
|
|
598
|
+
return this.getComparableFormData() !== this.pristineFormData;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// JSON of formData with array-editor scaffolding filtered out: array editors
|
|
602
|
+
// seed a blank row (carrying field defaults, e.g. an attachment type) into
|
|
603
|
+
// form data on first render, which must not count as a user edit
|
|
604
|
+
private getComparableFormData(): string {
|
|
605
|
+
const form = this.getConfig()?.form;
|
|
606
|
+
const comparable = { ...this.formData };
|
|
607
|
+
|
|
608
|
+
if (form) {
|
|
609
|
+
Object.entries(form).forEach(([fieldName, fieldConfig]) => {
|
|
610
|
+
const value = comparable[fieldName];
|
|
611
|
+
if (fieldConfig.type === 'array' && Array.isArray(value)) {
|
|
612
|
+
// same emptiness semantics as ArrayEditor.isEmptyItem
|
|
613
|
+
const isEmpty =
|
|
614
|
+
fieldConfig.isEmptyItem ||
|
|
615
|
+
((item: any) => {
|
|
616
|
+
const values = Object.values(item || {});
|
|
617
|
+
return (
|
|
618
|
+
values.length === 0 ||
|
|
619
|
+
values.every((v) => v === undefined || v === null || v === '')
|
|
620
|
+
);
|
|
621
|
+
});
|
|
622
|
+
comparable[fieldName] = value.filter((item: any) => !isEmpty(item));
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
return JSON.stringify(comparable);
|
|
628
|
+
}
|
|
629
|
+
|
|
536
630
|
willUpdate(changedProperties: PropertyValues): void {
|
|
537
631
|
super.willUpdate(changedProperties);
|
|
538
632
|
if (
|
|
@@ -719,14 +813,24 @@ export class NodeEditor extends RapidElement {
|
|
|
719
813
|
this.formData[key] = { ...value };
|
|
720
814
|
}
|
|
721
815
|
});
|
|
816
|
+
|
|
817
|
+
// fresh form, so nothing to warn about discarding yet
|
|
818
|
+
this.pristineFormData = this.getComparableFormData();
|
|
722
819
|
}
|
|
723
820
|
|
|
724
821
|
private async resolveFormData(): Promise<void> {
|
|
725
822
|
const config = this.getConfig();
|
|
726
823
|
if (!config?.resolveFormData) return;
|
|
727
824
|
|
|
825
|
+
const input = this.formData;
|
|
728
826
|
try {
|
|
729
|
-
const resolved = await config.resolveFormData(
|
|
827
|
+
const resolved = await config.resolveFormData(input);
|
|
828
|
+
|
|
829
|
+
// if the user edited while the resolve was in flight, applying the
|
|
830
|
+
// stale result would overwrite their edit — and rebasing the baseline
|
|
831
|
+
// below would then mask the loss as a clean form
|
|
832
|
+
if (this.formData !== input) return;
|
|
833
|
+
|
|
730
834
|
if (resolved && resolved !== this.formData) {
|
|
731
835
|
this.formData = resolved;
|
|
732
836
|
this.processFormDataForEditing();
|
|
@@ -749,6 +853,9 @@ export class NodeEditor extends RapidElement {
|
|
|
749
853
|
});
|
|
750
854
|
}
|
|
751
855
|
|
|
856
|
+
// resolving isn't a user edit; rebase the unsaved-changes baseline
|
|
857
|
+
this.pristineFormData = this.getComparableFormData();
|
|
858
|
+
|
|
752
859
|
this.requestUpdate();
|
|
753
860
|
}
|
|
754
861
|
} catch (error) {
|
|
@@ -2690,6 +2797,7 @@ export class NodeEditor extends RapidElement {
|
|
|
2690
2797
|
.open="${this.isOpen}"
|
|
2691
2798
|
.originX=${this.dialogOrigin?.x ?? null}
|
|
2692
2799
|
.originY=${this.dialogOrigin?.y ?? null}
|
|
2800
|
+
.checkForChanges=${() => this.hasUnsavedChanges()}
|
|
2693
2801
|
@temba-button-clicked=${this.handleDialogButtonClick}
|
|
2694
2802
|
primaryButtonName="Save"
|
|
2695
2803
|
cancelButtonName="Cancel"
|
package/src/interfaces.ts
CHANGED
|
@@ -54,6 +54,25 @@ export interface ScheduledEvent {
|
|
|
54
54
|
message?: string;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
// one event definition in a campaign's schedule, offset from a date field
|
|
58
|
+
// on the contact - consumed by the temba-campaign-events component
|
|
59
|
+
export interface CampaignScheduleEvent {
|
|
60
|
+
uuid: string;
|
|
61
|
+
type: 'message' | 'flow';
|
|
62
|
+
status: 'ready' | 'scheduling';
|
|
63
|
+
offset: number;
|
|
64
|
+
unit: string;
|
|
65
|
+
offset_display: string;
|
|
66
|
+
delivery_hour_display?: string;
|
|
67
|
+
relative_to: { key: string; name: string; system?: boolean };
|
|
68
|
+
flow?: ObjectReference;
|
|
69
|
+
message?: string;
|
|
70
|
+
count: number;
|
|
71
|
+
edit_url: string;
|
|
72
|
+
delete_url: string;
|
|
73
|
+
fires_url: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
57
76
|
export interface NamedUser extends User {
|
|
58
77
|
name: string;
|
|
59
78
|
}
|
|
@@ -120,6 +139,59 @@ export interface Flow {
|
|
|
120
139
|
activity: number[];
|
|
121
140
|
}
|
|
122
141
|
|
|
142
|
+
/** A single row in the campaign CRUDL list
|
|
143
|
+
* (`campaigns/campaign_list.html`). */
|
|
144
|
+
export interface Campaign {
|
|
145
|
+
uuid: string;
|
|
146
|
+
name: string;
|
|
147
|
+
/** The contact group the campaign schedules against. */
|
|
148
|
+
group: ObjectReference;
|
|
149
|
+
/** Number of events (messages / flow starts) in the campaign. */
|
|
150
|
+
events: number;
|
|
151
|
+
/** Contacts currently in the campaign's group. */
|
|
152
|
+
contacts: number;
|
|
153
|
+
modified_on: string;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** A single row in the trigger CRUDL list
|
|
157
|
+
* (`triggers/trigger_list.html`): what starts the flow (type +
|
|
158
|
+
* per-type details), any channel / group filters, and the flow it
|
|
159
|
+
* starts. Triggers have no uuid — rows key off the numeric id. */
|
|
160
|
+
export interface Trigger {
|
|
161
|
+
id: number;
|
|
162
|
+
/** Trigger type slug — drives the leading row icon and the
|
|
163
|
+
* details cell (`keyword`, `catch_all`, `schedule`,
|
|
164
|
+
* `inbound_call`, `missed_call`, `new_conversation`, `referral`,
|
|
165
|
+
* `closed_ticket`, `opt_in`, `opt_out`). */
|
|
166
|
+
type: string;
|
|
167
|
+
/** The flow the trigger starts. */
|
|
168
|
+
flow: ObjectReference;
|
|
169
|
+
/** Channel the trigger is limited to, with its type icon. */
|
|
170
|
+
channel?: (ObjectReference & { icon?: string }) | null;
|
|
171
|
+
/** Groups the trigger is limited to. */
|
|
172
|
+
groups?: ObjectReference[];
|
|
173
|
+
/** Groups the trigger excludes. */
|
|
174
|
+
exclude_groups?: ObjectReference[];
|
|
175
|
+
/** Contacts a scheduled trigger starts directly. */
|
|
176
|
+
contacts?: ObjectReference[];
|
|
177
|
+
/** Keyword triggers only. */
|
|
178
|
+
keywords?: string[];
|
|
179
|
+
/** Keyword match type — `F` (starts with) or `O` (matches). */
|
|
180
|
+
match_type?: string | null;
|
|
181
|
+
/** Referral triggers only. */
|
|
182
|
+
referrer_id?: string | null;
|
|
183
|
+
/** Scheduled triggers only — `display` is the server-rendered
|
|
184
|
+
* human schedule ("each week on Monday"); `next_fire` is unset
|
|
185
|
+
* once the schedule is exhausted or paused. */
|
|
186
|
+
schedule?: {
|
|
187
|
+
repeat_period?: string;
|
|
188
|
+
display?: string;
|
|
189
|
+
next_fire?: string | null;
|
|
190
|
+
} | null;
|
|
191
|
+
priority?: number;
|
|
192
|
+
created_on?: string;
|
|
193
|
+
}
|
|
194
|
+
|
|
123
195
|
export interface Msg {
|
|
124
196
|
/** Numeric id — present on persisted messages (the CRUDL list
|
|
125
197
|
* keys rows off it); absent on outbound drafts. */
|
|
@@ -137,7 +209,10 @@ export interface Msg {
|
|
|
137
209
|
/** Message type as exposed by the messages CRUDL endpoint
|
|
138
210
|
* (`text` / `optin` / …); mirrors `type` for that surface. */
|
|
139
211
|
msg_type?: string;
|
|
140
|
-
|
|
212
|
+
/** Attachments as exposed by the messages CRUDL endpoint, which
|
|
213
|
+
* serializes each as a `{content_type, url}` object; some other
|
|
214
|
+
* message surfaces carry them as `contentType:url` strings. */
|
|
215
|
+
attachments: (string | Attachment)[];
|
|
141
216
|
/** Labels applied to the message. */
|
|
142
217
|
labels?: ObjectReference[];
|
|
143
218
|
/** The flow the message was sent from, when there is one. */
|
|
@@ -192,6 +267,8 @@ export interface ContactGroup {
|
|
|
192
267
|
export interface URN {
|
|
193
268
|
scheme: string;
|
|
194
269
|
path: string;
|
|
270
|
+
display?: string | null;
|
|
271
|
+
channel?: ObjectReference | null;
|
|
195
272
|
}
|
|
196
273
|
|
|
197
274
|
export interface Group {
|
|
@@ -224,7 +301,7 @@ export interface Contact {
|
|
|
224
301
|
uuid: string;
|
|
225
302
|
stopped: boolean;
|
|
226
303
|
blocked: boolean;
|
|
227
|
-
urns:
|
|
304
|
+
urns: URN[];
|
|
228
305
|
language?: string;
|
|
229
306
|
fields: { [key: string]: string };
|
|
230
307
|
groups: Group[];
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { css, html, TemplateResult } from 'lit';
|
|
2
|
+
import { property } from 'lit/decorators.js';
|
|
3
|
+
import { RapidElement } from '../RapidElement';
|
|
4
|
+
import { designTokens } from '../styles/designTokens';
|
|
5
|
+
import { getClasses } from '../utils';
|
|
6
|
+
import { Icon } from '../Icons';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A collapsible card with a header showing an icon, label and optional
|
|
10
|
+
* count badge. Designed to live inside a temba-card-stack where the header
|
|
11
|
+
* doubles as the drag handle, but works standalone too. The body never
|
|
12
|
+
* scrolls internally — cards grow to their content.
|
|
13
|
+
*/
|
|
14
|
+
export class Card extends RapidElement {
|
|
15
|
+
static get styles() {
|
|
16
|
+
return css`
|
|
17
|
+
${designTokens}
|
|
18
|
+
|
|
19
|
+
:host {
|
|
20
|
+
display: block;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/* The chrome lives on an inner frame rather than the host so
|
|
24
|
+
document-level universal rules (e.g. tailwind's preflight
|
|
25
|
+
border-color) can't override it. */
|
|
26
|
+
.frame {
|
|
27
|
+
background: var(--card-bg, var(--surface));
|
|
28
|
+
border: 1px solid var(--card-border, var(--border-strong));
|
|
29
|
+
border-radius: var(--r-sm);
|
|
30
|
+
box-shadow: var(--shadow-2);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/* note variant — the sticky-note surface, header included */
|
|
34
|
+
:host([variant='note']) .frame {
|
|
35
|
+
background: var(--surface-note);
|
|
36
|
+
border-color: var(--border-note);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.card-header {
|
|
40
|
+
display: flex;
|
|
41
|
+
align-items: center;
|
|
42
|
+
padding: 8px 10px;
|
|
43
|
+
cursor: pointer;
|
|
44
|
+
user-select: none;
|
|
45
|
+
border-radius: var(--r-sm);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.card-header temba-icon {
|
|
49
|
+
--icon-color: var(--text-3);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.grip {
|
|
53
|
+
margin-right: 0.5em;
|
|
54
|
+
cursor: grab;
|
|
55
|
+
--icon-color: var(--text-4);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.card-header:hover .grip {
|
|
59
|
+
--icon-color: var(--text-3);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.label {
|
|
63
|
+
display: flex;
|
|
64
|
+
align-items: center;
|
|
65
|
+
flex-grow: 1;
|
|
66
|
+
font-size: 13px;
|
|
67
|
+
font-weight: var(--w-medium);
|
|
68
|
+
color: var(--text-2);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.label temba-icon {
|
|
72
|
+
margin-right: 0.5em;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.count {
|
|
76
|
+
display: inline-flex;
|
|
77
|
+
align-items: center;
|
|
78
|
+
justify-content: center;
|
|
79
|
+
min-width: 16px;
|
|
80
|
+
height: 16px;
|
|
81
|
+
padding: 0 4px;
|
|
82
|
+
margin-right: 0.5em;
|
|
83
|
+
border-radius: 999px;
|
|
84
|
+
background: var(--accent-100);
|
|
85
|
+
color: var(--accent-700);
|
|
86
|
+
font-size: 11px;
|
|
87
|
+
font-weight: var(--w-semibold);
|
|
88
|
+
font-variant-numeric: tabular-nums;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.dot {
|
|
92
|
+
height: 0.5em;
|
|
93
|
+
width: 0.5em;
|
|
94
|
+
margin-right: 0.5em;
|
|
95
|
+
background: var(--accent-600);
|
|
96
|
+
border-radius: 99px;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.toggle {
|
|
100
|
+
transition: transform 200ms ease;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.toggle.collapsed {
|
|
104
|
+
transform: rotate(-90deg);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/* Grid trick so collapse animates to natural content height without
|
|
108
|
+
capping how tall an expanded card can grow. */
|
|
109
|
+
.body {
|
|
110
|
+
display: grid;
|
|
111
|
+
grid-template-rows: 1fr;
|
|
112
|
+
transition: grid-template-rows 200ms ease;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.body.collapsed {
|
|
116
|
+
grid-template-rows: 0fr;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.inner {
|
|
120
|
+
min-height: 0;
|
|
121
|
+
overflow: hidden;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/* once fully expanded, let popovers (date pickers etc.) escape */
|
|
125
|
+
.body:not(.collapsed):not(.animating) .inner {
|
|
126
|
+
overflow: visible;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.content {
|
|
130
|
+
padding: 0 10px 10px;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* bleed mode: the body content runs edge-to-edge so a panel with its
|
|
134
|
+
own surface (e.g. the notepad) fills the card, clipped to the card
|
|
135
|
+
radius. The footer of such content sits on the card's bottom edge. */
|
|
136
|
+
:host([bleed]) .content {
|
|
137
|
+
padding: 0;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
:host([bleed]) .body:not(.collapsed):not(.animating) .inner {
|
|
141
|
+
overflow: hidden;
|
|
142
|
+
border-radius: 0 0 var(--r-sm) var(--r-sm);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/* plain mode: headerless and non-collapsible — the wrapper (e.g. a
|
|
146
|
+
tab pane) supplies the label — but still a proper card surface
|
|
147
|
+
with padding. Fills its pane and scrolls its content internally,
|
|
148
|
+
since a tab pane is height-bounded. */
|
|
149
|
+
:host([plain]) {
|
|
150
|
+
display: flex;
|
|
151
|
+
flex-direction: column;
|
|
152
|
+
flex-grow: 1;
|
|
153
|
+
min-height: 0;
|
|
154
|
+
margin-top: var(--layout-spacing, 8px);
|
|
155
|
+
margin-bottom: var(--layout-spacing, 8px);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
:host([plain]) .frame,
|
|
159
|
+
:host([plain]) .body,
|
|
160
|
+
:host([plain]) .inner {
|
|
161
|
+
display: flex;
|
|
162
|
+
flex-direction: column;
|
|
163
|
+
flex-grow: 1;
|
|
164
|
+
min-height: 0;
|
|
165
|
+
overflow: hidden;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
:host([plain]) .inner {
|
|
169
|
+
border-radius: var(--r-sm);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
:host([plain]) .content {
|
|
173
|
+
display: flex;
|
|
174
|
+
flex-direction: column;
|
|
175
|
+
flex-grow: 1;
|
|
176
|
+
min-height: 0;
|
|
177
|
+
padding: 10px;
|
|
178
|
+
overflow-y: auto;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/* no header row in plain mode, so bleeding content gets a top inset
|
|
182
|
+
directly against the card surface */
|
|
183
|
+
:host([plain][bleed]) .content {
|
|
184
|
+
padding: 10px 0 0 0;
|
|
185
|
+
}
|
|
186
|
+
`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
@property({ type: String })
|
|
190
|
+
label = '';
|
|
191
|
+
|
|
192
|
+
@property({ type: String })
|
|
193
|
+
icon = '';
|
|
194
|
+
|
|
195
|
+
@property({ type: Number })
|
|
196
|
+
count = 0;
|
|
197
|
+
|
|
198
|
+
// show a dot instead of the count
|
|
199
|
+
@property({ type: Boolean })
|
|
200
|
+
activity = false;
|
|
201
|
+
|
|
202
|
+
@property({ type: Boolean, reflect: true })
|
|
203
|
+
collapsed = false;
|
|
204
|
+
|
|
205
|
+
// render without chrome (no header, border or collapse) — content only
|
|
206
|
+
@property({ type: Boolean, reflect: true })
|
|
207
|
+
plain = false;
|
|
208
|
+
|
|
209
|
+
// body content runs edge-to-edge instead of getting the inset padding
|
|
210
|
+
@property({ type: Boolean, reflect: true })
|
|
211
|
+
bleed = false;
|
|
212
|
+
|
|
213
|
+
// named surface treatments, e.g. "note" for the sticky-note look
|
|
214
|
+
@property({ type: String, reflect: true })
|
|
215
|
+
variant = '';
|
|
216
|
+
|
|
217
|
+
@property({ type: Boolean })
|
|
218
|
+
dirty = false;
|
|
219
|
+
|
|
220
|
+
private animating = false;
|
|
221
|
+
|
|
222
|
+
private handleHeaderClick() {
|
|
223
|
+
this.collapsed = !this.collapsed;
|
|
224
|
+
this.animating = true;
|
|
225
|
+
this.requestUpdate();
|
|
226
|
+
this.dispatchEvent(
|
|
227
|
+
new CustomEvent('toggle', {
|
|
228
|
+
bubbles: true,
|
|
229
|
+
composed: true,
|
|
230
|
+
detail: { collapsed: this.collapsed, label: this.label }
|
|
231
|
+
})
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private handleTransitionEnd(event: TransitionEvent) {
|
|
236
|
+
// transitionend bubbles composed out of slotted content — only our own
|
|
237
|
+
// grid collapse animation should clear the clipping state
|
|
238
|
+
if (
|
|
239
|
+
event.target !== event.currentTarget ||
|
|
240
|
+
event.propertyName !== 'grid-template-rows'
|
|
241
|
+
) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
this.animating = false;
|
|
245
|
+
this.requestUpdate();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private handleDetailsChanged(event: CustomEvent) {
|
|
249
|
+
if ('dirty' in event.detail) {
|
|
250
|
+
this.dirty = event.detail.dirty;
|
|
251
|
+
}
|
|
252
|
+
if ('count' in event.detail) {
|
|
253
|
+
this.count = event.detail.count;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
public render(): TemplateResult {
|
|
258
|
+
if (this.plain) {
|
|
259
|
+
return html`
|
|
260
|
+
<div class="frame">
|
|
261
|
+
<div class="body">
|
|
262
|
+
<div class="inner">
|
|
263
|
+
<div class="content">
|
|
264
|
+
<slot
|
|
265
|
+
@temba-details-changed=${this.handleDetailsChanged}
|
|
266
|
+
></slot>
|
|
267
|
+
</div>
|
|
268
|
+
</div>
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return html`
|
|
275
|
+
<div class="frame">
|
|
276
|
+
<div class="card-header" @click=${this.handleHeaderClick}>
|
|
277
|
+
<temba-icon name=${Icon.drag} class="grip"></temba-icon>
|
|
278
|
+
<div class="label">
|
|
279
|
+
${this.icon
|
|
280
|
+
? html`<temba-icon name=${this.icon}></temba-icon>`
|
|
281
|
+
: null}
|
|
282
|
+
${this.label}${this.dirty ? ' *' : ''}
|
|
283
|
+
</div>
|
|
284
|
+
<slot name="header-actions"></slot>
|
|
285
|
+
${this.count > 0
|
|
286
|
+
? this.activity
|
|
287
|
+
? html`<div class="dot"></div>`
|
|
288
|
+
: html`<div class="count">${this.count.toLocaleString()}</div>`
|
|
289
|
+
: null}
|
|
290
|
+
<temba-icon
|
|
291
|
+
name=${Icon.arrow_down}
|
|
292
|
+
class="toggle ${this.collapsed ? 'collapsed' : ''}"
|
|
293
|
+
></temba-icon>
|
|
294
|
+
</div>
|
|
295
|
+
<div
|
|
296
|
+
class="body ${getClasses({
|
|
297
|
+
collapsed: this.collapsed,
|
|
298
|
+
animating: this.animating
|
|
299
|
+
})}"
|
|
300
|
+
@transitionend=${this.handleTransitionEnd}
|
|
301
|
+
>
|
|
302
|
+
<div class="inner">
|
|
303
|
+
<div class="content">
|
|
304
|
+
<slot @temba-details-changed=${this.handleDetailsChanged}></slot>
|
|
305
|
+
</div>
|
|
306
|
+
</div>
|
|
307
|
+
</div>
|
|
308
|
+
</div>
|
|
309
|
+
`;
|
|
310
|
+
}
|
|
311
|
+
}
|