@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.
- package/dist/custom-elements.json +93 -74
- package/dist/vscode.html-custom-data.json +17 -12
- package/dist/web-types.json +33 -23
- package/dist/zn.d.ts +16 -1
- package/dist/zn.min.js +284 -284
- package/docs/pages/components/button-group.md +49 -0
- package/package.json +1 -1
- package/src/components/button/button.scss +23 -3
- package/src/components/button-group/button-group.component.ts +20 -3
- package/src/components/button-group/button-group.test.ts +18 -0
- package/src/components/content-block/content-block.component.ts +12 -1
- package/src/components/content-block/content-block.test.ts +34 -2
- package/src/components/content-block/sanitize-html.ts +85 -0
|
@@ -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
|
@@ -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:
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
20
|
+
it('should render plain text in the text body as text', async () => {
|
|
21
|
+
const content = await textSection('1 < 2 && 3 > 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('<img src="x" onerror="window.__xss = true"> hello');
|
|
22
28
|
|
|
23
29
|
expect(content.querySelector('img')).to.be.null;
|
|
24
|
-
expect(content.textContent).to.contain('
|
|
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
|
+
'<p><em><strong>Need help?</strong> Visit our '
|
|
37
|
+
+ '<a href="http://help.example.com" onclick="x()">Help Center</a></em></p>'
|
|
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
|
+
'<p><a href="JavaScript:alert(1)">click</a></p>'
|
|
51
|
+
+ '<style>p{color:red}</style><script>window.__xss = true</script>'
|
|
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
|
+
}
|