@nyaruka/temba-components 0.158.3 → 0.159.1
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 +7 -4
- package/dist/static/svg/index.svg +1 -1
- package/dist/temba-components.js +1475 -602
- package/dist/temba-components.js.map +1 -1
- package/package.json +1 -2
- package/src/Icons.ts +8 -1
- package/src/display/Button.ts +24 -14
- package/src/display/Thumbnail.ts +1 -1
- package/src/events/eventRenderers.ts +29 -0
- package/src/events.ts +22 -0
- package/src/interfaces.ts +46 -2
- package/src/layout/PageHeader.ts +338 -0
- package/src/list/ContactList.ts +68 -52
- package/src/list/ContentList.ts +1472 -346
- package/src/list/FlowList.ts +20 -26
- package/src/list/MsgList.ts +169 -71
- package/src/live/ContactEvents.ts +880 -0
- package/src/styles/designTokens.ts +5 -2
- package/src/styles/pillVariants.ts +21 -6
- package/static/css/design-system.css +769 -0
- package/static/css/temba-components.css +16 -77
- package/static/svg/index.svg +1 -1
- package/static/svg/work/traced/chevron-down-double.svg +1 -0
- package/static/svg/work/traced/chevron-up-double.svg +1 -0
- package/static/svg/work/used/chevron-down-double.svg +3 -0
- package/static/svg/work/used/chevron-up-double.svg +3 -0
- package/temba-modules.ts +4 -2
- package/web-dev-server.config.mjs +9 -0
- package/src/live/ContactPending.ts +0 -247
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nyaruka/temba-components",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.159.1",
|
|
4
4
|
"description": "Web components to support rapidpro and related projects",
|
|
5
5
|
"author": "Nyaruka <code@nyaruka.coim>",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -14,7 +14,6 @@
|
|
|
14
14
|
"build": "rimraf dist && pnpm svg && tsc && rollup -c rollup.components.mjs ",
|
|
15
15
|
"build-wc": "rollup -c rollup.webchat.mjs",
|
|
16
16
|
"dev": "pnpm build && cp -R ./dist/* ../temba/rapidpro/node_modules/@nyaruka/temba-components/dist/ && cp -R ./dist/* ../floweditor/node_modules/@nyaruka/temba-components/dist/",
|
|
17
|
-
"postversion": "git push --tags && git push origin main",
|
|
18
17
|
"pre-commit": "pnpm locale:extract && pnpm locale:build && pnpm svg && git add ./src/Icons.ts ./static/svg/index.svg ./xliff ./src/locales && lint-staged",
|
|
19
18
|
"format:eslint": "eslint --ext .ts . --fix --ignore-path .gitignore --ignore-pattern \"src/locales/\"",
|
|
20
19
|
"format:prettier": "prettier \"**/*.js\" \"**/*.ts\" \"!src/locales/**\" --config .prettierrc --write --ignore-path .gitignore",
|
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 pnpm svg to update
|
|
3
|
-
export const SVG_FINGERPRINT = '
|
|
3
|
+
export const SVG_FINGERPRINT = 'ed78045b1cf74b0b9242822c54badf99';
|
|
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
|
down = 'chevron-down',
|
|
61
|
+
down_double = 'chevron-down-double',
|
|
61
62
|
download = 'download-01',
|
|
62
63
|
drag = 'dots-grid',
|
|
63
64
|
edit = 'edit-02',
|
|
@@ -135,6 +136,11 @@ export enum Icon {
|
|
|
135
136
|
reset = 'flip-backward',
|
|
136
137
|
resthooks = 'share-07',
|
|
137
138
|
restore = 'play',
|
|
139
|
+
/** Restoring archived messages — moves them back to the inbox, so
|
|
140
|
+
* the inbox tray reads better here than the generic `restore`
|
|
141
|
+
* (which is `play` because flows/triggers/campaigns use it to mean
|
|
142
|
+
* "reactivate"). */
|
|
143
|
+
restore_messages = 'inbox-01',
|
|
138
144
|
results_export = 'download-cloud-01',
|
|
139
145
|
retry = 'refresh-cw-05',
|
|
140
146
|
revisions = 'clock-rewind',
|
|
@@ -189,6 +195,7 @@ export enum Icon {
|
|
|
189
195
|
triggers = 'signal-01',
|
|
190
196
|
updated = 'edit-02',
|
|
191
197
|
up = 'chevron-up',
|
|
198
|
+
up_double = 'chevron-up-double',
|
|
192
199
|
upload = 'upload-cloud-01',
|
|
193
200
|
upload_image = 'camera-01',
|
|
194
201
|
usages = 'link-04',
|
package/src/display/Button.ts
CHANGED
|
@@ -10,6 +10,13 @@ export class Button extends LitElement {
|
|
|
10
10
|
display: inline-flex;
|
|
11
11
|
align-self: stretch;
|
|
12
12
|
font-family: var(--font);
|
|
13
|
+
/* Match the styleguide's .ds Inter rendering — the stylistic
|
|
14
|
+
sets (ss01/cv11) and the "tnum 0" off-switch — so glyph
|
|
15
|
+
shapes line up with the design-system buttons. */
|
|
16
|
+
font-feature-settings:
|
|
17
|
+
'ss01',
|
|
18
|
+
'cv11',
|
|
19
|
+
'tnum' 0;
|
|
13
20
|
}
|
|
14
21
|
|
|
15
22
|
/* DS .btn-sm — sizing, type, transition, shape */
|
|
@@ -58,34 +65,38 @@ export class Button extends LitElement {
|
|
|
58
65
|
font-size: 12px;
|
|
59
66
|
}
|
|
60
67
|
|
|
61
|
-
/* DS .btn-primary — solid accent fill with a
|
|
62
|
-
|
|
63
|
-
1px
|
|
68
|
+
/* DS .btn-primary — solid accent fill with a faint top
|
|
69
|
+
highlight and a ground shadow so it sits a touch above the
|
|
70
|
+
surface. The base 1px transparent border stays in the box
|
|
71
|
+
model so the primary's footprint matches the secondary's. */
|
|
64
72
|
.primary-button {
|
|
65
73
|
background: var(--accent-600);
|
|
66
|
-
border-color: var(--accent-700);
|
|
67
74
|
color: #fff;
|
|
75
|
+
box-shadow:
|
|
76
|
+
inset 0 1px 0 rgba(255, 255, 255, 0.18),
|
|
77
|
+
0 1px 1px rgba(15, 22, 36, 0.1);
|
|
68
78
|
}
|
|
69
79
|
.primary-button:hover {
|
|
70
80
|
background: var(--accent-700);
|
|
71
81
|
}
|
|
72
82
|
|
|
73
|
-
/*
|
|
83
|
+
/* Ghost-style secondary — transparent fill so it blends into
|
|
84
|
+
any surface (the dialog gutter's grey, a white card, etc.),
|
|
85
|
+
no border, with a semi-transparent wash on hover that shows
|
|
86
|
+
up regardless of the underlying colour. */
|
|
74
87
|
.secondary-button {
|
|
75
|
-
background:
|
|
76
|
-
border-color: var(--border-strong);
|
|
88
|
+
background: transparent;
|
|
77
89
|
color: var(--text-1);
|
|
78
90
|
}
|
|
79
91
|
.secondary-button:hover {
|
|
80
|
-
background:
|
|
92
|
+
background: rgba(0, 0, 0, 0.05);
|
|
81
93
|
}
|
|
82
94
|
|
|
83
|
-
/*
|
|
84
|
-
|
|
95
|
+
/* Flat success fill — mirrors the DS .btn-danger chrome (no
|
|
96
|
+
lift), tinted green for go-ahead CTAs. */
|
|
85
97
|
.attention-button,
|
|
86
98
|
.affirmative {
|
|
87
99
|
background: var(--success, #16a34a);
|
|
88
|
-
border-color: color-mix(in srgb, var(--success, #16a34a) 80%, black);
|
|
89
100
|
color: #fff;
|
|
90
101
|
}
|
|
91
102
|
.attention-button:hover,
|
|
@@ -93,10 +104,9 @@ export class Button extends LitElement {
|
|
|
93
104
|
background: color-mix(in srgb, var(--success, #16a34a) 88%, black);
|
|
94
105
|
}
|
|
95
106
|
|
|
96
|
-
/* DS .btn-danger */
|
|
107
|
+
/* DS .btn-danger — flat danger fill (no lift). */
|
|
97
108
|
.destructive-button {
|
|
98
109
|
background: var(--danger, #d03f3f);
|
|
99
|
-
border-color: color-mix(in srgb, var(--danger, #d03f3f) 80%, black);
|
|
100
110
|
color: #fff;
|
|
101
111
|
}
|
|
102
112
|
.destructive-button:hover {
|
|
@@ -136,7 +146,7 @@ export class Button extends LitElement {
|
|
|
136
146
|
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.12);
|
|
137
147
|
}
|
|
138
148
|
.secondary-button.active-button {
|
|
139
|
-
background:
|
|
149
|
+
background: rgba(0, 0, 0, 0.08);
|
|
140
150
|
}
|
|
141
151
|
|
|
142
152
|
/* disabled */
|
package/src/display/Thumbnail.ts
CHANGED
|
@@ -428,7 +428,7 @@ export class Thumbnail extends RapidElement {
|
|
|
428
428
|
alt="Location preview"
|
|
429
429
|
/>`
|
|
430
430
|
: html`<div
|
|
431
|
-
style="padding:1em; background:rgba(0,0,0,.05);border-radius:var(--curvature);"
|
|
431
|
+
style="padding:var(--thumb-icon-padding, 1em); background:rgba(0,0,0,.05);border-radius:var(--curvature);"
|
|
432
432
|
>
|
|
433
433
|
<temba-icon
|
|
434
434
|
size="1.5"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { html, TemplateResult } from 'lit';
|
|
2
2
|
import {
|
|
3
|
+
AirtimeCreatedEvent,
|
|
3
4
|
AirtimeTransferredEvent,
|
|
4
5
|
CallEvent,
|
|
5
6
|
ChatStartedEvent,
|
|
@@ -17,6 +18,7 @@ import { getLanguageName } from '../languages';
|
|
|
17
18
|
import { oxfordFn } from '../utils';
|
|
18
19
|
|
|
19
20
|
export enum Events {
|
|
21
|
+
AIRTIME_CREATED = 'airtime_created',
|
|
20
22
|
AIRTIME_TRANSFERRED = 'airtime_transferred',
|
|
21
23
|
BROADCAST_CREATED = 'broadcast_created',
|
|
22
24
|
CALL_CREATED = 'call_created',
|
|
@@ -370,6 +372,30 @@ export const renderAirtimeTransferredEvent = (
|
|
|
370
372
|
</div>`;
|
|
371
373
|
};
|
|
372
374
|
|
|
375
|
+
export const renderAirtimeCreatedEvent = (
|
|
376
|
+
event: AirtimeCreatedEvent
|
|
377
|
+
): TemplateResult => {
|
|
378
|
+
const status = event._status?.status ?? 'created';
|
|
379
|
+
const amount = html`${valueText(event.amount)} ${event.currency}`;
|
|
380
|
+
|
|
381
|
+
switch (status) {
|
|
382
|
+
case 'reversed':
|
|
383
|
+
return html`<div>Airtime transfer reversed</div>`;
|
|
384
|
+
case 'rejected':
|
|
385
|
+
case 'cancelled':
|
|
386
|
+
case 'declined':
|
|
387
|
+
return html`<div>Airtime transfer failed</div>`;
|
|
388
|
+
case 'completed':
|
|
389
|
+
return html`<div style=${eventLineStyle}>
|
|
390
|
+
Transferred ${amount} of airtime
|
|
391
|
+
</div>`;
|
|
392
|
+
default:
|
|
393
|
+
return html`<div style=${eventLineStyle}>
|
|
394
|
+
Sending ${amount} of airtime
|
|
395
|
+
</div>`;
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
|
|
373
399
|
export const renderContactLanguageChangedEvent = (
|
|
374
400
|
event: ContactLanguageChangedEvent
|
|
375
401
|
): TemplateResult => {
|
|
@@ -560,6 +586,9 @@ export const renderEvent = (
|
|
|
560
586
|
case Events.WARNING:
|
|
561
587
|
content = renderDiagnosticEvent(event, isSimulation);
|
|
562
588
|
break;
|
|
589
|
+
case Events.AIRTIME_CREATED:
|
|
590
|
+
content = renderAirtimeCreatedEvent(event as AirtimeCreatedEvent);
|
|
591
|
+
break;
|
|
563
592
|
case Events.AIRTIME_TRANSFERRED:
|
|
564
593
|
content = renderAirtimeTransferredEvent(event as AirtimeTransferredEvent);
|
|
565
594
|
break;
|
package/src/events.ts
CHANGED
|
@@ -90,6 +90,28 @@ export interface AirtimeTransferredEvent extends ContactEvent {
|
|
|
90
90
|
amount: string;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
export type AirtimeStatus =
|
|
94
|
+
| 'created'
|
|
95
|
+
| 'confirmed'
|
|
96
|
+
| 'rejected'
|
|
97
|
+
| 'cancelled'
|
|
98
|
+
| 'submitted'
|
|
99
|
+
| 'completed'
|
|
100
|
+
| 'reversed'
|
|
101
|
+
| 'declined';
|
|
102
|
+
|
|
103
|
+
export interface AirtimeCreatedEvent extends ContactEvent {
|
|
104
|
+
sender: string;
|
|
105
|
+
recipient: string;
|
|
106
|
+
currency: string;
|
|
107
|
+
amount: string;
|
|
108
|
+
external_id?: string;
|
|
109
|
+
_status?: {
|
|
110
|
+
created_on: string;
|
|
111
|
+
status: AirtimeStatus;
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
93
115
|
export type CallStartedEvent = ContactEvent;
|
|
94
116
|
|
|
95
117
|
export interface ContactHistoryPage {
|
package/src/interfaces.ts
CHANGED
|
@@ -36,7 +36,8 @@ export enum DateStyle {
|
|
|
36
36
|
export enum ScheduledEventType {
|
|
37
37
|
CampaignEvent = 'campaign_event',
|
|
38
38
|
ScheduledBroadcast = 'scheduled_broadcast',
|
|
39
|
-
ScheduledTrigger = 'scheduled_trigger'
|
|
39
|
+
ScheduledTrigger = 'scheduled_trigger',
|
|
40
|
+
SentBroadcast = 'sent_broadcast'
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
export enum TicketStatus {
|
|
@@ -100,15 +101,53 @@ export interface FlowDetails {
|
|
|
100
101
|
};
|
|
101
102
|
}
|
|
102
103
|
|
|
104
|
+
/** A single row in the flow CRUDL list (`flows/flow_list.html`).
|
|
105
|
+
* Distinct from {@link FlowDetails}, which is the per-flow editor
|
|
106
|
+
* payload — this is the lighter list-row shape. */
|
|
107
|
+
export interface Flow {
|
|
108
|
+
uuid: string;
|
|
109
|
+
name: string;
|
|
110
|
+
/** Flow type — drives the leading row icon. */
|
|
111
|
+
type: 'messaging' | 'voice' | 'ivr' | 'background' | 'survey' | string;
|
|
112
|
+
runs: number;
|
|
113
|
+
ongoing: number;
|
|
114
|
+
/** Completion ratio in the range 0–1. */
|
|
115
|
+
completion: number;
|
|
116
|
+
has_issues: boolean;
|
|
117
|
+
status: 'active' | 'archived' | string;
|
|
118
|
+
labels: ObjectReference[];
|
|
119
|
+
/** Recent run counts, oldest to newest — rendered as a sparkline. */
|
|
120
|
+
activity: number[];
|
|
121
|
+
}
|
|
122
|
+
|
|
103
123
|
export interface Msg {
|
|
124
|
+
/** Numeric id — present on persisted messages (the CRUDL list
|
|
125
|
+
* keys rows off it); absent on outbound drafts. */
|
|
126
|
+
id?: number;
|
|
104
127
|
text: string;
|
|
105
128
|
status: string;
|
|
106
129
|
channel: ObjectReference;
|
|
107
130
|
quick_replies: string[];
|
|
108
131
|
urn: string;
|
|
132
|
+
/** The message's contact — populated by the messages CRUDL
|
|
133
|
+
* endpoint. Carries whichever of name/urn the endpoint exposes. */
|
|
134
|
+
contact?: { uuid?: string; name?: string; urn?: string };
|
|
109
135
|
direction: string;
|
|
110
136
|
type: string;
|
|
137
|
+
/** Message type as exposed by the messages CRUDL endpoint
|
|
138
|
+
* (`text` / `optin` / …); mirrors `type` for that surface. */
|
|
139
|
+
msg_type?: string;
|
|
111
140
|
attachments: string[];
|
|
141
|
+
/** Labels applied to the message. */
|
|
142
|
+
labels?: ObjectReference[];
|
|
143
|
+
/** The flow the message was sent from, when there is one. */
|
|
144
|
+
flow?: ObjectReference;
|
|
145
|
+
/** When the message was created. */
|
|
146
|
+
created_on?: string;
|
|
147
|
+
/** Permission- and retention-gated URL to this message's channel
|
|
148
|
+
* log. Present on the CRUDL list when the viewer can read logs and
|
|
149
|
+
* the message is within the retention window; absent otherwise. */
|
|
150
|
+
logs_url?: string;
|
|
112
151
|
unsendable_reason?:
|
|
113
152
|
| 'no_route'
|
|
114
153
|
| 'contact_blocked'
|
|
@@ -326,5 +365,10 @@ export enum CustomEventType {
|
|
|
326
365
|
RevisionsClosed = 'temba-revisions-closed',
|
|
327
366
|
RowClick = 'temba-row-click',
|
|
328
367
|
SelectionChange = 'temba-selection-change',
|
|
329
|
-
BulkAction = 'temba-bulk-action'
|
|
368
|
+
BulkAction = 'temba-bulk-action',
|
|
369
|
+
/** A restorable piece of component state changed (page, sort, …).
|
|
370
|
+
* Fired by lists so the host SPA can persist the state into the
|
|
371
|
+
* browser's history entry (typically via `history.replaceState`).
|
|
372
|
+
* Detail: `{ key: string, state: object }`. */
|
|
373
|
+
HistoryChange = 'temba-history-change'
|
|
330
374
|
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { css, html, PropertyValues, TemplateResult } from 'lit';
|
|
2
|
+
import { property, state } from 'lit/decorators.js';
|
|
3
|
+
import { RapidElement } from '../RapidElement';
|
|
4
|
+
import { CustomEventType } from '../interfaces';
|
|
5
|
+
import { getUrl } from '../utils';
|
|
6
|
+
import { designTokens } from '../styles/designTokens';
|
|
7
|
+
import { ContentMenuItem, ContentMenuItemType } from '../list/ContentMenu';
|
|
8
|
+
|
|
9
|
+
/** Request headers that ask the server for the page's content menu
|
|
10
|
+
* payload (`{ items: ContentMenuItem[] }`) — the rapidpro contract
|
|
11
|
+
* the standalone `temba-content-menu` also speaks. */
|
|
12
|
+
const MENU_HEADERS = {
|
|
13
|
+
'X-Temba-Content-Menu': '1',
|
|
14
|
+
'X-Temba-Spa': '1'
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Page header bar — a title (and optional subtitle) on the left and
|
|
19
|
+
* the page's content menu (action buttons + an overflow `⋮`) on the
|
|
20
|
+
* right, styled from the design tokens.
|
|
21
|
+
*
|
|
22
|
+
* Reusable on its own for any page that has a content menu, and
|
|
23
|
+
* embedded by {@link ContentList} as the list's header so a list
|
|
24
|
+
* page and a plain page share one header treatment. The menu is
|
|
25
|
+
* fetched from {@link contentMenuEndpoint}; clicking an item fires
|
|
26
|
+
* `temba-selection` (with the item + click origin) for the host to
|
|
27
|
+
* act on, mirroring `temba-content-menu`.
|
|
28
|
+
*
|
|
29
|
+
* The `actions` slot renders between the title and the content menu
|
|
30
|
+
* — list components slot their search / bulk-action controls there.
|
|
31
|
+
*/
|
|
32
|
+
export class PageHeader extends RapidElement {
|
|
33
|
+
static get styles() {
|
|
34
|
+
return css`
|
|
35
|
+
${designTokens}
|
|
36
|
+
|
|
37
|
+
:host {
|
|
38
|
+
display: block;
|
|
39
|
+
font-family: var(--font);
|
|
40
|
+
/* Match the styleguide's .ds Inter rendering — the stylistic
|
|
41
|
+
sets (ss01/cv11) and the "tnum 0" off-switch — so glyph
|
|
42
|
+
shapes line up with the design-system buttons. */
|
|
43
|
+
font-feature-settings:
|
|
44
|
+
'ss01',
|
|
45
|
+
'cv11',
|
|
46
|
+
'tnum' 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/* Title on the left, actions + content menu on the right. */
|
|
50
|
+
.header {
|
|
51
|
+
display: flex;
|
|
52
|
+
align-items: flex-start;
|
|
53
|
+
gap: var(--gap);
|
|
54
|
+
padding: 20px 0 16px 0;
|
|
55
|
+
}
|
|
56
|
+
.titles {
|
|
57
|
+
flex: 1 1 auto;
|
|
58
|
+
min-width: 0;
|
|
59
|
+
}
|
|
60
|
+
.title {
|
|
61
|
+
font-size: 15.5px;
|
|
62
|
+
font-weight: var(--w-semibold);
|
|
63
|
+
color: var(--text-1);
|
|
64
|
+
line-height: 1.3;
|
|
65
|
+
}
|
|
66
|
+
.subtitle {
|
|
67
|
+
font-size: 12.5px;
|
|
68
|
+
color: var(--text-3);
|
|
69
|
+
line-height: 1.3;
|
|
70
|
+
margin-top: 1px;
|
|
71
|
+
}
|
|
72
|
+
.actions {
|
|
73
|
+
flex: 0 0 auto;
|
|
74
|
+
display: flex;
|
|
75
|
+
align-items: center;
|
|
76
|
+
gap: 10px;
|
|
77
|
+
color: var(--text-2);
|
|
78
|
+
font-size: 13px;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* Content-menu action buttons — match the design system's
|
|
82
|
+
.btn.btn-sm / .btn-primary / .btn-secondary so the inbox CTA
|
|
83
|
+
reads identically to the styleguide. 28px tall, regular
|
|
84
|
+
weight, 12.5px font, the same transitions and faint top
|
|
85
|
+
highlight + ground-shadow on the primary fill. */
|
|
86
|
+
.menu-button {
|
|
87
|
+
display: inline-flex;
|
|
88
|
+
align-items: center;
|
|
89
|
+
gap: 6px;
|
|
90
|
+
/* border-box so height: 28px includes the 1px border the
|
|
91
|
+
same way .ds * does in the styleguide — otherwise the
|
|
92
|
+
border adds 2px and the button computes 2px taller. */
|
|
93
|
+
box-sizing: border-box;
|
|
94
|
+
height: 28px;
|
|
95
|
+
padding: 0 10px;
|
|
96
|
+
border: 1px solid var(--border-strong);
|
|
97
|
+
border-radius: var(--r-sm);
|
|
98
|
+
font-size: 12.5px;
|
|
99
|
+
font-weight: var(--w-regular);
|
|
100
|
+
letter-spacing: -0.005em;
|
|
101
|
+
cursor: pointer;
|
|
102
|
+
user-select: none;
|
|
103
|
+
background: var(--surface);
|
|
104
|
+
color: var(--text-1);
|
|
105
|
+
white-space: nowrap;
|
|
106
|
+
transition:
|
|
107
|
+
background 120ms,
|
|
108
|
+
border-color 120ms,
|
|
109
|
+
color 120ms,
|
|
110
|
+
box-shadow 120ms;
|
|
111
|
+
}
|
|
112
|
+
.menu-button:hover {
|
|
113
|
+
background: var(--sunken);
|
|
114
|
+
}
|
|
115
|
+
.menu-button.primary {
|
|
116
|
+
background: var(--accent-600);
|
|
117
|
+
border-color: transparent;
|
|
118
|
+
color: white;
|
|
119
|
+
box-shadow:
|
|
120
|
+
inset 0 1px 0 rgba(255, 255, 255, 0.18),
|
|
121
|
+
0 1px 1px rgba(15, 22, 36, 0.1);
|
|
122
|
+
}
|
|
123
|
+
.menu-button.primary:hover {
|
|
124
|
+
background: var(--accent-700);
|
|
125
|
+
}
|
|
126
|
+
.menu-button[disabled] {
|
|
127
|
+
opacity: 0.45;
|
|
128
|
+
cursor: not-allowed;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/* Overflow toggle — the trailing ⋮ that opens the rest of the
|
|
132
|
+
menu items. Plain icon button, matches the list's Search. */
|
|
133
|
+
.menu-toggle {
|
|
134
|
+
display: inline-flex;
|
|
135
|
+
align-items: center;
|
|
136
|
+
cursor: pointer;
|
|
137
|
+
user-select: none;
|
|
138
|
+
padding: 6px;
|
|
139
|
+
border-radius: var(--r-sm);
|
|
140
|
+
color: var(--text-2);
|
|
141
|
+
--icon-color: currentColor;
|
|
142
|
+
}
|
|
143
|
+
.menu-toggle:hover {
|
|
144
|
+
background: var(--sunken);
|
|
145
|
+
color: var(--text-1);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/* Overflow dropdown — a column of menu items + dividers. */
|
|
149
|
+
.menu-list {
|
|
150
|
+
min-width: 200px;
|
|
151
|
+
padding: 6px 4px;
|
|
152
|
+
font-size: 13px;
|
|
153
|
+
}
|
|
154
|
+
.menu-item {
|
|
155
|
+
padding: 6px 12px;
|
|
156
|
+
border-radius: var(--r-sm);
|
|
157
|
+
cursor: pointer;
|
|
158
|
+
color: var(--text-1);
|
|
159
|
+
white-space: nowrap;
|
|
160
|
+
}
|
|
161
|
+
.menu-item:hover {
|
|
162
|
+
background: var(--accent-50);
|
|
163
|
+
color: var(--accent-800);
|
|
164
|
+
}
|
|
165
|
+
.menu-divider {
|
|
166
|
+
height: 1px;
|
|
167
|
+
background: var(--border);
|
|
168
|
+
margin: 6px 8px;
|
|
169
|
+
}
|
|
170
|
+
`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Header title. A `title` slot overrides it for rich content. */
|
|
174
|
+
@property({ type: String, attribute: 'header-title' })
|
|
175
|
+
headerTitle = '';
|
|
176
|
+
|
|
177
|
+
/** Smaller subtitle under the title. A `subtitle` slot overrides. */
|
|
178
|
+
@property({ type: String })
|
|
179
|
+
subtitle = '';
|
|
180
|
+
|
|
181
|
+
/** GET endpoint for the page's content menu. The response shape is
|
|
182
|
+
* `{ items: ContentMenuItem[] }` (rapidpro's content-menu view). */
|
|
183
|
+
@property({ type: String, attribute: 'content-menu-endpoint' })
|
|
184
|
+
contentMenuEndpoint = '';
|
|
185
|
+
|
|
186
|
+
/** When true, the content menu (buttons + overflow) is not drawn —
|
|
187
|
+
* the host sets this while it's in a selection / bulk-action mode
|
|
188
|
+
* where the page menu isn't relevant. The menu data is kept, so
|
|
189
|
+
* clearing the flag restores the menu without a re-fetch. */
|
|
190
|
+
@property({ type: Boolean, attribute: 'hide-menu' })
|
|
191
|
+
hideMenu = false;
|
|
192
|
+
|
|
193
|
+
/** Buttons split out of the fetched menu (`as_button` items). */
|
|
194
|
+
@state()
|
|
195
|
+
private buttons: ContentMenuItem[] = [];
|
|
196
|
+
|
|
197
|
+
/** Remaining menu items, shown under the overflow `⋮`. */
|
|
198
|
+
@state()
|
|
199
|
+
private items: ContentMenuItem[] = [];
|
|
200
|
+
|
|
201
|
+
private pendingMenu: AbortController = null;
|
|
202
|
+
|
|
203
|
+
public disconnectedCallback(): void {
|
|
204
|
+
if (this.pendingMenu) {
|
|
205
|
+
this.pendingMenu.abort();
|
|
206
|
+
}
|
|
207
|
+
super.disconnectedCallback();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
protected updated(changes: PropertyValues): void {
|
|
211
|
+
super.updated(changes);
|
|
212
|
+
if (changes.has('contentMenuEndpoint')) {
|
|
213
|
+
this.fetchContentMenu();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Public API — re-pull the content menu (e.g. after an action
|
|
218
|
+
* changes which items are available). */
|
|
219
|
+
public refresh(): void {
|
|
220
|
+
this.fetchContentMenu();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private async fetchContentMenu(): Promise<void> {
|
|
224
|
+
if (!this.contentMenuEndpoint) {
|
|
225
|
+
this.buttons = [];
|
|
226
|
+
this.items = [];
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (this.pendingMenu) this.pendingMenu.abort();
|
|
230
|
+
const controller = new AbortController();
|
|
231
|
+
this.pendingMenu = controller;
|
|
232
|
+
try {
|
|
233
|
+
const response = await getUrl(
|
|
234
|
+
this.contentMenuEndpoint,
|
|
235
|
+
controller,
|
|
236
|
+
MENU_HEADERS
|
|
237
|
+
);
|
|
238
|
+
// Staleness guard — if another fetch superseded this one
|
|
239
|
+
// between the request firing and the response arriving, drop
|
|
240
|
+
// the result so the newer fetch wins (mirrors the pattern in
|
|
241
|
+
// ContactList.loadFields).
|
|
242
|
+
if (this.pendingMenu !== controller) return;
|
|
243
|
+
const menu = (response.json?.items as ContentMenuItem[]) || [];
|
|
244
|
+
this.buttons = menu.filter((item) => item.as_button);
|
|
245
|
+
this.items = menu.filter((item) => !item.as_button);
|
|
246
|
+
this.fireCustomEvent(CustomEventType.Loaded, {
|
|
247
|
+
buttons: this.buttons,
|
|
248
|
+
items: this.items
|
|
249
|
+
});
|
|
250
|
+
} catch (err) {
|
|
251
|
+
if ((err as DOMException)?.name !== 'AbortError') {
|
|
252
|
+
// eslint-disable-next-line no-console
|
|
253
|
+
console.error('content menu fetch failed', err);
|
|
254
|
+
}
|
|
255
|
+
} finally {
|
|
256
|
+
if (this.pendingMenu === controller) {
|
|
257
|
+
this.pendingMenu = null;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Fire `temba-selection` with the clicked item and the click
|
|
263
|
+
* origin — same payload the standalone content menu emits, so the
|
|
264
|
+
* host's existing menu handling keeps working. */
|
|
265
|
+
private handleItemClicked(item: ContentMenuItem, event: MouseEvent): void {
|
|
266
|
+
if (item.disabled) return;
|
|
267
|
+
const el = event.currentTarget as Element;
|
|
268
|
+
const rect = el?.getBoundingClientRect();
|
|
269
|
+
this.fireCustomEvent(CustomEventType.Selection, {
|
|
270
|
+
item,
|
|
271
|
+
event,
|
|
272
|
+
originX: rect ? rect.left + rect.width / 2 : event.clientX,
|
|
273
|
+
originY: rect ? rect.top : event.clientY
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private renderContentMenu(): TemplateResult {
|
|
278
|
+
if (this.hideMenu) return html``;
|
|
279
|
+
return html`
|
|
280
|
+
${this.buttons.map(
|
|
281
|
+
(button) => html`
|
|
282
|
+
<div
|
|
283
|
+
class="menu-button ${button.primary ? 'primary' : ''}"
|
|
284
|
+
?disabled=${button.disabled}
|
|
285
|
+
@click=${(e: MouseEvent) => this.handleItemClicked(button, e)}
|
|
286
|
+
>
|
|
287
|
+
${button.label}
|
|
288
|
+
</div>
|
|
289
|
+
`
|
|
290
|
+
)}
|
|
291
|
+
${this.items.length > 0
|
|
292
|
+
? html`
|
|
293
|
+
<temba-dropdown>
|
|
294
|
+
<div slot="toggle" class="menu-toggle">
|
|
295
|
+
<temba-icon name="menu" size="1.2"></temba-icon>
|
|
296
|
+
</div>
|
|
297
|
+
<div slot="dropdown" class="menu-list">
|
|
298
|
+
${this.items.map((item) =>
|
|
299
|
+
item.type === ContentMenuItemType.DIVIDER
|
|
300
|
+
? html`<div class="menu-divider"></div>`
|
|
301
|
+
: html`<div
|
|
302
|
+
class="menu-item"
|
|
303
|
+
@click=${(e: MouseEvent) =>
|
|
304
|
+
this.handleItemClicked(item, e)}
|
|
305
|
+
>
|
|
306
|
+
${item.label}
|
|
307
|
+
</div>`
|
|
308
|
+
)}
|
|
309
|
+
</div>
|
|
310
|
+
</temba-dropdown>
|
|
311
|
+
`
|
|
312
|
+
: null}
|
|
313
|
+
`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
public render(): TemplateResult {
|
|
317
|
+
const hasSubtitle =
|
|
318
|
+
this.subtitle || this.querySelector('[slot="subtitle"]');
|
|
319
|
+
return html`
|
|
320
|
+
<div class="header">
|
|
321
|
+
<div class="titles">
|
|
322
|
+
<div class="title">
|
|
323
|
+
<slot name="title">${this.headerTitle}</slot>
|
|
324
|
+
</div>
|
|
325
|
+
${hasSubtitle
|
|
326
|
+
? html`<div class="subtitle">
|
|
327
|
+
<slot name="subtitle">${this.subtitle}</slot>
|
|
328
|
+
</div>`
|
|
329
|
+
: null}
|
|
330
|
+
</div>
|
|
331
|
+
<div class="actions">
|
|
332
|
+
<slot name="actions"></slot>
|
|
333
|
+
${this.renderContentMenu()}
|
|
334
|
+
</div>
|
|
335
|
+
</div>
|
|
336
|
+
`;
|
|
337
|
+
}
|
|
338
|
+
}
|