@nyaruka/temba-components 0.172.0 → 0.173.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nyaruka/temba-components",
3
- "version": "0.172.0",
3
+ "version": "0.173.0",
4
4
  "description": "Web components to support rapidpro and related projects",
5
5
  "author": "Nyaruka <code@nyaruka.coim>",
6
6
  "main": "dist/index.js",
package/src/Icons.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /* eslint-disable @typescript-eslint/no-duplicate-enum-values */
2
2
  // for cache busting we dynamically generate a fingerprint, use bun run svg to update
3
- export const SVG_FINGERPRINT = '27198f759c371df944f8ec5de0a29dd6';
3
+ export const SVG_FINGERPRINT = '132907abcfa6a4611ba27470e2c5c2c0';
4
4
 
5
5
  // only icons below are included in the sprite sheet
6
6
  export enum Icon {
@@ -58,6 +58,7 @@ export enum Icon {
58
58
  delete = 'trash-03',
59
59
  delete_small = 'x',
60
60
  docs = 'file-02',
61
+ documents = 'folder',
61
62
  down = 'chevron-down',
62
63
  down_double = 'chevron-down-double',
63
64
  download = 'download-01',
@@ -97,6 +98,7 @@ export enum Icon {
97
98
  integrations = 'layers-three-01',
98
99
  invitations = 'user-plus-01',
99
100
  issue = 'alert-square',
101
+ knowledge = 'book-open-01',
100
102
  label = 'tag-01',
101
103
  language = 'translate-01',
102
104
  language_default = 'switch-horizontal-01',
@@ -210,6 +212,7 @@ export enum Icon {
210
212
  users = 'users-01',
211
213
  video = 'video-recorder',
212
214
  webhook = 'link-external-01',
215
+ website = 'layout-alt-03',
213
216
  workspace = 'folder',
214
217
  zoom_fit = 'maximize-02',
215
218
  zoom_in = 'expand-06',
package/src/interfaces.ts CHANGED
@@ -525,6 +525,8 @@ export enum CustomEventType {
525
525
  RevisionReverted = 'temba-revision-reverted',
526
526
  RevisionsClosed = 'temba-revisions-closed',
527
527
  RowClick = 'temba-row-click',
528
+ ShortcutEdit = 'temba-shortcut-edit',
529
+ ShortcutDelete = 'temba-shortcut-delete',
528
530
  SelectionChange = 'temba-selection-change',
529
531
  BulkAction = 'temba-bulk-action',
530
532
  /** The label dropdown's "New Label…" row was clicked — detail
@@ -14,13 +14,19 @@ export class HeaderBar extends RapidElement {
14
14
  ${designTokens}
15
15
 
16
16
  :host {
17
+ /* The single source for the bar's inset — it sets the horizontal
18
+ padding here and, for fit-content bars, the vertical padding the
19
+ slotted page header inherits. Both axes move together by
20
+ construction, so the surround can't drift. */
21
+ --header-bar-inset: 8px;
22
+
17
23
  flex: 0 0 auto;
18
24
  display: flex;
19
25
  align-items: center;
20
26
  /* 52px strip plus the 1px rule */
21
- height: 53px;
27
+ height: var(--header-h);
22
28
  box-sizing: border-box;
23
- padding: 0 8px;
29
+ padding: 0 var(--header-bar-inset);
24
30
  background: var(--surface);
25
31
  /* the rule is a box-shadow, not a border — host pages (tailwind
26
32
  preflight) reset border-width on every element, and outer-scope
@@ -28,6 +34,26 @@ export class HeaderBar extends RapidElement {
28
34
  box-shadow: inset 0 -1px 0 0 var(--border);
29
35
  }
30
36
 
37
+ /* Standalone page headers keep the strip height as a floor but grow
38
+ for taller content (a subtitle), with the same surround on every
39
+ side either way.
40
+
41
+ The inset is solved for rather than picked: it's the padding that
42
+ makes inset + one title line + inset come out at exactly the strip
43
+ height, so a single-line title fills the strip with balanced
44
+ spacing. That's why the floor is expressed as height, not as a
45
+ min-height the padding has to fight — with the inset derived this
46
+ way the content already meets the floor, so there's never slack for
47
+ flex centering to absorb into an uneven gap above and below. */
48
+ :host([fit-content]) {
49
+ --header-bar-inset: calc((var(--header-h) - var(--title-line)) / 2);
50
+ --page-header-padding: var(--header-bar-inset) 0;
51
+
52
+ align-items: stretch;
53
+ height: auto;
54
+ min-height: var(--header-h);
55
+ }
56
+
31
57
  ::slotted(*) {
32
58
  flex-grow: 1;
33
59
  min-width: 0;
@@ -49,14 +49,19 @@ export class PageHeader extends RapidElement {
49
49
  /* One row: the title/subtitle block on the left and the
50
50
  actions/content-menu on the right, vertically centered against
51
51
  each other. The title block is the flexing column so the
52
- actions hold their size. The vertical padding matches the
53
- horizontal inset the host supplies (the list panel's 20px) so
54
- the whole header is wrapped in even, consistent padding. */
52
+ actions hold their size. Hosts tune the vertical padding to match
53
+ their own horizontal inset list panels use the default, header
54
+ bars pass down theirs — so the header always ends up with an even
55
+ surround. The height follows from that padding plus the content; a
56
+ host wanting a taller strip should raise the padding rather than
57
+ impose a height, which would be absorbed into the gap around the
58
+ title instead of growing the box. */
55
59
  .header {
60
+ box-sizing: border-box;
56
61
  display: flex;
57
62
  align-items: center;
58
63
  gap: var(--gap);
59
- padding: 12px 0;
64
+ padding: var(--page-header-padding, 12px 0);
60
65
  }
61
66
  /* Title + subtitle stacked tight, sharing the left column. It
62
67
  flexes and clips so a long subtitle truncates against the
@@ -68,13 +73,15 @@ export class PageHeader extends RapidElement {
68
73
  flex-direction: column;
69
74
  gap: 1px;
70
75
  }
76
+ /* Sized from the shared tokens — temba-header-bar solves its inset
77
+ against --title-line, so the two stay in step. */
71
78
  .title {
72
- font-size: 15.5px;
79
+ font-size: var(--title-size);
73
80
  /* slotted contact names render at the title's size */
74
- --contact-name-font-size: 15.5px;
81
+ --contact-name-font-size: var(--title-size);
75
82
  font-weight: var(--w-semibold);
76
83
  color: var(--text-1);
77
- line-height: 1.25;
84
+ line-height: var(--title-leading);
78
85
  white-space: nowrap;
79
86
  overflow: hidden;
80
87
  text-overflow: ellipsis;
@@ -0,0 +1,347 @@
1
+ import { css, html, TemplateResult } from 'lit';
2
+ import { property, state } from 'lit/decorators.js';
3
+ import { Icon } from '../Icons';
4
+ import { CustomEventType, Shortcut } from '../interfaces';
5
+ import { ContentList, ContentListColumn } from './ContentList';
6
+
7
+ /**
8
+ * Shortcut management list used by the Tickets knowledge page. This is
9
+ * intentionally separate from `temba-shortcuts`, which is the compact picker
10
+ * shown while composing a ticket reply.
11
+ */
12
+ export class ShortcutContentList extends ContentList<Shortcut> {
13
+ static get styles() {
14
+ return css`
15
+ ${ContentList.styles}
16
+
17
+ tr.row {
18
+ cursor: pointer;
19
+ }
20
+
21
+ .table-frame {
22
+ margin-left: -20px;
23
+ }
24
+
25
+ tr.header th:first-child,
26
+ tr.row td:first-child {
27
+ padding-left: 20px;
28
+ }
29
+
30
+ tr.header th:last-child,
31
+ tr.row td:last-child {
32
+ padding-right: 20px;
33
+ }
34
+
35
+ .shortcut-name {
36
+ font-weight: var(--w-medium);
37
+ overflow: hidden;
38
+ text-overflow: ellipsis;
39
+ white-space: nowrap;
40
+ }
41
+
42
+ .shortcut-text {
43
+ color: var(--text-2);
44
+ overflow: hidden;
45
+ text-overflow: ellipsis;
46
+ white-space: nowrap;
47
+ }
48
+
49
+ .detail {
50
+ display: flex;
51
+ flex-direction: column;
52
+ }
53
+
54
+ .detail-header {
55
+ align-items: center;
56
+ border-bottom: 1px solid var(--border);
57
+ display: flex;
58
+ gap: 12px;
59
+ padding: 1em;
60
+ }
61
+
62
+ .detail-title-block {
63
+ display: flex;
64
+ flex: 1;
65
+ flex-direction: column;
66
+ gap: 4px;
67
+ min-width: 0;
68
+ }
69
+
70
+ .detail-title {
71
+ color: var(--text-1);
72
+ font-size: 15.5px;
73
+ font-weight: var(--w-semibold);
74
+ overflow: hidden;
75
+ text-overflow: ellipsis;
76
+ white-space: nowrap;
77
+ }
78
+
79
+ .detail-meta {
80
+ color: var(--text-3);
81
+ font-size: 0.9em;
82
+ }
83
+
84
+ .detail-actions {
85
+ align-items: center;
86
+ display: flex;
87
+ flex-shrink: 0;
88
+ gap: 8px;
89
+ }
90
+
91
+ .menu-button {
92
+ align-items: center;
93
+ background: var(--surface);
94
+ border: 1px solid var(--border-strong);
95
+ border-radius: var(--r-sm);
96
+ box-sizing: border-box;
97
+ color: var(--text-1);
98
+ cursor: pointer;
99
+ display: inline-flex;
100
+ font-size: 12.5px;
101
+ height: 26px;
102
+ padding: 0 10px;
103
+ white-space: nowrap;
104
+ }
105
+
106
+ .menu-button:hover {
107
+ background: var(--sunken);
108
+ }
109
+
110
+ .menu-button.destructive {
111
+ color: var(--danger);
112
+ }
113
+
114
+ .menu-button.destructive:hover {
115
+ border-color: var(--danger);
116
+ background: color-mix(in srgb, var(--danger) 6%, var(--surface));
117
+ }
118
+
119
+ .detail-close {
120
+ align-items: center;
121
+ border-radius: var(--r-sm);
122
+ color: var(--text-3);
123
+ cursor: pointer;
124
+ display: inline-flex;
125
+ flex-shrink: 0;
126
+ height: 28px;
127
+ justify-content: center;
128
+ user-select: none;
129
+ width: 28px;
130
+ }
131
+
132
+ .detail-close:hover {
133
+ background: var(--sunken);
134
+ color: var(--text-1);
135
+ }
136
+
137
+ .detail-close temba-icon {
138
+ --icon-color: currentColor;
139
+ }
140
+
141
+ .detail-body {
142
+ max-height: calc(100vh - 300px);
143
+ overflow-y: auto;
144
+ padding: 1.25em 1.75em;
145
+ }
146
+
147
+ .detail-response {
148
+ background: color-mix(in srgb, var(--sunken) 45%, var(--surface));
149
+ border: 1px solid var(--border);
150
+ border-radius: var(--r);
151
+ color: var(--text-1);
152
+ line-height: 1.5;
153
+ padding: 0.9em 1em 1em;
154
+ }
155
+
156
+ /* pre-wrap lives on the text node's own element so the template's
157
+ indentation around the interpolation isn't rendered as leading
158
+ whitespace */
159
+ .detail-text {
160
+ overflow-wrap: anywhere;
161
+ white-space: pre-wrap;
162
+ }
163
+
164
+ .detail-section-title {
165
+ color: var(--text-3);
166
+ font-size: 0.75em;
167
+ font-weight: var(--w-semibold);
168
+ letter-spacing: 0.05em;
169
+ margin-bottom: 0.6em;
170
+ text-transform: uppercase;
171
+ }
172
+ `;
173
+ }
174
+
175
+ @property({ type: Boolean })
176
+ editable = false;
177
+
178
+ @property({ type: Boolean })
179
+ deletable = false;
180
+
181
+ @state()
182
+ private detailShortcut: Shortcut = null;
183
+
184
+ constructor() {
185
+ super();
186
+ this.valueKey = 'uuid';
187
+ this.emptyMessage = 'No shortcuts';
188
+ this.searchPlaceholder = 'Search shortcuts';
189
+ this.columns = [
190
+ {
191
+ key: 'name',
192
+ label: 'Name',
193
+ minWidth: '140px',
194
+ maxWidth: '240px'
195
+ },
196
+ { key: 'text', label: 'Text', minWidth: '240px', grow: true }
197
+ ];
198
+ }
199
+
200
+ public connectedCallback(): void {
201
+ super.connectedCallback();
202
+ this.addEventListener(
203
+ CustomEventType.RowClick,
204
+ this.handleRowClickEvent as EventListener
205
+ );
206
+ }
207
+
208
+ public disconnectedCallback(): void {
209
+ super.disconnectedCallback();
210
+ this.removeEventListener(
211
+ CustomEventType.RowClick,
212
+ this.handleRowClickEvent as EventListener
213
+ );
214
+ }
215
+
216
+ private handleRowClickEvent = (event: CustomEvent): void => {
217
+ const shortcut = event.detail?.item as Shortcut;
218
+ if (shortcut) {
219
+ this.detailShortcut = shortcut;
220
+ }
221
+ };
222
+
223
+ private handleDetailClosed = (): void => {
224
+ this.detailShortcut = null;
225
+ };
226
+
227
+ private handleEditClicked(): void {
228
+ const shortcut = this.detailShortcut;
229
+ this.detailShortcut = null;
230
+ this.fireCustomEvent(CustomEventType.ShortcutEdit, { item: shortcut });
231
+ }
232
+
233
+ private handleDeleteClicked(): void {
234
+ const shortcut = this.detailShortcut;
235
+ this.detailShortcut = null;
236
+ this.fireCustomEvent(CustomEventType.ShortcutDelete, { item: shortcut });
237
+ }
238
+
239
+ private handleActivationKey(event: KeyboardEvent): void {
240
+ if (event.key === 'Enter' || event.key === ' ') {
241
+ event.preventDefault();
242
+ this.handleDetailClosed();
243
+ }
244
+ }
245
+
246
+ protected renderCell(
247
+ shortcut: Shortcut,
248
+ column: ContentListColumn
249
+ ): TemplateResult | string {
250
+ switch (column.key) {
251
+ case 'name':
252
+ return html`<span class="shortcut-name" title=${shortcut.name || ''}
253
+ >${shortcut.name || ''}</span
254
+ >`;
255
+ case 'text':
256
+ return html`<span class="shortcut-text" title=${shortcut.text || ''}
257
+ >${shortcut.text || ''}</span
258
+ >`;
259
+ default:
260
+ return super.renderCell(shortcut, column);
261
+ }
262
+ }
263
+
264
+ private renderDetail(): TemplateResult {
265
+ const shortcut = this.detailShortcut;
266
+
267
+ return html`
268
+ <temba-dialog
269
+ size="xlarge"
270
+ variant="flat"
271
+ primaryButtonName=""
272
+ cancelButtonName=""
273
+ hideOnClick
274
+ ?open=${!!shortcut}
275
+ @temba-dialog-hidden=${this.handleDetailClosed}
276
+ @keyup=${(event: KeyboardEvent) => {
277
+ if (event.key === 'Escape') {
278
+ this.handleDetailClosed();
279
+ }
280
+ }}
281
+ >
282
+ ${shortcut
283
+ ? html`
284
+ <div class="detail">
285
+ <div class="detail-header">
286
+ <div class="detail-title-block">
287
+ <div class="detail-title">${shortcut.name}</div>
288
+ ${shortcut.modified_on
289
+ ? html`<div class="detail-meta">
290
+ Modified
291
+ <temba-date
292
+ value=${shortcut.modified_on}
293
+ display="datetime"
294
+ ></temba-date>
295
+ </div>`
296
+ : null}
297
+ </div>
298
+ ${this.editable || this.deletable
299
+ ? html`<div class="detail-actions">
300
+ ${this.editable
301
+ ? html`<button
302
+ type="button"
303
+ class="menu-button"
304
+ @click=${this.handleEditClicked}
305
+ >
306
+ Edit
307
+ </button>`
308
+ : null}
309
+ ${this.deletable
310
+ ? html`<button
311
+ type="button"
312
+ class="menu-button destructive"
313
+ @click=${this.handleDeleteClicked}
314
+ >
315
+ Delete
316
+ </button>`
317
+ : null}
318
+ </div>`
319
+ : null}
320
+ <div
321
+ class="detail-close"
322
+ role="button"
323
+ tabindex="0"
324
+ aria-label="Close"
325
+ @click=${this.handleDetailClosed}
326
+ @keydown=${this.handleActivationKey}
327
+ >
328
+ <temba-icon name=${Icon.close} size="1.4"></temba-icon>
329
+ </div>
330
+ </div>
331
+ <div class="detail-body">
332
+ <div class="detail-response">
333
+ <div class="detail-section-title">Response</div>
334
+ <div class="detail-text">${shortcut.text}</div>
335
+ </div>
336
+ </div>
337
+ </div>
338
+ `
339
+ : null}
340
+ </temba-dialog>
341
+ `;
342
+ }
343
+
344
+ public render(): TemplateResult {
345
+ return html`${super.render()}${this.renderDetail()}`;
346
+ }
347
+ }
@@ -103,6 +103,17 @@ export const designTokens = css`
103
103
  --pad: 10px;
104
104
  --gap: 14px;
105
105
 
106
+ /* Page header strip. --header-h is the height of a header bar over a
107
+ single-line title (the tickets / contact chat strip). --title-line is
108
+ one line of that title, declared here rather than inside
109
+ temba-page-header because temba-header-bar derives its balanced inset
110
+ from it — keeping both on one definition means restyling the title
111
+ can't silently unbalance the strip. */
112
+ --header-h: 53px;
113
+ --title-size: 15.5px;
114
+ --title-leading: 1.25;
115
+ --title-line: calc(var(--title-size) * var(--title-leading));
116
+
106
117
  /* shadows */
107
118
  --shadow-1:
108
119
  0 1px 1px rgba(15, 22, 36, 0.04), 0 1px 2px rgba(15, 22, 36, 0.04);