@kubex/zinc 1.1.76 → 1.1.78

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`:
@@ -74,6 +74,29 @@ Use the `disabled` attribute to disable the rating component.
74
74
  <zn-rating value="3" disabled label="Rating disabled with value"></zn-rating>
75
75
  ```
76
76
 
77
+ ### Help Text
78
+
79
+ Add descriptive help text to a rating with the `help-text` attribute. For help text that contains HTML, use the
80
+ `help-text` slot instead.
81
+
82
+ ```html:preview
83
+ <zn-rating label="Rate this product" help-text="Select a star to rate this product."></zn-rating>
84
+ <br />
85
+ <zn-rating value="3" precision="0.5" label="Overall rating">
86
+ <div slot="help-text">Ratings are <strong>public</strong> and can be changed at any time.</div>
87
+ </zn-rating>
88
+ ```
89
+
90
+ Help text is styled to match [Input](/components/input) and follows the `size` attribute.
91
+
92
+ ```html:preview
93
+ <zn-rating size="small" value="3" help-text="Small help text"></zn-rating>
94
+ <br />
95
+ <zn-rating size="medium" value="3" help-text="Medium help text"></zn-rating>
96
+ <br />
97
+ <zn-rating size="large" value="3" help-text="Large help text"></zn-rating>
98
+ ```
99
+
77
100
  ### Sizes
78
101
 
79
102
  Use the `size` attribute to change the rating size. Available sizes are `small`, `medium` (default), and `large`.
@@ -116,6 +139,40 @@ The rating component displays hover effects by default, scaling the hovered star
116
139
  **Usage:** Hover effects provide visual feedback to help users understand which rating they will select. The hover effect is automatically disabled for `readonly` and `disabled` ratings.
117
140
  :::
118
141
 
142
+ ### Preview
143
+
144
+ Use the `preview` attribute to show the value next to the symbols. While the pointer moves across the symbols the
145
+ preview follows it, so the value being selected is visible before it is committed. Once the pointer leaves, the preview
146
+ falls back to the selected value.
147
+
148
+ ```html:preview
149
+ <zn-rating preview label="Rate this product"></zn-rating>
150
+ <br />
151
+ <zn-rating preview value="2.5" precision="0.5" label="Half star precision"></zn-rating>
152
+ <br />
153
+ <zn-rating preview value="3.75" precision="0.25" max="10" size="large" label="Quarter star precision"></zn-rating>
154
+ ```
155
+
156
+ The preview is formatted to match `precision`, so a rating with `precision="0.5"` previews `4.5` rather than `4`.
157
+ It also works with `readonly` and `disabled` ratings, where it simply displays the current value.
158
+
159
+ ```html:preview
160
+ <zn-rating preview value="4.5" precision="0.5" readonly label="Average rating"></zn-rating>
161
+ <br />
162
+ <zn-rating preview value="3" disabled label="Rating disabled"></zn-rating>
163
+ ```
164
+
165
+ Style the preview with the `--preview-color` and `--preview-size` custom properties, or the `preview` part.
166
+
167
+ ```html:preview
168
+ <zn-rating
169
+ preview
170
+ value="4"
171
+ label="Custom preview styling"
172
+ style="--preview-color: #3B82F6; --preview-size: 1.25rem;"
173
+ ></zn-rating>
174
+ ```
175
+
119
176
  ### Form Integration
120
177
 
121
178
  Ratings work seamlessly with forms and will be submitted with form data using the `name` attribute.
@@ -272,6 +329,8 @@ Available CSS custom properties:
272
329
  - `--symbol-color-active` - Color of filled/active symbols
273
330
  - `--symbol-size` - Size of the symbols
274
331
  - `--symbol-spacing` - Spacing between symbols
332
+ - `--preview-color` - Color of the preview value (see [Preview](#preview))
333
+ - `--preview-size` - Font size of the preview value
275
334
 
276
335
  ### Complete Example
277
336
 
@@ -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.76",
3
+ "version": "1.1.78",
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",
@@ -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 () => {
@@ -1,6 +1,7 @@
1
1
  import {classMap} from "lit/directives/class-map.js";
2
2
  import {type CSSResultGroup, html, unsafeCSS} from 'lit';
3
3
  import {FormControlController, validValidityState} from "../../internal/form";
4
+ import {HasSlotController} from "../../internal/slot";
4
5
  import {property, query, state} from 'lit/decorators.js';
5
6
  import {styleMap} from 'lit/directives/style-map.js';
6
7
  import {unsafeHTML} from "lit/directives/unsafe-html.js";
@@ -22,10 +23,16 @@ import styles from './rating.scss';
22
23
  *
23
24
  * @slot - The default slot.
24
25
  * @slot example - An example slot.
26
+ * @slot help-text - Text that describes how to use the rating. Alternatively, you can use the `help-text` attribute.
25
27
  *
28
+ * @csspart form-control - The form control that wraps the symbols and help text.
29
+ * @csspart form-control-help-text - The help text's wrapper.
26
30
  * @csspart base - The component's base wrapper.
31
+ * @csspart preview - The value shown next to the symbols when `preview` is enabled.
27
32
  *
28
33
  * @cssproperty --example - An example CSS custom property.
34
+ * @cssproperty --preview-color - The color of the preview value.
35
+ * @cssproperty --preview-size - The font size of the preview value.
29
36
  */
30
37
  export default class ZnRating extends ZincElement implements ZincFormControl {
31
38
  static styles: CSSResultGroup = unsafeCSS(styles);
@@ -34,14 +41,21 @@ export default class ZnRating extends ZincElement implements ZincFormControl {
34
41
  assumeInteractionOn: ['zn-blur', 'zn-input']
35
42
  });
36
43
 
44
+ private readonly hasSlotController = new HasSlotController(this, 'help-text');
45
+
37
46
  @query('.rating') rating: HTMLElement;
38
47
 
48
+ @query('.rating__symbols') symbols: HTMLElement;
49
+
39
50
  @state() private hoverValue: number = 0;
40
51
 
41
52
  @state() private isHovering: boolean = false;
42
53
 
43
54
  @property() label: string;
44
55
 
56
+ /** The rating's help text. If you need to display HTML, use the `help-text` slot instead. */
57
+ @property({attribute: 'help-text'}) helpText: string = '';
58
+
45
59
  @property() name: string;
46
60
 
47
61
  @property({type: Number}) value: number = 0;
@@ -54,6 +68,9 @@ export default class ZnRating extends ZincElement implements ZincFormControl {
54
68
 
55
69
  @property({type: Boolean}) disabled: boolean = false;
56
70
 
71
+ /** Displays the value alongside the symbols, updating live as the pointer moves across them. */
72
+ @property({type: Boolean}) preview: boolean = false;
73
+
57
74
  @property({}) size: 'small' | 'medium' | 'large' = 'medium';
58
75
 
59
76
  @property() getSymbol: (value: number) => string = () => '<zn-icon src="star" library="material"></zn-icon>';
@@ -94,12 +111,17 @@ export default class ZnRating extends ZincElement implements ZincFormControl {
94
111
  }
95
112
 
96
113
  private _getValueFromXCoordinate(coordinate: number): number {
97
- const {left, width} = this.rating.getBoundingClientRect();
114
+ const {left, width} = this.symbols.getBoundingClientRect();
98
115
  const value = this._roundToPrecision(((coordinate - left) / width) * this.max, this.precision);
99
116
 
100
117
  return Math.min(Math.max(value, 0), this.max);
101
118
  }
102
119
 
120
+ private _formatValue(value: number): string {
121
+ const decimals = String(this.precision).split('.')[1]?.length ?? 0;
122
+ return value.toFixed(decimals);
123
+ }
124
+
103
125
  private _getValueFromMousePosition(event: MouseEvent): number {
104
126
  return this._getValueFromXCoordinate(event.clientX);
105
127
  }
@@ -150,13 +172,15 @@ export default class ZnRating extends ZincElement implements ZincFormControl {
150
172
 
151
173
  private _handleTouchEnd(event: TouchEvent) {
152
174
  this.isHovering = false;
153
- this._setValue(this._getValueFromTouchPosition(event));
175
+ // `touches` is empty once the finger lifts, so the final position lives in `changedTouches`.
176
+ this._setValue(this._getValueFromXCoordinate(event.changedTouches[0].clientX));
154
177
 
155
178
  event.preventDefault();
156
179
  }
157
180
 
158
181
  render() {
159
182
  const counter = Array.from(Array(this.max).keys());
183
+ const hasHelpText = this.helpText ? true : this.hasSlotController.test('help-text');
160
184
  let displayValue = 0;
161
185
 
162
186
  if (this.disabled || this.readonly) {
@@ -167,69 +191,102 @@ export default class ZnRating extends ZincElement implements ZincFormControl {
167
191
 
168
192
  return html`
169
193
  <div
170
- part="base"
194
+ part="form-control"
171
195
  class="${classMap({
172
- rating: true,
173
- 'rating--readonly': this.readonly,
174
- 'rating--disabled': this.disabled,
175
- 'rating--small': this.size === 'small',
176
- 'rating--medium': this.size === 'medium',
177
- 'rating--large': this.size === 'large'
178
- })}"
179
- role="slider"
180
- aria-label="${this.label}"
181
- aria-disabled="${this.disabled ? 'true' : 'false'}"
182
- aria-readonly="${this.readonly ? 'true' : 'false'}"
183
- aria-valuenow="${this.value}"
184
- aria-valuemin="0"
185
- aria-valuemax="${this.max}"
186
- @click="${this._handleClick}"
187
- @mouseenter="${this._handleMouseEnter}"
188
- @mousemove="${this._handleMouseMove}"
189
- @mouseleave="${this._handleMouseLeave}"
190
- @touchstart="${this._handleTouchStart}"
191
- @touchmove="${this._handleTouchMove}"
192
- @touceend="${this._handleTouchEnd}"
193
- >
194
- <span class="rating__symbols">
195
- ${counter.map(index => {
196
- if (displayValue > index && displayValue < index + 1) {
196
+ 'form-control': true,
197
+ 'form-control--small': this.size === 'small',
198
+ 'form-control--medium': this.size === 'medium',
199
+ 'form-control--large': this.size === 'large',
200
+ 'form-control--has-help-text': hasHelpText
201
+ })}">
202
+ <div
203
+ part="base"
204
+ class="${classMap({
205
+ rating: true,
206
+ 'rating--readonly': this.readonly,
207
+ 'rating--disabled': this.disabled,
208
+ 'rating--small': this.size === 'small',
209
+ 'rating--medium': this.size === 'medium',
210
+ 'rating--large': this.size === 'large'
211
+ })}"
212
+ role="slider"
213
+ aria-label="${this.label}"
214
+ aria-describedby="help-text"
215
+ aria-disabled="${this.disabled ? 'true' : 'false'}"
216
+ aria-readonly="${this.readonly ? 'true' : 'false'}"
217
+ aria-valuenow="${this.value}"
218
+ aria-valuemin="0"
219
+ aria-valuemax="${this.max}"
220
+ >
221
+ <span
222
+ class="rating__symbols"
223
+ @click="${this._handleClick}"
224
+ @mouseenter="${this._handleMouseEnter}"
225
+ @mousemove="${this._handleMouseMove}"
226
+ @mouseleave="${this._handleMouseLeave}"
227
+ @touchstart="${this._handleTouchStart}"
228
+ @touchmove="${this._handleTouchMove}"
229
+ @touchend="${this._handleTouchEnd}"
230
+ >
231
+ ${counter.map(index => {
232
+ if (displayValue > index && displayValue < index + 1) {
233
+ return html`
234
+ <span
235
+ class=${classMap({
236
+ rating__symbol: true,
237
+ 'rating__partial-symbol-container': true,
238
+ 'rating__symbol--hover': this.isHovering && Math.ceil(displayValue) === index + 1
239
+ })}
240
+ role="presentation">
241
+ <div
242
+ style=${styleMap({
243
+ clipPath: `inset(0 0 0 ${(displayValue - index) * 100}%)`
244
+ })}>
245
+ ${unsafeHTML(this.getSymbol(index + 1))}
246
+ </div>
247
+ <div
248
+ class="rating__partial--filled"
249
+ style=${styleMap({
250
+ clipPath: `inset(0 ${100 - (displayValue - index) * 100}% 0 0)`
251
+ })}>
252
+ ${unsafeHTML(this.getSymbol(index + 1))}
253
+ </div>
254
+ </span>
255
+ `;
256
+ }
197
257
  return html`
198
258
  <span
199
259
  class=${classMap({
200
260
  rating__symbol: true,
201
- 'rating__partial-symbol-container': true,
202
- 'rating__symbol--hover': this.isHovering && Math.ceil(displayValue) === index + 1
261
+ 'rating__symbol--hover': this.isHovering && Math.ceil(displayValue) === index + 1,
262
+ 'rating__symbol--active': displayValue >= index + 1
203
263
  })}
204
264
  role="presentation">
205
- <div
206
- style=${styleMap({
207
- clipPath: `inset(0 0 0 ${(displayValue - index) * 100}%)`
208
- })}>
209
- ${unsafeHTML(this.getSymbol(index + 1))}
210
- </div>
211
- <div
212
- class="rating__partial--filled"
213
- style=${styleMap({
214
- clipPath: `inset(0 ${100 - (displayValue - index) * 100}% 0 0)`
215
- })}>
216
- ${unsafeHTML(this.getSymbol(index + 1))}
217
- </div>
218
- </span>
219
- `;
220
- }
221
- return html`
265
+ ${unsafeHTML(this.getSymbol(index + 1))}
266
+ </span>`;
267
+ })}
268
+ </span>
269
+ ${this.preview
270
+ ? html`
222
271
  <span
272
+ part="preview"
223
273
  class=${classMap({
224
- rating__symbol: true,
225
- 'rating__symbol--hover': this.isHovering && Math.ceil(displayValue) === index + 1,
226
- 'rating__symbol--active': displayValue >= index + 1
274
+ rating__preview: true,
275
+ 'rating__preview--hover': this.isHovering
227
276
  })}
228
- role="presentation">
229
- ${unsafeHTML(this.getSymbol(index + 1))}
230
- </span>`;
231
- })}
232
- </span>
277
+ aria-hidden="true">
278
+ ${this._formatValue(displayValue)}
279
+ </span>`
280
+ : ''}
281
+ </div>
282
+
283
+ <div
284
+ part="form-control-help-text"
285
+ id="help-text"
286
+ class="form-control__help-text"
287
+ aria-hidden=${hasHelpText ? 'false' : 'true'}>
288
+ <slot name="help-text">${this.helpText}</slot>
289
+ </div>
233
290
  </div>
234
291
  `;
235
292
  }
@@ -1,10 +1,13 @@
1
1
  @use "../../wc";
2
+ @use "../../form-control";
2
3
 
3
4
  :host {
4
5
  --symbol-color: rgb(var(--zn-border-color));
5
6
  --symbol-color-active: var(--zn-color-amber-400);
6
7
  --symbol-size: 1.2rem;
7
8
  --symbol-spacing: 4px;
9
+ --preview-color: rgb(var(--zn-text));
10
+ --preview-size: 0.875rem;
8
11
 
9
12
  display: block;
10
13
  }
@@ -12,6 +15,7 @@
12
15
  .rating {
13
16
  position: relative;
14
17
  display: inline-flex;
18
+ align-items: center;
15
19
  border-radius: var(--zn-border-radius);
16
20
  vertical-align: middle;
17
21
 
@@ -62,6 +66,21 @@
62
66
  }
63
67
  }
64
68
 
69
+ &__preview {
70
+ margin-left: var(--symbol-spacing);
71
+ font-size: var(--preview-size);
72
+ font-weight: var(--zn-font-weight-semibold);
73
+ line-height: 1;
74
+ color: var(--preview-color);
75
+ font-variant-numeric: tabular-nums;
76
+ pointer-events: none;
77
+ user-select: none;
78
+
79
+ &--hover {
80
+ color: var(--symbol-color-active);
81
+ }
82
+ }
83
+
65
84
  &--disabled &__symbols,
66
85
  &--readonly &__symbols {
67
86
  cursor: default;
@@ -72,6 +91,11 @@
72
91
  scale: none;
73
92
  }
74
93
 
94
+ &--disabled &__preview--hover,
95
+ &--readonly &__preview--hover {
96
+ color: var(--preview-color);
97
+ }
98
+
75
99
  &--disabled {
76
100
  opacity: 0.5;
77
101
  }
@@ -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")', () => {