@kubex/zinc 1.1.75 → 1.1.77

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.
@@ -56,7 +56,13 @@ handlers can't be cancelled from out here — blocking pointer input is the only
56
56
  way to stop them, and hover goes with it. Scrolling doesn't: an overflowing page
57
57
  is scrolled by the panel instead, as below.
58
58
 
59
- Set `interactive` when the embed is meant to be used rather than looked at.
59
+ Set `interactive` when the embed is meant to be used rather than looked at. The
60
+ frame then behaves as a viewport: it stays the panel's own height and the embed
61
+ scrolls itself, so there's a single scrollbar and the embed's `100vh`,
62
+ `position: fixed` and sticky content size to what's actually on screen. The
63
+ [reported content height](#overflowing-content) is ignored while `interactive`
64
+ is set — it exists to make an *inert* frame's overflow reachable, which an
65
+ interactive one does for itself.
60
66
 
61
67
  ```html:preview
62
68
  <zn-preview-frame
@@ -73,11 +79,13 @@ Set `interactive` when the embed is meant to be used rather than looked at.
73
79
 
74
80
  ## Overflowing Content
75
81
 
76
- A page taller than the panel is scrolled by the panel, not inside the frame. The
77
- frame can't do it itself: a cross-origin document can't be scrolled from the host
78
- (`contentWindow.scrollTo` is blocked), and with pointer input off the wheel never
79
- reaches it anyway. So the frame is instead laid out at its full content height —
80
- nothing scrolls inside it — and the panel scrolls that.
82
+ An inert page taller than the panel is scrolled by the panel, not inside the
83
+ frame. The frame can't do it itself: a cross-origin document can't be scrolled
84
+ from the host (`contentWindow.scrollTo` is blocked), and with pointer input off
85
+ the wheel never reaches it anyway. So the frame is instead laid out at its full
86
+ content height — nothing scrolls inside it — and the panel scrolls that. An
87
+ [`interactive`](#interactivity) frame doesn't need any of this and opts out of
88
+ it: it keeps the panel's height and the embed scrolls itself.
81
89
 
82
90
  For the frame to be sized that way, the embed reports its height alongside
83
91
  `hp-preview:rendered`:
@@ -24,6 +24,10 @@ Set `controls-caption` and `preview-caption` to label each column's header
24
24
  row — both are empty by default, rendering no text (the controls column's
25
25
  header row still renders either way, so the two columns stay aligned).
26
26
 
27
+ The preview is [interactive](/components/preview-frame/#interactivity): the
28
+ embedded page can be clicked and hovered, and it scrolls itself, so the preview
29
+ column never adds a second scrollbar beside the embed's own.
30
+
27
31
  The preview always fills its column, leaving no dead space beneath it.
28
32
  `min-height` (default `480`) is a floor for that column, not a fixed height —
29
33
  it's still forwarded to the [preview frame](/components/preview-frame/), which
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubex/zinc",
3
- "version": "1.1.75",
3
+ "version": "1.1.77",
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",
@@ -4,7 +4,6 @@ import {HasSlotController} from "../../internal/slot";
4
4
  import {html, unsafeCSS} from 'lit';
5
5
  import {MutationController} from '@lit-labs/observers/mutation-controller.js';
6
6
  import {property, queryAssignedNodes, queryAsync, state} from 'lit/decorators.js';
7
- import {unsafeHTML} from 'lit-html/directives/unsafe-html.js';
8
7
  import ZincElement from "../../internal/zinc-element";
9
8
  import type {PropertyValues} from 'lit';
10
9
  import type ZnTile from "../tile";
@@ -280,7 +279,7 @@ export default class ContentBlock extends ZincElement {
280
279
  'text-section--text': section.type === 'text',
281
280
  'hidden': section.type === 'reply'
282
281
  })}>
283
- ${section.lines.map((line) => html`${unsafeHTML(line)}<br>`)}
282
+ ${section.lines.map((line) => html`${line}<br>`)}
284
283
  </div>
285
284
  `)}
286
285
  </div>
@@ -375,7 +374,7 @@ export default class ContentBlock extends ZincElement {
375
374
  const textRows: TextRow[] = [];
376
375
 
377
376
  if (textContent) {
378
- const text = textContent.innerText;
377
+ const text = textContent.innerText.replace(/<br\s*\/?>/gi, '\n');
379
378
  const rows = text.split('\n');
380
379
  let previousType: TextRow['type'] | null = null;
381
380
  let forceReply = false;
@@ -1,10 +1,34 @@
1
1
  import '../../../dist/zn.min.js';
2
- import { expect, fixture, html } from '@open-wc/testing';
2
+ import { expect, fixture } from '@open-wc/testing';
3
+ import type { LitElement } from 'lit';
4
+
5
+ const textSection = async (body: string) => {
6
+ const el = await fixture<LitElement>(
7
+ `<zn-content-block><div slot="text">${body}</div></zn-content-block>`
8
+ );
9
+ await el.updateComplete;
10
+ return el.shadowRoot!.querySelector<HTMLDivElement>('.text-content')!;
11
+ };
3
12
 
4
13
  describe('<zn-content-block>', () => {
5
14
  it('should render a component', async () => {
6
- const el = await fixture(html` <zn-content-block></zn-content-block> `);
15
+ const el = await fixture('<zn-content-block></zn-content-block>');
7
16
 
8
17
  expect(el).to.exist;
9
18
  });
19
+
20
+ it('should render markup in the text body as text', async () => {
21
+ const content = await textSection('&lt;img src="x" onerror="window.__xss = true"&gt; hello');
22
+
23
+ expect(content.querySelector('img')).to.be.null;
24
+ expect(content.textContent).to.contain('<img src="x" onerror="window.__xss = true"> hello');
25
+ expect((window as unknown as Record<string, unknown>).__xss).to.be.undefined;
26
+ });
27
+
28
+ it('should treat escaped breaks in the text body as line breaks', async () => {
29
+ const content = await textSection('line1&lt;br /&gt;line2');
30
+
31
+ expect(content.textContent).to.not.contain('<br');
32
+ expect(content.querySelectorAll('br').length).to.be.greaterThan(1);
33
+ });
10
34
  });
@@ -111,6 +111,12 @@ export default class ZnPreviewFrame extends ZincElement {
111
111
  * be reached from here to cancel its own handlers, so this blocks pointer
112
112
  * input entirely — hover goes with it. Scrolling doesn't: an overflowing
113
113
  * page is scrolled by the panel rather than by the frame (see _contentHeight).
114
+ *
115
+ * Set, the frame becomes a real viewport instead: it stays the panel's own
116
+ * height and the embed scrolls itself, so there is one scrollbar rather than
117
+ * a panel scrolling an oversized frame, and the embed's viewport-relative
118
+ * layout (`100vh`, `position: fixed`, sticky headers) sizes to what's on
119
+ * screen. _contentHeight is ignored while this is set.
114
120
  */
115
121
  @property({type: Boolean, reflect: true}) interactive = false;
116
122
 
@@ -125,7 +131,8 @@ export default class ZnPreviewFrame extends ZincElement {
125
131
  * height rather than the panel's, so the page never scrolls inside the frame
126
132
  * — the panel scrolls instead, which is what makes an overflowing preview
127
133
  * reachable while pointer input to the frame is blocked. 0 = unknown, and the
128
- * frame falls back to filling the panel.
134
+ * frame falls back to filling the panel. Kept up to date either way, but only
135
+ * laid out when the frame is inert: an `interactive` frame scrolls itself.
129
136
  */
130
137
  @state() private _contentHeight = 0;
131
138
 
@@ -396,7 +403,10 @@ export default class ZnPreviewFrame extends ZincElement {
396
403
  // transformed back down, so the frame fills the panel while the content
397
404
  // renders smaller and more of the page is visible. Percentage width means
398
405
  // nothing is measured — no layout feedback loop.
399
- const content = this.error ? 0 : this._contentHeight;
406
+ // Growing the frame past the panel is the workaround for an inert frame not
407
+ // being scrollable; interactive, that trades one scrollbar for two and hands
408
+ // the embed a viewport taller than the panel it's shown in.
409
+ const content = this.error || this.interactive ? 0 : this._contentHeight;
400
410
  const iframeStyles = this.fill
401
411
  ? {width: '100%', height: content ? `max(${content}px, 100%)` : '100%'}
402
412
  : {
@@ -683,6 +683,44 @@ describe('<zn-preview-frame>', () => {
683
683
  const stage = el.shadowRoot!.querySelector<HTMLDivElement>('.preview__stage')!;
684
684
  expect(stage.style.height).to.equal('max(1500px, 100%)');
685
685
  });
686
+
687
+ it('leaves an interactive frame at the panel height, so the embed scrolls itself', async () => {
688
+ const el = await fixture(html`
689
+ <zn-preview-frame
690
+ src="about:blank"
691
+ frame-origin="https://site.example"
692
+ data-uri="/payload"
693
+ fill
694
+ interactive></zn-preview-frame>`);
695
+
696
+ const iframe = el.shadowRoot!.querySelector('iframe')!;
697
+ const panel = el.shadowRoot!.querySelector<HTMLDivElement>('.preview')!;
698
+ const stage = el.shadowRoot!.querySelector<HTMLDivElement>('.preview__stage')!;
699
+
700
+ reportHeight(el, 1500);
701
+ await new Promise(resolve => setTimeout(resolve, 50));
702
+
703
+ expect(iframe.style.height).to.equal('100%');
704
+ expect(stage.style.height).to.equal('');
705
+ expect(panel.scrollHeight).to.equal(panel.clientHeight);
706
+ });
707
+
708
+ it('inflates the frame again when interactive is turned off', async () => {
709
+ const el = await fixture(html`
710
+ <zn-preview-frame
711
+ src="about:blank"
712
+ frame-origin="https://site.example"
713
+ data-uri="/payload"
714
+ interactive></zn-preview-frame>`) as HTMLElement & {interactive: boolean};
715
+ const iframe = el.shadowRoot!.querySelector('iframe')!;
716
+
717
+ reportHeight(el, 1400);
718
+ await new Promise(resolve => setTimeout(resolve, 50));
719
+ expect(iframe.style.height).to.equal('480px');
720
+
721
+ el.interactive = false;
722
+ await waitUntil(() => iframe.style.height === '1400px', 'kept the reported height unused');
723
+ });
686
724
  });
687
725
 
688
726
  it('skips the config fetch when data-uri is empty', async () => {
@@ -840,12 +840,14 @@ export default class ZnThemeEditor extends ZincElement {
840
840
  <slot @slotchange="${this._onSlotChange}"></slot>
841
841
  ${this._hasNestedGroups()
842
842
  ? this._renderTabs(section => html`
843
- ${this._hasAssignedControls(section.name) ? html`
844
- <slot name="${section.name}" class="editor__section-slot" @slotchange="${this._onSlotChange}"></slot>` : nothing}
845
- ${this._renderGroups(section)}`)
843
+ ${this._hasAssignedControls(section.name) ? html`
844
+ <slot name="${section.name}" class="editor__section-slot"
845
+ @slotchange="${this._onSlotChange}"></slot>` : nothing}
846
+ ${this._renderGroups(section)}`)
846
847
  : this.sectionLayout === 'tabs'
847
848
  ? this._renderTabs(section => html`
848
- <slot name="${section.name}" class="editor__section-slot" @slotchange="${this._onSlotChange}"></slot>`)
849
+ <slot name="${section.name}" class="editor__section-slot"
850
+ @slotchange="${this._onSlotChange}"></slot>`)
849
851
  : this._renderSections()}
850
852
  </div>
851
853
  ${this.hasSlotController.test('footer') ? html`
@@ -910,6 +912,7 @@ export default class ZnThemeEditor extends ZincElement {
910
912
  device="${this.device}"
911
913
  min-height="${this.minHeight}"
912
914
  fill
915
+ interactive
913
916
  backdrop="${this.standalone ? 'panel' : 'dots'}"
914
917
  exportparts="base:preview__base,stage:preview__stage,iframe:preview__iframe,error:preview__error"
915
918
  @zn-error="${this._onFrameError}"></zn-preview-frame>
@@ -180,9 +180,11 @@
180
180
  margin-top: auto;
181
181
  }
182
182
 
183
- // The preview's scroll region: the toolbar above stays put.
183
+ // Holds the preview below the toolbar, and never scrolls: the frame is its own
184
+ // scroll region (interactive, the embed is), so a scroller here would only ever
185
+ // be a second scrollbar beside that one.
184
186
  .editor__preview {
185
- overflow: auto;
187
+ overflow: hidden;
186
188
  flex: 1 1 auto;
187
189
  min-width: 0;
188
190
  min-height: 0;
@@ -729,6 +729,22 @@ describe('<zn-theme-editor>', () => {
729
729
  expect(controlsHeader).to.exist;
730
730
  expect(controlsHeader!.textContent?.trim()).to.equal('');
731
731
  });
732
+
733
+ it('gives the preview an interactive frame that scrolls itself, so the column never adds a second scrollbar', async () => {
734
+ const el = await fixture(FIXTURE);
735
+ await (el as HTMLElement & {updateComplete: Promise<unknown>}).updateComplete;
736
+
737
+ const preview = el.shadowRoot!.querySelector<HTMLElement>('[part="preview"]')!;
738
+ const frame = el.shadowRoot!.querySelector<HTMLElement>('zn-preview-frame')!;
739
+ await (frame as HTMLElement & {updateComplete: Promise<unknown>}).updateComplete;
740
+
741
+ expect(frame.hasAttribute('interactive')).to.equal(true);
742
+ expect(getComputedStyle(preview).overflowY).to.equal('hidden');
743
+ expect(preview.scrollHeight).to.equal(preview.clientHeight);
744
+
745
+ const iframe = frame.shadowRoot!.querySelector('iframe')!;
746
+ expect(getComputedStyle(iframe).pointerEvents).to.equal('auto');
747
+ });
732
748
  });
733
749
 
734
750
  describe('tabbed sections (section-layout="tabs")', () => {