@kubex/zinc 1.1.170 → 1.1.172

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.
@@ -139,6 +139,55 @@ Button groups work with all button variants including colors and styles.
139
139
  </zn-button-group>
140
140
  ```
141
141
 
142
+ ### Panel Background
143
+
144
+ Use the `panel-bg` attribute to give every button in the group the icon button look: a panel background with a border, text-coloured labels and a tinted hover. Color variants tint the label and icon instead of filling the button.
145
+
146
+ ```html:preview
147
+ <zn-button-group panel-bg>
148
+ <zn-button icon="format_bold"></zn-button>
149
+ <zn-button icon="format_italic"></zn-button>
150
+ <zn-button icon="format_underlined"></zn-button>
151
+ </zn-button-group>
152
+
153
+ <br />
154
+ <br />
155
+
156
+ <zn-button-group panel-bg>
157
+ <zn-button icon="view_list">List</zn-button>
158
+ <zn-button icon="grid_view">Grid</zn-button>
159
+ <zn-button icon="delete" color="error">Delete</zn-button>
160
+ </zn-button-group>
161
+
162
+ <br />
163
+ <br />
164
+
165
+ <zn-button-group panel-bg gap>
166
+ <zn-button>Cancel</zn-button>
167
+ <zn-button>Save</zn-button>
168
+ </zn-button-group>
169
+ ```
170
+
171
+ Set `aria-pressed="true"` on the selected button to show it in its active colour, for segmented toggles.
172
+
173
+ ```html:preview
174
+ <zn-button-group panel-bg class="panel-bg-toggle">
175
+ <zn-button aria-pressed="false">Mine</zn-button>
176
+ <zn-button aria-pressed="true">Everyone</zn-button>
177
+ </zn-button-group>
178
+
179
+ <script type="module">
180
+ const group = document.querySelector('.panel-bg-toggle');
181
+ group.addEventListener('click', event => {
182
+ const clicked = event.target.closest('zn-button');
183
+ if (!clicked) return;
184
+ group.querySelectorAll('zn-button').forEach(button => {
185
+ button.setAttribute('aria-pressed', String(button === clicked));
186
+ });
187
+ });
188
+ </script>
189
+ ```
190
+
142
191
  ### With Icons
143
192
 
144
193
  Button groups work seamlessly with icon buttons.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubex/zinc",
3
- "version": "1.1.170",
3
+ "version": "1.1.172",
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",
@@ -493,12 +493,12 @@
493
493
  }
494
494
 
495
495
  :host([data-zn-button-group__button--first]:not([data-zn-button-group__button--last])) .button {
496
- border-radius: 5px 0 0 5px !important;
496
+ border-radius: var(--zn-button-border-radius, var(--zn-border-radius)) 0 0 var(--zn-button-border-radius, var(--zn-border-radius)) !important;
497
497
  border-right: 0 !important;
498
498
  }
499
499
 
500
500
  :host([data-zn-button-group__button--last]:not([data-zn-button-group__button--first])) .button {
501
- border-radius: 0 5px 5px 0 !important;
501
+ border-radius: 0 var(--zn-button-border-radius, var(--zn-border-radius)) var(--zn-button-border-radius, var(--zn-border-radius)) 0 !important;
502
502
  border-left: 0 !important;
503
503
  }
504
504
 
@@ -513,12 +513,32 @@
513
513
  white-space: nowrap;
514
514
  }
515
515
 
516
- .button--panel-bg {
516
+ // The icon-button look on a regular button: color variants tint the ink only
517
+ @mixin panel-bg {
517
518
  background: rgb(var(--zn-panel));
518
519
  border-color: var(--zn-button-border-color, rgb(var(--zn-border-color)));
519
520
  color: rgb(var(--zn-text));
520
521
 
522
+ &.button--error, &.button--info, &.button--success, &.button--warning {
523
+ color: rgb(var(--btn-color));
524
+ }
525
+
521
526
  &:hover:not([disabled]), &:active:not([disabled]) {
522
527
  background: rgba(var(--zn-color-tab), 0.5);
523
528
  }
529
+
530
+ &[aria-pressed="true"] {
531
+ &, &:hover:not([disabled]), &:active:not([disabled]) {
532
+ background: rgba(var(--btn-color), 0.1);
533
+ color: rgb(var(--btn-color));
534
+ }
535
+ }
536
+ }
537
+
538
+ .button--panel-bg {
539
+ @include panel-bg;
540
+ }
541
+
542
+ :host([data-zn-button-group__button--panel-bg]) .button:not(.button--icon-button) {
543
+ @include panel-bg;
524
544
  }
@@ -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, query} from 'lit/decorators.js';
4
4
  import ZincElement from '../../internal/zinc-element';
5
5
 
@@ -30,18 +30,27 @@ export default class ZnButtonGroup extends ZincElement {
30
30
  @property({type: Boolean}) wrap = false;
31
31
  @property({type: Boolean}) start = false;
32
32
  @property({type: Boolean}) gap = false;
33
+ /** Gives every button in the group the icon-button look: panel background,
34
+ * border, text-colored ink and a tab-tint hover. */
35
+ @property({type: Boolean, attribute: 'panel-bg'}) panelBackground = false;
33
36
 
34
37
  @query('slot') defaultSlot: HTMLSlotElement;
35
38
 
39
+ protected updated(changed: PropertyValues<this>) {
40
+ if (changed.has('panelBackground')) {
41
+ this.handleSlotChange();
42
+ }
43
+ }
44
+
36
45
  private handleSlotChange() {
37
- if (this.direction === 'vertical') {
46
+ if (!this.defaultSlot) {
38
47
  return;
39
48
  }
40
49
 
41
50
  const allSlotted = [...this.defaultSlot.assignedElements({flatten: true})] as HTMLElement[];
42
51
 
43
52
  // Collect buttons from direct zn-button elements and from zn-dropdown wrappers
44
- const groupItems: {button: HTMLElement; wrapper?: HTMLElement}[] = [];
53
+ const groupItems: { button: HTMLElement; wrapper?: HTMLElement }[] = [];
45
54
  for (const el of allSlotted) {
46
55
  if (el.tagName === 'ZN-BUTTON') {
47
56
  groupItems.push({button: el});
@@ -53,6 +62,14 @@ export default class ZnButtonGroup extends ZincElement {
53
62
  }
54
63
  }
55
64
 
65
+ groupItems.forEach(({button}) => {
66
+ button.toggleAttribute('data-zn-button-group__button--panel-bg', this.panelBackground);
67
+ });
68
+
69
+ if (this.direction === 'vertical') {
70
+ return;
71
+ }
72
+
56
73
  groupItems.forEach(({button, wrapper}, index) => {
57
74
  button.toggleAttribute('data-zn-button-group__button--grow', this.grow);
58
75
 
@@ -7,4 +7,22 @@ describe('<zn-button-group>', () => {
7
7
 
8
8
  expect(el).to.exist;
9
9
  });
10
+
11
+ it('marks slotted buttons with panel-bg and clears it when removed', async () => {
12
+ const el = await fixture<HTMLElement & {panelBackground: boolean; updateComplete: Promise<boolean>}>(html`
13
+ <zn-button-group panel-bg>
14
+ <zn-button>One</zn-button>
15
+ <zn-button>Two</zn-button>
16
+ </zn-button-group>
17
+ `);
18
+ await el.updateComplete;
19
+ const buttons = [...el.querySelectorAll('zn-button')];
20
+
21
+ expect(buttons.every(b => b.hasAttribute('data-zn-button-group__button--panel-bg'))).to.be.true;
22
+
23
+ el.panelBackground = false;
24
+ await el.updateComplete;
25
+
26
+ expect(buttons.some(b => b.hasAttribute('data-zn-button-group__button--panel-bg'))).to.be.false;
27
+ });
10
28
  });
@@ -2,8 +2,10 @@ import {classMap} from "lit/directives/class-map.js";
2
2
  import {deepQuerySelectorAll} from "../../utilities/query";
3
3
  import {HasSlotController} from "../../internal/slot";
4
4
  import {html, unsafeCSS} from 'lit';
5
+ import {looksLikeHtml, sanitizeHtml} from './sanitize-html';
5
6
  import {MutationController} from '@lit-labs/observers/mutation-controller.js';
6
7
  import {property, queryAssignedNodes, queryAsync, state} from 'lit/decorators.js';
8
+ import {unsafeHTML} from 'lit/directives/unsafe-html.js';
7
9
  import ZincElement from "../../internal/zinc-element";
8
10
  import type {PropertyValues} from 'lit';
9
11
  import type ZnTile from "../tile";
@@ -13,6 +15,8 @@ import styles from './content-block.scss';
13
15
  interface TextRow {
14
16
  lines: string[];
15
17
  type: 'reply' | 'text';
18
+ /** `lines` holds a single sanitized HTML string. */
19
+ html?: boolean;
16
20
  }
17
21
 
18
22
  /**
@@ -264,7 +268,9 @@ export default class ContentBlock extends ZincElement {
264
268
  'text-section--text': section.type === 'text',
265
269
  'hidden': section.type === 'reply'
266
270
  })}>
267
- ${section.lines.map((line) => html`${line}<br>`)}
271
+ ${section.html
272
+ ? unsafeHTML(section.lines[0])
273
+ : section.lines.map((line) => html`${line}<br>`)}
268
274
  </div>
269
275
  `)}
270
276
  </div>
@@ -358,6 +364,11 @@ export default class ContentBlock extends ZincElement {
358
364
  const textContent = this.querySelector('[slot="text"]') as HTMLDivElement | null;
359
365
  const textRows: TextRow[] = [];
360
366
 
367
+ if (textContent && looksLikeHtml(textContent.innerText)) {
368
+ this._textRows = [{lines: [sanitizeHtml(textContent.innerText)], type: 'text', html: true}];
369
+ return this._textRows;
370
+ }
371
+
361
372
  if (textContent) {
362
373
  const text = textContent.innerText.replace(/<br\s*\/?>/gi, '\n');
363
374
  const rows = text.split('\n');
@@ -17,11 +17,43 @@ describe('<zn-content-block>', () => {
17
17
  expect(el).to.exist;
18
18
  });
19
19
 
20
- it('should render markup in the text body as text', async () => {
20
+ it('should render plain text in the text body as text', async () => {
21
+ const content = await textSection('1 &lt; 2 &amp;&amp; 3 &gt; 2');
22
+
23
+ expect(content.textContent).to.contain('1 < 2 && 3 > 2');
24
+ });
25
+
26
+ it('should drop images and handlers from markup in the text body', async () => {
21
27
  const content = await textSection('&lt;img src="x" onerror="window.__xss = true"&gt; hello');
22
28
 
23
29
  expect(content.querySelector('img')).to.be.null;
24
- expect(content.textContent).to.contain('<img src="x" onerror="window.__xss = true"> hello');
30
+ expect(content.textContent).to.contain('hello');
31
+ expect((window as unknown as Record<string, unknown>).__xss).to.be.undefined;
32
+ });
33
+
34
+ it('should render allowed formatting from markup in the text body', async () => {
35
+ const content = await textSection(
36
+ '&lt;p&gt;&lt;em&gt;&lt;strong&gt;Need help?&lt;/strong&gt; Visit our '
37
+ + '&lt;a href="http://help.example.com" onclick="x()"&gt;Help Center&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;'
38
+ );
39
+
40
+ expect(content.querySelector('p em strong')?.textContent).to.equal('Need help?');
41
+ const link = content.querySelector('a')!;
42
+ expect(link.getAttribute('href')).to.equal('http://help.example.com/');
43
+ expect(link.getAttribute('rel')).to.equal('noopener noreferrer');
44
+ expect(link.hasAttribute('onclick')).to.be.false;
45
+ expect(content.textContent).to.not.contain('<p>');
46
+ });
47
+
48
+ it('should strip unsafe links and dropped elements from markup in the text body', async () => {
49
+ const content = await textSection(
50
+ '&lt;p&gt;&lt;a href="JavaScript:alert(1)"&gt;click&lt;/a&gt;&lt;/p&gt;'
51
+ + '&lt;style&gt;p{color:red}&lt;/style&gt;&lt;script&gt;window.__xss = true&lt;/script&gt;'
52
+ );
53
+
54
+ expect(content.querySelector('a')!.hasAttribute('href')).to.be.false;
55
+ expect(content.querySelector('style, script')).to.be.null;
56
+ expect(content.textContent).to.not.contain('color:red');
25
57
  expect((window as unknown as Record<string, unknown>).__xss).to.be.undefined;
26
58
  });
27
59
 
@@ -0,0 +1,85 @@
1
+ const ALLOWED = new Set(['p', 'br', 'strong', 'b', 'em', 'i', 'u', 'ul', 'ol', 'li', 'a']);
2
+
3
+ // Removed with their contents; any other disallowed element is unwrapped to its children.
4
+ // img is dropped because a remote src in an email is a tracking pixel.
5
+ const DROPPED = new Set([
6
+ 'script', 'style', 'template', 'iframe', 'frame', 'object', 'embed', 'noscript', 'title', 'head',
7
+ 'meta', 'link', 'base', 'svg', 'math', 'img', 'picture', 'video', 'audio', 'form', 'input', 'button',
8
+ 'select', 'textarea',
9
+ ]);
10
+
11
+ // Unwrapped like any disallowed element, but kept as a paragraph so their text doesn't run together
12
+ const BLOCKS = new Set(['div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'table', 'tr', 'center', 'section']);
13
+
14
+ const SAFE_PROTOCOLS = new Set(['http:', 'https:', 'mailto:']);
15
+
16
+ const HTML_TAG = /<\/?(p|div|span|strong|b|em|i|u|a|img|ul|ol|li|table|tr|td|h[1-6]|blockquote|font|center)\b[^>]*>/i;
17
+
18
+ /** Whether a plain-text body is really HTML source. `<br>` alone doesn't count. */
19
+ export function looksLikeHtml(text: string): boolean {
20
+ return HTML_TAG.test(text);
21
+ }
22
+
23
+ function safeHref(href: string | null): string | null {
24
+ if (!href) return null;
25
+ try {
26
+ const url = new URL(href.trim());
27
+ return SAFE_PROTOCOLS.has(url.protocol) ? url.href : null;
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ function copyChildren(from: Node, to: Node, doc: Document) {
34
+ from.childNodes.forEach((child) => {
35
+ if (child.nodeType === Node.TEXT_NODE) {
36
+ to.appendChild(doc.createTextNode(child.textContent ?? ''));
37
+ return;
38
+ }
39
+ if (child.nodeType !== Node.ELEMENT_NODE) return;
40
+
41
+ const tag = (child as Element).localName;
42
+ if (DROPPED.has(tag)) return;
43
+ if (tag === 'p' || BLOCKS.has(tag)) {
44
+ appendParagraph(child, to, doc);
45
+ return;
46
+ }
47
+ if (!ALLOWED.has(tag)) {
48
+ copyChildren(child, to, doc);
49
+ return;
50
+ }
51
+
52
+ const el = doc.createElement(tag);
53
+ if (tag === 'a') {
54
+ const href = safeHref((child as Element).getAttribute('href'));
55
+ if (href) {
56
+ el.setAttribute('href', href);
57
+ el.setAttribute('target', '_blank');
58
+ el.setAttribute('rel', 'noopener noreferrer');
59
+ }
60
+ }
61
+ copyChildren(child, el, doc);
62
+ to.appendChild(el);
63
+ });
64
+ }
65
+
66
+ // Skips paragraphs left empty, e.g. spacer paragraphs or ones that only held an image
67
+ function appendParagraph(from: Node, to: Node, doc: Document) {
68
+ const p = doc.createElement('p');
69
+ copyChildren(from, p, doc);
70
+ if (p.textContent?.trim()) {
71
+ to.appendChild(p);
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Rebuilds untrusted HTML from an allowlist of formatting tags. Attributes are never copied,
77
+ * except a link's href when it is http(s) or mailto.
78
+ */
79
+ export function sanitizeHtml(source: string): string {
80
+ const parsed = new DOMParser().parseFromString(source, 'text/html');
81
+ const out = document.implementation.createHTMLDocument('');
82
+ const root = out.createElement('div');
83
+ copyChildren(parsed.body, root, out);
84
+ return root.innerHTML;
85
+ }