@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.
@@ -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
+ }