@kubex/zinc 1.1.181 → 1.1.183

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.
@@ -52,6 +52,53 @@ layout: component
52
52
  </zn-content-block>
53
53
  ```
54
54
 
55
+ ### Editor Formatting
56
+
57
+ Text that holds escaped HTML, such as a note saved from `zn-editor`, is sanitized and rendered with the
58
+ editor's formatting. Only the text and background colour survive from a `style` attribute.
59
+
60
+ ```html:preview
61
+
62
+ <zn-content-block
63
+ time="16 Jun 2025, 13:00"
64
+ sender="John Smith"
65
+ avatar="JS"
66
+ >
67
+ <div slot="text">
68
+ &lt;p&gt;&lt;strong&gt;Bold&lt;/strong&gt;&lt;/p&gt;
69
+ &lt;p&gt;&lt;em&gt;Italic&lt;/em&gt;&lt;/p&gt;
70
+ &lt;p&gt;&lt;u&gt;Underline&lt;/u&gt;&lt;/p&gt;
71
+ &lt;p&gt;&lt;s&gt;Strikethrough&lt;/s&gt;&lt;/p&gt;
72
+ &lt;blockquote&gt;Blockquote&lt;/blockquote&gt;
73
+ &lt;p&gt;&lt;code&gt;Inline code&lt;/code&gt;&lt;/p&gt;
74
+ &lt;pre data-language=plain&gt;Code block&lt;/pre&gt;
75
+ &lt;p&gt;&lt;span style=color:red&gt;Coloured text&lt;/span&gt;&lt;/p&gt;
76
+ &lt;ul&gt;&lt;li&gt;Bullet&lt;/li&gt;&lt;li&gt;Bullet&lt;ul&gt;&lt;li&gt;Nested bullet&lt;/li&gt;&lt;/ul&gt;&lt;/li&gt;&lt;/ul&gt;
77
+ &lt;ol&gt;&lt;li&gt;Numbered&lt;ol&gt;&lt;li&gt;Nested numbered&lt;/li&gt;&lt;li&gt;Nested numbered&lt;/li&gt;&lt;/ol&gt;&lt;/li&gt;&lt;/ol&gt;
78
+ &lt;ul&gt;&lt;li data-list=checked&gt;Checked&lt;/li&gt;&lt;li data-list=unchecked&gt;Unchecked&lt;/li&gt;&lt;/ul&gt;
79
+ &lt;hr class=ql-hr&gt;
80
+ &lt;p&gt;Divider above&lt;/p&gt;
81
+ &lt;p&gt;&lt;a href=https://example.com&gt;Link&lt;/a&gt;&lt;/p&gt;
82
+ </div>
83
+ <div slot="html">
84
+ <p><strong>Bold</strong></p>
85
+ <p><em>Italic</em></p>
86
+ <p><u>Underline</u></p>
87
+ <p><s>Strikethrough</s></p>
88
+ <blockquote>Blockquote</blockquote>
89
+ <p><code>Inline code</code></p>
90
+ <pre data-language="plain">Code block</pre>
91
+ <p><span style="color:red">Coloured text</span></p>
92
+ <ul><li>Bullet</li><li>Bullet<ul><li>Nested bullet</li></ul></li></ul>
93
+ <ol><li>Numbered<ol><li>Nested numbered</li><li>Nested numbered</li></ol></li></ol>
94
+ <ul><li data-list="checked">Checked</li><li data-list="unchecked">Unchecked</li></ul>
95
+ <hr class="ql-hr">
96
+ <p>Divider above</p>
97
+ <p><a href="https://example.com">Link</a></p>
98
+ </div>
99
+ </zn-content-block>
100
+ ```
101
+
55
102
  ### Attachments
56
103
 
57
104
  Attachments render in a row at the bottom of the content and are only visible while the block
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubex/zinc",
3
- "version": "1.1.181",
3
+ "version": "1.1.183",
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",
@@ -1,5 +1,5 @@
1
1
  import {classMap} from "lit/directives/class-map.js";
2
- import {type CSSResultGroup, html, unsafeCSS} from 'lit';
2
+ import {type CSSResultGroup, html, type PropertyValues, unsafeCSS} from 'lit';
3
3
  import {property} from 'lit/decorators.js';
4
4
  import ZincElement from '../../internal/zinc-element';
5
5
 
@@ -65,14 +65,43 @@ export default class ZnCols extends ZincElement {
65
65
  // the whole subtree, to avoid reacting to unrelated content updates deeper in a column.
66
66
  private readonly childObserver: MutationObserver = new MutationObserver(() => this.requestUpdate());
67
67
 
68
+ private readonly resizeObserver: ResizeObserver = new ResizeObserver(() => this.markRowEnds());
69
+
68
70
  connectedCallback() {
69
71
  super.connectedCallback();
70
72
  this.childObserver.observe(this, {childList: true});
73
+ this.resizeObserver.observe(this);
71
74
  }
72
75
 
73
76
  disconnectedCallback() {
74
77
  super.disconnectedCallback();
75
78
  this.childObserver.disconnect();
79
+ this.resizeObserver.disconnect();
80
+ }
81
+
82
+ protected updated(changed: PropertyValues<this>) {
83
+ super.updated(changed);
84
+ this.markRowEnds();
85
+ }
86
+
87
+ /**
88
+ * Flags each child that ends its row as laid out, so `divide` only draws between neighbours on the same row
89
+ * however the columns have wrapped.
90
+ */
91
+ private markRowEnds() {
92
+ const children = Array.from(this.querySelectorAll(':scope > *:not([slot])'));
93
+ if (!this.divide) {
94
+ children.forEach((child) => child.removeAttribute('data-row-end'));
95
+ return;
96
+ }
97
+
98
+ const rects = children.map((child) => child.getBoundingClientRect());
99
+ children.forEach((child, i) => {
100
+ const rect = rects[i];
101
+ const rowEnd = !rects.some((other) =>
102
+ other !== rect && other.top < rect.bottom && other.bottom > rect.top && other.left > rect.left);
103
+ child.toggleAttribute('data-row-end', rowEnd);
104
+ });
76
105
  }
77
106
 
78
107
  /**
@@ -63,6 +63,10 @@
63
63
  background-color: rgb(var(--zn-border-color));
64
64
  }
65
65
  }
66
+
67
+ ::slotted([data-row-end]):after {
68
+ display: none;
69
+ }
66
70
  }
67
71
 
68
72
  &--no-gap {
@@ -135,4 +135,35 @@ describe('<zn-cols>', () => {
135
135
  expect(orderOf(el.querySelector('#usage'))).to.equal('-1');
136
136
  });
137
137
  });
138
+
139
+ describe('dividers', () => {
140
+ const dividerOf = (el: Element | null) => getComputedStyle(el!, '::after').display;
141
+
142
+ it('should only divide neighbours on the same row', async () => {
143
+ const el = await cols(`
144
+ <zn-cols layout="1,1" divide style="width: 600px">
145
+ <div id="a" style="height: 20px"></div>
146
+ <div id="b" style="height: 20px"></div>
147
+ <div id="c" style="height: 20px"></div>
148
+ <div id="d" style="height: 20px"></div>
149
+ </zn-cols>`);
150
+
151
+ expect(dividerOf(el.querySelector('#a'))).to.equal('block');
152
+ expect(dividerOf(el.querySelector('#b'))).to.equal('none');
153
+ expect(dividerOf(el.querySelector('#c'))).to.equal('block');
154
+ });
155
+
156
+ it('should drop the dividers once the columns wrap', async () => {
157
+ const el = await cols(`
158
+ <zn-cols layout="1,1" divide style="width: 600px">
159
+ <div id="a" style="height: 20px"></div>
160
+ <div id="b" style="height: 20px"></div>
161
+ </zn-cols>`);
162
+
163
+ el.style.width = '400px';
164
+ await aTimeout(100);
165
+
166
+ expect(dividerOf(el.querySelector('#a'))).to.equal('none');
167
+ });
168
+ });
138
169
  });
@@ -13,6 +13,21 @@ import type ZnTile from "../tile";
13
13
 
14
14
  import styles from './content-block.scss';
15
15
 
16
+ // What the editor (Quill) can produce beyond the sanitizer's defaults
17
+ const EDITOR_TAGS = ['s', 'code', 'pre', 'blockquote', 'hr', 'span'];
18
+
19
+ // The iframe can't see the shadow styles, so editor formatting is restyled to match the text view
20
+ const IFRAME_STYLES = 'html,body{background-color:#fff;margin:0}img{max-width:100%;max-height:500px}'
21
+ + 'p{margin:0}ul,ol{margin:0;padding-left:1.5em}'
22
+ + 'blockquote{border-left:4px solid #ddd;margin:5px 0;padding-left:16px}'
23
+ + 'code{background-color:#f0f0f0;border-radius:3px;font-size:85%;padding:2px 4px}'
24
+ + 'pre{background-color:#23241f;border-radius:3px;margin:5px 0;padding:5px 10px;color:#f8f8f2;overflow:auto}'
25
+ + 'pre code{background:none;padding:0}'
26
+ + 'hr{border:none;border-top:1px solid #ddd;margin:8px 0}'
27
+ + 'li[data-list=checked],li[data-list=unchecked]{list-style:none}'
28
+ + 'li[data-list=checked]::before{content:"☑";color:#777;margin:0 .3em 0 -1.5em}'
29
+ + 'li[data-list=unchecked]::before{content:"☐";color:#777;margin:0 .3em 0 -1.5em}';
30
+
16
31
  interface TextRow {
17
32
  lines: string[];
18
33
  type: 'reply' | 'text';
@@ -70,14 +85,13 @@ export default class ContentBlock extends ZincElement {
70
85
  if (iframe.contentDocument) {
71
86
  const div = this.htmlNodes[0] as HTMLDivElement;
72
87
  if (div) {
73
- let convertedHtml = div?.innerHTML ?? '';
74
- convertedHtml = convertedHtml.replace(/&nbsp;/g, ' ');
75
- convertedHtml = convertedHtml.replace(/&lt;/g, '<');
76
- convertedHtml = convertedHtml.replace(/&gt;/g, '>');
88
+ // Escaped source has no element children; textContent decodes it once, including &amp;nbsp;
89
+ let convertedHtml = div.children.length ? div.innerHTML : div.textContent ?? '';
77
90
  convertedHtml = convertedHtml.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
78
91
 
79
- const baseStyles = "<style>body,html{background-color: #ffffff}img{max-width:100%; max-height: 500px;}</style>";
80
- iframe.srcdoc = baseStyles + convertedHtml;
92
+ const {fontFamily, fontSize, lineHeight, color} = getComputedStyle(this.shadowRoot?.querySelector('.text-section') ?? this);
93
+ iframe.srcdoc = `<style>body{font-family:${fontFamily};font-size:${fontSize};line-height:${lineHeight};color:${color}}${IFRAME_STYLES}</style>`
94
+ + convertedHtml;
81
95
  }
82
96
  }
83
97
 
@@ -366,7 +380,7 @@ export default class ContentBlock extends ZincElement {
366
380
  const textRows: TextRow[] = [];
367
381
 
368
382
  if (textContent && looksLikeHtml(textContent.innerText)) {
369
- this._textRows = [{lines: [sanitizeHtml(textContent.innerText)], type: 'text', html: true}];
383
+ this._textRows = [{lines: [sanitizeHtml(textContent.innerText, EDITOR_TAGS, true)], type: 'text', html: true}];
370
384
  return this._textRows;
371
385
  }
372
386
 
@@ -118,6 +118,38 @@
118
118
  font-size: var(--zn-font-size-small);
119
119
  opacity: 0.8;
120
120
  }
121
+
122
+ p {
123
+ margin: 0;
124
+ }
125
+
126
+ blockquote {
127
+ border-left: 4px solid rgb(var(--zn-border-color));
128
+ margin: 5px 0;
129
+ padding-left: 16px;
130
+ }
131
+
132
+ code {
133
+ background-color: #f0f0f0;
134
+ border-radius: 3px;
135
+ font-size: 85%;
136
+ padding: 2px 4px;
137
+ }
138
+
139
+ pre {
140
+ background-color: #23241f;
141
+ border-radius: 3px;
142
+ margin-block: 5px;
143
+ padding: 5px 10px;
144
+ color: #f8f8f2;
145
+ overflow: auto;
146
+ }
147
+
148
+ hr {
149
+ border: none;
150
+ border-top: 1px solid rgb(var(--zn-border-color));
151
+ margin: var(--zn-spacing-small) 0;
152
+ }
121
153
  }
122
154
 
123
155
  .hidden {
@@ -139,6 +171,10 @@ ol, ul {
139
171
  margin: 0;
140
172
  }
141
173
 
174
+ li > ol, li > ul {
175
+ padding-left: 1.5em;
176
+ }
177
+
142
178
  li[data-list=checked] > .ql-ui, li[data-list=unchecked] > .ql-ui {
143
179
  color: #777;
144
180
  }
@@ -17,6 +17,16 @@ describe('<zn-content-block>', () => {
17
17
  expect(el).to.exist;
18
18
  });
19
19
 
20
+ it('should decode escaped source in the html body once', async () => {
21
+ const el = await fixture<LitElement>(
22
+ '<zn-content-block><div slot="html">&lt;p&gt;Test&amp;nbsp;Format&lt;/p&gt;</div></zn-content-block>'
23
+ );
24
+ await el.updateComplete;
25
+ const iframe = el.shadowRoot!.querySelector('iframe')!;
26
+
27
+ expect(iframe.srcdoc).to.contain('<p>Test&nbsp;Format</p>');
28
+ });
29
+
20
30
  it('should render plain text in the text body as text', async () => {
21
31
  const content = await textSection('1 &lt; 2 &amp;&amp; 3 &gt; 2');
22
32
 
@@ -45,6 +55,21 @@ describe('<zn-content-block>', () => {
45
55
  expect(content.textContent).to.not.contain('<p>');
46
56
  });
47
57
 
58
+ it('should keep editor formatting and only colours from styles in the text body', async () => {
59
+ const content = await textSection(
60
+ '&lt;p&gt;&lt;s&gt;struck&lt;/s&gt; &lt;code&gt;inline&lt;/code&gt;&lt;/p&gt;&lt;blockquote&gt;quote&lt;/blockquote&gt;'
61
+ + '&lt;pre&gt;block&lt;/pre&gt;&lt;hr&gt;'
62
+ + '&lt;p&gt;&lt;span style="color: red; background-image: url(https://x.test/p.gif)"&gt;red&lt;/span&gt;&lt;/p&gt;'
63
+ );
64
+
65
+ expect(content.querySelector('s')?.textContent).to.equal('struck');
66
+ expect(content.querySelector('code')?.textContent).to.equal('inline');
67
+ expect(content.querySelector('blockquote')?.textContent).to.equal('quote');
68
+ expect(content.querySelector('pre')?.textContent).to.equal('block');
69
+ expect(content.querySelector('hr')).to.exist;
70
+ expect(content.querySelector('span')!.getAttribute('style')).to.equal('color: red;');
71
+ });
72
+
48
73
  it('should strip unsafe links and dropped elements from markup in the text body', async () => {
49
74
  const content = await textSection(
50
75
  '&lt;p&gt;&lt;a href="JavaScript:alert(1)"&gt;click&lt;/a&gt;&lt;/p&gt;'
@@ -127,8 +127,8 @@ export default class ZnStatsTile extends ZincElement {
127
127
  }
128
128
 
129
129
  private renderAmount() {
130
- return html`${this.getDisplayAmount()}${this.icon ? html`
131
- <zn-icon class="icon" src="${this.icon}" size="20" color="${ifDefined(this.iconColor)}"></zn-icon>` : null}`;
130
+ return html`<span class="value">${this.getDisplayAmount()}${this.icon ? html`
131
+ <zn-icon class="icon" src="${this.icon}" size="20" color="${ifDefined(this.iconColor)}"></zn-icon>` : null}</span>`;
132
132
  }
133
133
 
134
134
  private renderValueFirst() {
@@ -73,6 +73,12 @@
73
73
  }
74
74
  }
75
75
 
76
+ .value {
77
+ display: inline-flex;
78
+ align-items: center;
79
+ white-space: nowrap;
80
+ }
81
+
76
82
  .icon {
77
83
  margin-left: var(--zn-spacing-x-small);
78
84
  }
@@ -89,13 +95,19 @@
89
95
 
90
96
  .amount {
91
97
  flex-grow: 0;
92
- gap: var(--zn-spacing-x-small);
98
+ flex-wrap: wrap;
99
+ column-gap: var(--zn-spacing-x-small);
93
100
  color: inherit;
94
101
  }
95
102
 
103
+ .value {
104
+ gap: var(--zn-spacing-x-small);
105
+ }
106
+
96
107
  .diff {
97
108
  font-size: var(--zn-font-size-small);
98
109
  font-weight: 400;
110
+ white-space: nowrap;
99
111
  }
100
112
 
101
113
  .diff .neutral,
@@ -16,11 +16,12 @@ const DROPPED = [
16
16
  /**
17
17
  * Sanitizes untrusted HTML down to formatting tags, keeping only a link's href when it is http(s) or mailto.
18
18
  * Tags in `keep` are allowed as they are, so a block such as `pre` isn't turned into a paragraph.
19
+ * With `colors`, a `style` attribute survives holding only its text and background colour.
19
20
  */
20
- export function sanitizeHtml(source: string, keep: string[] = []): string {
21
+ export function sanitizeHtml(source: string, keep: string[] = [], colors = false): string {
21
22
  const fragment = DOMPurify.sanitize(source, {
22
23
  ALLOWED_TAGS: [...FORMATTING, ...BLOCKS, ...keep],
23
- ALLOWED_ATTR: ['href'],
24
+ ALLOWED_ATTR: colors ? ['href', 'style'] : ['href'],
24
25
  ALLOWED_URI_REGEXP: /^(?:https?|mailto):/i,
25
26
  FORBID_CONTENTS: DROPPED,
26
27
  RETURN_DOM_FRAGMENT: true,
@@ -36,6 +37,14 @@ export function sanitizeHtml(source: string, keep: string[] = []): string {
36
37
  fragment.querySelectorAll('p').forEach(p => {
37
38
  if (!p.textContent?.trim()) p.remove();
38
39
  });
40
+ // Any other declaration could load a remote url(), which in an email is a tracking pixel
41
+ fragment.querySelectorAll<HTMLElement>('[style]').forEach(el => {
42
+ const {color, backgroundColor} = el.style;
43
+ el.removeAttribute('style');
44
+ el.style.color = color;
45
+ el.style.backgroundColor = backgroundColor;
46
+ if (!el.getAttribute('style')) el.removeAttribute('style');
47
+ });
39
48
  fragment.querySelectorAll('a[href]').forEach(link => {
40
49
  try {
41
50
  link.setAttribute('href', new URL(link.getAttribute('href')!.trim()).href);