@kubex/zinc 1.1.2 → 1.1.4

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.
@@ -18,10 +18,10 @@ layout: component
18
18
 
19
19
  ### Basic Message
20
20
 
21
- The avatar initials and colour are derived from `sender`. `time` is a unix timestamp, displayed as a localised HH:MM.
21
+ The avatar initials and color are derived from `sender`. `time` is a unix timestamp, displayed as a localised HH:MM.
22
22
 
23
23
  ```html:preview
24
- <zn-chat-message sender="Alan Jones" time="1718193420">
24
+ <zn-chat-message sender="John Smith" time="1718193420">
25
25
  Thanks for getting in touch — I'll take a look at your account now.
26
26
  </zn-chat-message>
27
27
  ```
@@ -60,3 +60,57 @@ The bubble background can be themed with the `--message-background` custom prope
60
60
  This one uses an info-tinted bubble instead of the default cream.
61
61
  </zn-chat-message>
62
62
  ```
63
+
64
+ ### Message Attribute
65
+
66
+ Instead of slotting content, pass an HTML string via the `message` attribute. It is sanitized
67
+ (scripts and event handlers stripped), newlines become line breaks and bare URLs become links.
68
+
69
+ ```html:preview
70
+ <zn-chat-message sender="Agent Name" time="1718193420"
71
+ message="Here's the link to your invoice: https://example.com/invoice/123 — let me know if you have any questions."></zn-chat-message>
72
+ ```
73
+
74
+ ### Conversation Sides
75
+
76
+ `agent-initiated` and `customer-initiated` mark which side sent the message. Consecutive messages from
77
+ the same side within a minute are grouped (the avatar and header are hidden on the follow-ups).
78
+
79
+ ```html:preview
80
+ <zn-chat-message sender="John Smith" time="1718193420" agent-initiated
81
+ message="Hi! How can I help you today?"></zn-chat-message>
82
+ <zn-chat-message sender="Customer" time="1718193440" customer-initiated
83
+ message="I was charged twice for my subscription."></zn-chat-message>
84
+ <zn-chat-message sender="Customer" time="1718193450" customer-initiated
85
+ message="Can you take a look?"></zn-chat-message>
86
+ ```
87
+
88
+ ### Internal Note
89
+
90
+ `action-type="internal"` tints the bubble and adds an INTERNAL badge to the header.
91
+
92
+ ```html:preview
93
+ <zn-chat-message sender="Agent Name" time="1718193420" agent-initiated action-type="internal"
94
+ message="Customer has been refunded — keep an eye out for a follow-up email."></zn-chat-message>
95
+ ```
96
+
97
+ ### Sending State
98
+
99
+ `action-type="message-sending"` shows the bubble as a pending message with a "Sending..." indicator.
100
+
101
+ ```html:preview
102
+ <zn-chat-message sender="Agent Name" agent-initiated action-type="message-sending"
103
+ message="This message hasn't been delivered yet."></zn-chat-message>
104
+ ```
105
+
106
+ ### System Events
107
+
108
+ System action types render as a centred card instead of a message bubble. A default label is shown
109
+ when no `message` is provided. Supported types: `connected.agent`, `customer.connected`,
110
+ `customer.disconnected`, `transfer`, `attachment.added`, `ended`, `customer.ended`.
111
+
112
+ ```html:preview
113
+ <zn-chat-message action-type="customer.connected" time="1718193420"></zn-chat-message>
114
+ <zn-chat-message action-type="transfer" time="1718193460" message="Transferred to Billing"></zn-chat-message>
115
+ <zn-chat-message action-type="ended" time="1718193500"></zn-chat-message>
116
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubex/zinc",
3
- "version": "1.1.2",
3
+ "version": "1.1.4",
4
4
  "description": "A collection of web components for building web applications based off of @shoelace-style/Shoelace",
5
5
  "keywords": [
6
6
  "web components",
@@ -28,6 +28,7 @@ export default class ZnAlert extends ZincElement {
28
28
  @property({type: String}) icon: string = '';
29
29
  @property({type: String}) caption: string = '';
30
30
  @property({type: Boolean}) collapse: boolean = false;
31
+ @property({type: String, reflect: true}) appearance: 'transparent' | 'solid' = 'transparent';
31
32
  @property({type: String}) level: 'primary' | 'error' | 'info' | 'success' | 'warning' | 'note' | 'cosmic' = 'info';
32
33
  @property({type: String}) size: 'small' | 'medium' | 'large' = 'medium';
33
34
 
@@ -12,8 +12,16 @@
12
12
  background-color: rgba(var(--zn-panel), 0.8);
13
13
  }
14
14
 
15
+ :host([appearance="solid"]) {
16
+ background-color: rgb(var(--zn-panel));
17
+
18
+ .wrapper:before {
19
+ backdrop-filter: none;
20
+ }
21
+ }
22
+
15
23
  .alert-icon {
16
- padding: var(--zn-spacing-2x-small) var(--zn-spacing-x-small) 0;
24
+ padding: var(--zn-spacing-2x-small) var(--zn-spacing-x-small);
17
25
  }
18
26
 
19
27
  p {
@@ -1,20 +1,27 @@
1
- import {type CSSResultGroup, html, unsafeCSS} from 'lit';
1
+ import {cleanHTML} from "./clean-message";
2
+ import {type CSSResultGroup, html, nothing, unsafeCSS} from 'lit';
2
3
  import {property} from "lit/decorators.js";
4
+ import {unsafeHTML} from "lit/directives/unsafe-html.js";
3
5
  import ZincElement from '../../internal/zinc-element';
4
6
  import ZnIcon from "../icon";
5
7
 
6
8
  import styles from './chat-message.scss';
7
9
 
10
+ export type ChatMessageActionType = '' | 'connected.agent' | 'attachment.added' | 'multi.answer' | 'transfer' |
11
+ 'ended' | 'error' | 'message-sending' | 'customer.ended' | 'customer.connected' | 'customer.disconnected' |
12
+ 'internal';
13
+
8
14
  /**
9
15
  * @summary A single message in a chat-style conversation: avatar, sender,
10
- * time, optional badge, and a message bubble with optional actions.
16
+ * time, optional badge, and a message bubble with optional actions. Also renders
17
+ * system events (connections, transfers, etc.) as a centred card.
11
18
  * @documentation https://zinc.style/components/chat-message
12
19
  * @status experimental
13
20
  * @since 1.0
14
21
  *
15
22
  * @dependency zn-icon
16
23
  *
17
- * @slot - The message content.
24
+ * @slot - The message content. Ignored when the `message` attribute is set.
18
25
  * @slot badge - Rendered in the header after the sender and time (e.g. an INTERNAL NOTE chip).
19
26
  * @slot edit-dialog-trigger - Action rendered at the end of the bubble (e.g. a remove icon button).
20
27
  * @slot edit-dialog - Pass-through for an associated dialog element.
@@ -25,6 +32,7 @@ import styles from './chat-message.scss';
25
32
  * @csspart header - The sender/time/badge row.
26
33
  * @csspart bubble - The message bubble.
27
34
  * @csspart content - The message content within the bubble.
35
+ * @csspart system-card - The card rendered for system action types.
28
36
  *
29
37
  * @cssproperty --message-background - The bubble's background colour.
30
38
  */
@@ -34,40 +42,166 @@ export default class ZnChatMessage extends ZincElement {
34
42
  'zn-icon': ZnIcon
35
43
  };
36
44
 
37
- /** The sender's name, also used for the avatar initials. */
45
+ static readonly SYSTEM_ACTION_TYPES: readonly string[] = [
46
+ 'connected.agent', 'attachment.added', 'transfer', 'ended',
47
+ 'customer.ended', 'customer.connected', 'customer.disconnected',
48
+ ];
49
+
50
+ private static readonly DISPLAYED_ACTION_TYPES: readonly string[] = [
51
+ '', 'connected.agent', 'attachment.added', 'multi.answer', 'transfer', 'ended', 'error',
52
+ 'customer.ended', 'message-sending', 'customer.connected', 'customer.disconnected', 'internal',
53
+ ];
54
+
55
+ /** The sender's name, also used for the avatar. */
38
56
  @property() sender: string = '';
39
57
 
40
- /** Unix timestamp (seconds) of the message, shown as HH:MM. */
58
+ /**
59
+ * The message body as an HTML string. When set, it is sanitized (scripts and event
60
+ * handlers stripped), newlines become line breaks and bare URLs become links. When
61
+ * omitted the default slot is rendered instead.
62
+ */
63
+ @property() message: string = '';
64
+
65
+ /** Unix timestamp (seconds) of the message, shown as HH:MM (with date if not today). */
41
66
  @property() time: string = '';
42
67
 
43
- private getTime(time: string) {
44
- const date = new Date(parseInt(time, 10) * 1000);
45
- return date.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'});
68
+ /** Overrides the avatar source. Defaults to `sender`. */
69
+ @property() avatar: string = '';
70
+
71
+ /** The kind of message. Drives system-card rendering, the sending state and badges. */
72
+ @property({attribute: 'action-type', reflect: true}) actionType: ChatMessageActionType = '';
73
+
74
+ /** Marks the message as initiated by the customer (affects styling and grouping). */
75
+ @property({type: Boolean, reflect: true, attribute: 'customer-initiated'}) customerInitiated = false;
76
+
77
+ /** Marks the message as initiated by an agent (affects styling and grouping). */
78
+ @property({type: Boolean, reflect: true, attribute: 'agent-initiated'}) agentInitiated = false;
79
+
80
+ connectedCallback() {
81
+ const isContent = !this.actionType || this.actionType === 'internal';
82
+ const agentInitiated = isContent && this.agentInitiated;
83
+ const customerInitiated = isContent && this.customerInitiated;
84
+
85
+ const previous = this.previousElementSibling as ZnChatMessage;
86
+ const inTime = parseInt(this.time) - parseInt(previous?.time);
87
+ const previousActionType = previous?.getAttribute('action-type') ?? '';
88
+ const previousIsSystem = ZnChatMessage.SYSTEM_ACTION_TYPES.includes(previousActionType);
89
+ const previousIsInternal = previousActionType === 'internal';
90
+ const thisIsInternal = this.actionType === 'internal';
91
+
92
+ if (previous && !previousIsSystem && inTime < 60 && previousIsInternal === thisIsInternal &&
93
+ ((previous?.hasAttribute('agent-initiated') && agentInitiated) ||
94
+ (previous?.hasAttribute('customer-initiated') && customerInitiated))) {
95
+ this.classList.add('message-continued');
96
+ }
97
+
98
+ super.connectedCallback();
46
99
  }
47
100
 
48
101
  protected render() {
102
+ if (!ZnChatMessage.DISPLAYED_ACTION_TYPES.includes(this.actionType)) {
103
+ return nothing;
104
+ }
105
+
106
+ if (this.isSystemMessage()) {
107
+ return this.renderSystemCard();
108
+ }
109
+
110
+ const sending = this.actionType === 'message-sending';
111
+
49
112
  return html`
50
113
  <div part="base" class="message">
51
114
  <div part="avatar" class="message__avatar">
52
- <zn-icon library="avatar" src="${this.sender}" size="36" round></zn-icon>
115
+ <zn-icon library="avatar" src="${this.avatar || this.sender}" size="36" round></zn-icon>
53
116
  </div>
54
117
  <div part="body" class="message__body">
55
- <div part="header" class="message__header">
56
- <span class="message__sender">${this.sender}</span>
57
- ${this.time ? html`<span class="message__time">${this.getTime(this.time)}</span>` : null}
58
- <slot name="badge"></slot>
59
- </div>
60
- <div part="bubble" class="message__bubble">
61
- <div part="content" class="message__content">
62
- <slot></slot>
63
- </div>
64
- <div class="message__actions">
65
- <slot name="edit-dialog-trigger"></slot>
66
- </div>
118
+ ${sending ? null : this.renderHeader()}
119
+ <div part="bubble" class="message__bubble ${sending ? 'message__bubble--sending' : ''}">
120
+ <div part="content" class="message__content">${this.renderContent()}</div>
121
+ ${sending ? html`<div class="message__meta">Sending...</div>` : html`
122
+ <div class="message__actions">
123
+ <slot name="edit-dialog-trigger"></slot>
124
+ </div>`}
67
125
  </div>
68
126
  </div>
69
127
  </div>
70
128
  <slot name="edit-dialog"></slot>
71
129
  `;
72
130
  }
131
+
132
+ private isSystemMessage() {
133
+ return ZnChatMessage.SYSTEM_ACTION_TYPES.includes(this.actionType);
134
+ }
135
+
136
+ private renderSystemCard() {
137
+ return html`
138
+ <div part="base" class="message message--system">
139
+ <div part="avatar" class="message__avatar"></div>
140
+ <div part="body" class="message__body">
141
+ <div part="system-card" class="system-card">
142
+ <span class="system-card__text">${this.message || this.systemLabel()}</span>
143
+ ${this.time ? html`<span class="system-card__time">${this.getTime()}</span>` : nothing}
144
+ </div>
145
+ </div>
146
+ </div>`;
147
+ }
148
+
149
+ private systemLabel() {
150
+ switch (this.actionType) {
151
+ case 'customer.connected':
152
+ return 'Customer connected';
153
+ case 'customer.disconnected':
154
+ return 'Customer disconnected';
155
+ case 'connected.agent':
156
+ return 'Agent connected';
157
+ case 'transfer':
158
+ return 'Transferred';
159
+ case 'attachment.added':
160
+ return 'Attachment added';
161
+ case 'ended':
162
+ return 'Ended';
163
+ case 'customer.ended':
164
+ return 'Customer ended';
165
+ default:
166
+ return '';
167
+ }
168
+ }
169
+
170
+ private renderHeader() {
171
+ return html`
172
+ <div part="header" class="message__header">
173
+ <span class="message__sender">${this.sender || (this.customerInitiated ? 'Customer' : 'You')}</span>
174
+ ${this.time ? html`<span class="message__time">${this.getSentTime()}</span>` : nothing}
175
+ ${this.actionType === 'internal' ? html`<span class="message__badge">INTERNAL</span>` : nothing}
176
+ <slot name="badge"></slot>
177
+ </div>`;
178
+ }
179
+
180
+ private renderContent() {
181
+ if (!this.message) {
182
+ return html`<slot></slot>`;
183
+ }
184
+
185
+ let content = cleanHTML(this.message);
186
+ content = content.replace(/\r\n|\r|\n/g, '<br>');
187
+ content = content.replace(/\b(((https?|ftp|dict):\/\/|www\.)[^'">\s]+\.[^'">\s]+)(?=\s|$)(?!["<>])/g,
188
+ '<a href="$1" target="_blank">$1</a>');
189
+ return unsafeHTML(content);
190
+ }
191
+
192
+ /** HH:MM only — used on the system card. */
193
+ private getTime() {
194
+ const date = new Date(parseInt(this.time, 10) * 1000);
195
+ return date.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'});
196
+ }
197
+
198
+ /** HH:MM, prefixed with the date when the message is not from today. */
199
+ private getSentTime() {
200
+ const time = new Date(parseInt(this.time, 10) * 1000);
201
+ const hm = time.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'});
202
+ if (time.getDate() !== (new Date()).getDate()) {
203
+ return `${time.toLocaleDateString()} ${hm}`;
204
+ }
205
+ return hm;
206
+ }
73
207
  }
@@ -7,9 +7,12 @@
7
7
  .message {
8
8
  display: flex;
9
9
  gap: var(--zn-spacing-small);
10
+ align-items: flex-start;
10
11
 
11
12
  &__avatar {
12
- flex-shrink: 0;
13
+ flex: 0 0 36px;
14
+ display: flex;
15
+ justify-content: center;
13
16
  }
14
17
 
15
18
  &__body {
@@ -35,13 +38,33 @@
35
38
  @include wc.text-style(subheading-small);
36
39
  }
37
40
 
41
+ &__badge {
42
+ font-size: 10px;
43
+ font-weight: 600;
44
+ letter-spacing: 0.5px;
45
+ padding: 2px 6px;
46
+ border-radius: var(--zn-border-radius);
47
+ border: 1px solid rgba(var(--zn-color-info), 0.4);
48
+ color: rgb(var(--zn-color-info));
49
+ background: rgba(var(--zn-color-info), 0.08);
50
+ text-transform: uppercase;
51
+ }
52
+
38
53
  &__bubble {
54
+ box-sizing: border-box;
39
55
  display: flex;
40
56
  align-items: flex-start;
41
57
  gap: var(--zn-spacing-small);
42
- padding: var(--zn-spacing-small);
58
+ padding: 11px var(--zn-spacing-small); // 11px top/bottom + 24px line = 46px base
43
59
  border-radius: var(--zn-border-radius);
44
60
  background: var(--message-background, hsl(48, 78%, 94%));
61
+ min-height: 46px; // base (padding included); grows only when text wraps
62
+
63
+ &--sending {
64
+ border: 1px dashed rgb(var(--zn-border-color));
65
+ opacity: 0.7;
66
+ flex-direction: column;
67
+ }
45
68
  }
46
69
 
47
70
  &__content {
@@ -50,6 +73,74 @@
50
73
  flex: 1 1 auto;
51
74
  min-width: 0;
52
75
  overflow-wrap: anywhere;
76
+
77
+ p {
78
+ margin: 0;
79
+ padding: 0;
80
+ }
81
+
82
+ pre {
83
+ background-color: #23241f;
84
+ border-radius: 3px;
85
+ margin-block: 5px;
86
+ padding: 5px 10px;
87
+ color: #f8f8f2;
88
+ overflow: auto;
89
+ }
90
+
91
+ blockquote {
92
+ border-left: 4px solid rgb(var(--zn-border-color));
93
+ margin: 5px 0;
94
+ padding-left: 16px;
95
+ }
96
+
97
+ ol, ul {
98
+ padding: 0;
99
+ margin: 0;
100
+ }
101
+
102
+ li {
103
+ list-style-type: none;
104
+ padding-left: 1em;
105
+ position: relative;
106
+ }
107
+
108
+ ol {
109
+ counter-reset: list-0 0;
110
+ }
111
+
112
+ ol > li {
113
+ counter-increment: list-0 1;
114
+ }
115
+
116
+ ul > li::before {
117
+ content: "•";
118
+ }
119
+
120
+ ol > li::before {
121
+ content: counter(list-0) ". ";
122
+ }
123
+
124
+ ol li::before, ul li::before {
125
+ display: inline-block;
126
+ margin-left: -1.5em;
127
+ margin-right: 0.3em;
128
+ text-align: right;
129
+ white-space: nowrap;
130
+ width: 1.2em;
131
+ }
132
+
133
+ img {
134
+ max-width: 100%;
135
+ }
136
+
137
+ a {
138
+ color: rgb(var(--zn-color-info));
139
+ }
140
+ }
141
+
142
+ &__meta {
143
+ @include wc.text-style(subheading-small);
53
144
  }
54
145
 
55
146
  &__actions {
@@ -58,3 +149,58 @@
58
149
  align-items: center;
59
150
  }
60
151
  }
152
+
153
+ // Bubble tints by message origin / type.
154
+ :host([agent-initiated]) .message__bubble {
155
+ background: var(--message-background, rgba(var(--zn-color-info), 0.1));
156
+ }
157
+
158
+ :host([action-type="internal"]) .message__bubble {
159
+ background: var(--message-background, rgba(var(--zn-color-warning), 0.15));
160
+ }
161
+
162
+ :host([action-type="error"]) .message__bubble {
163
+ background: var(--message-background, rgba(var(--zn-color-error), 0.12));
164
+ }
165
+
166
+ // Consecutive messages from the same participant are grouped together.
167
+ :host(.message-continued) {
168
+ .message {
169
+ margin-top: 3px;
170
+ }
171
+
172
+ .message__avatar {
173
+ visibility: hidden;
174
+ }
175
+
176
+ .message__header {
177
+ display: none;
178
+ }
179
+ }
180
+
181
+ // System events — a centred, understated card.
182
+ .message--system {
183
+ margin-top: var(--zn-spacing-x-small);
184
+ }
185
+
186
+ .system-card {
187
+ box-sizing: border-box;
188
+ display: flex;
189
+ align-items: center;
190
+ gap: var(--zn-spacing-x-small);
191
+ padding: 6px 12px;
192
+ border-radius: var(--zn-border-radius);
193
+ border: 1px solid rgb(var(--zn-border-color));
194
+ font-size: 12px;
195
+ color: rgb(var(--zn-text-color));
196
+ min-height: 46px; // base height, matching message bubbles
197
+
198
+ &__text {
199
+ @include wc.text-style(paragraph);
200
+ }
201
+
202
+ &__time {
203
+ @include wc.text-style(subheading-small);
204
+ white-space: nowrap;
205
+ }
206
+ }
@@ -0,0 +1,46 @@
1
+ function stringToHTML(str: string): HTMLElement {
2
+ const parser = new DOMParser();
3
+ const doc = parser.parseFromString(str, 'text/html');
4
+ return doc.body;
5
+ }
6
+
7
+ function removeScripts(node: HTMLElement) {
8
+ node.querySelectorAll('script').forEach((script) => {
9
+ script.remove();
10
+ });
11
+ }
12
+
13
+ function isPossiblyDangerousAttribute(name: string, value: string): boolean {
14
+ if (['src', 'href', 'xlink:href'].includes(name)) {
15
+ // eslint-disable-next-line no-script-url -- defensively matching the scheme we want to strip
16
+ if (value.includes('javascript:') || value.includes('data:text/html')) return true;
17
+ }
18
+
19
+ return name.startsWith('on');
20
+ }
21
+
22
+ function removeAttributes(elem: Element) {
23
+ const attributes = elem.attributes;
24
+ for (let i = attributes.length - 1; i >= 0; i--) {
25
+ if (isPossiblyDangerousAttribute(attributes[i].name, attributes[i].value)) {
26
+ elem.removeAttributeNode(attributes[i]);
27
+ }
28
+ }
29
+ }
30
+
31
+ function clean(html: Element) {
32
+ const nodes = html.children;
33
+ for (let i = nodes.length - 1; i >= 0; i--) {
34
+ const node = nodes[i];
35
+ removeAttributes(node);
36
+ clean(node);
37
+ }
38
+ }
39
+
40
+ /** Strip scripts and event/handler attributes from an untrusted HTML string. */
41
+ export function cleanHTML(message: string): string {
42
+ const html = stringToHTML(message);
43
+ removeScripts(html);
44
+ clean(html);
45
+ return html.innerHTML;
46
+ }
@@ -76,8 +76,8 @@
76
76
  }
77
77
 
78
78
  .ql-editor.ql-blank:before {
79
- right: var(--zn-spacing-medium);
80
- left: var(--zn-spacing-medium);
79
+ right: calc(var(--zn-gutter, 0) + var(--zn-base-gap, 0));
80
+ left: calc(var(--zn-gutter, 0) + var(--zn-base-gap, 0));
81
81
  color: rgb(var(--zn-text));
82
82
  }
83
83