@kubex/zinc 1.1.85 → 1.1.87

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.
@@ -62,6 +62,11 @@ export default class ZnPreviewFrame extends ZincElement {
62
62
  * refresh the preview. These are never intercepted: the shell submits them
63
63
  * (so its own response handling — alerts, refreshes — runs as normal) and the
64
64
  * preview re-fetches its config once the shell reports the save complete.
65
+ * Matched by delegation on the shell's bubbled `complete` event rather than
66
+ * by attaching to the forms themselves, so a save anywhere on the page
67
+ * refreshes the preview — including forms in a different DOM root, e.g. a
68
+ * page-level form saved while the preview sits inside a tab panel's shadow
69
+ * root (a page's Template select lives on one tab, its preview on another).
65
70
  * Set empty to disable.
66
71
  */
67
72
  @property({attribute: 'refresh-on'}) refreshOn = 'form';
@@ -141,8 +146,13 @@ export default class ZnPreviewFrame extends ZincElement {
141
146
  private _contentObserver: ResizeObserver | undefined;
142
147
 
143
148
  private readonly _watchedForms = new Set<HTMLFormElement>();
144
- private readonly _refreshForms = new Set<HTMLFormElement>();
145
149
  private readonly _debounceTimers = new Map<HTMLFormElement, number>();
150
+ // 'complete' delegation targets: the document always, plus the component's
151
+ // own shadow root when it has one (a non-composed event never reaches the
152
+ // document). A composed event inside the shadow root hits both, so the
153
+ // handler dedupes by event object.
154
+ private _shellSaveRoots: EventTarget[] = [];
155
+ private _lastShellSave: Event | undefined;
146
156
 
147
157
  // Forms are siblings in light DOM and get replaced when other content
148
158
  // re-renders; re-resolve them whenever the surrounding DOM changes.
@@ -158,6 +168,8 @@ export default class ZnPreviewFrame extends ZincElement {
158
168
  window.addEventListener('message', this._onMessage);
159
169
  this._attachForms();
160
170
  const root = this.getRootNode();
171
+ this._shellSaveRoots = root instanceof ShadowRoot ? [document, root] : [document];
172
+ this._shellSaveRoots.forEach(target => target.addEventListener('complete', this._onShellSave));
161
173
  if (root instanceof Document) {
162
174
  this._formObserver.observe(root.body);
163
175
  } else if (root instanceof ShadowRoot) {
@@ -178,8 +190,8 @@ export default class ZnPreviewFrame extends ZincElement {
178
190
  this._contentObserver = undefined;
179
191
  this._watchedForms.forEach(form => this._detachForm(form));
180
192
  this._watchedForms.clear();
181
- this._refreshForms.forEach(form => form.removeEventListener('complete', this._onShellSave));
182
- this._refreshForms.clear();
193
+ this._shellSaveRoots.forEach(target => target.removeEventListener('complete', this._onShellSave));
194
+ this._shellSaveRoots = [];
183
195
  }
184
196
 
185
197
  private readonly _onMessage = (e: MessageEvent) => {
@@ -315,30 +327,18 @@ export default class ZnPreviewFrame extends ZincElement {
315
327
  form.addEventListener('zn-input', this._onChange);
316
328
  form.addEventListener('change', this._onChange);
317
329
  });
318
-
319
- // Shell-saved forms: the shell fires 'complete' on the form it submitted.
320
- const refreshMatched = new Set<HTMLFormElement>();
321
- if (this.refreshOn) {
322
- root.querySelectorAll(this.refreshOn).forEach(node => {
323
- if (node instanceof HTMLFormElement && !matched.has(node)) refreshMatched.add(node);
324
- });
325
- }
326
-
327
- this._refreshForms.forEach(form => {
328
- if (!refreshMatched.has(form)) {
329
- form.removeEventListener('complete', this._onShellSave);
330
- this._refreshForms.delete(form);
331
- }
332
- });
333
-
334
- refreshMatched.forEach(form => {
335
- if (this._refreshForms.has(form)) return;
336
- this._refreshForms.add(form);
337
- form.addEventListener('complete', this._onShellSave);
338
- });
339
330
  }
340
331
 
341
- private readonly _onShellSave = () => {
332
+ // Shell-saved forms: the shell fires a bubbling 'complete' on the form it
333
+ // submitted. composedPath()[0] recovers the form when the event was
334
+ // retargeted crossing a shadow boundary on its way to the document.
335
+ private readonly _onShellSave = (e: Event) => {
336
+ if (e === this._lastShellSave) return;
337
+ this._lastShellSave = e;
338
+ if (!this.refreshOn) return;
339
+ const origin = e.composedPath()[0];
340
+ if (!(origin instanceof HTMLFormElement) || !origin.matches(this.refreshOn)) return;
341
+ if (this._watchedForms.has(origin)) return; // auto-saved forms refresh via _save
342
342
  void this._sendConfig();
343
343
  };
344
344
 
@@ -248,6 +248,54 @@ describe('<zn-preview-frame>', () => {
248
248
  expect(posted[0]['type']).to.equal('hp-preview:config');
249
249
  });
250
250
 
251
+ // A uri tab panel puts the preview in the tabs component's shadow root while
252
+ // page-level forms (e.g. the page's Template select) stay in the document —
253
+ // a save out there must still refresh the preview.
254
+ it('refreshes on a shell save of a form outside the component\'s own root', async () => {
255
+ const wrapper = await fixture(html`
256
+ <div>
257
+ <form action="/save" method="post"><input name="template" value="flow"></form>
258
+ <div id="host"></div>
259
+ </div>`);
260
+
261
+ const shadow = wrapper.querySelector('#host')!.attachShadow({mode: 'open'});
262
+ const el = document.createElement('zn-preview-frame');
263
+ el.setAttribute('src', 'about:blank');
264
+ el.setAttribute('frame-origin', 'https://site.example');
265
+ el.setAttribute('data-uri', '/payload');
266
+ shadow.appendChild(el);
267
+ await (el as HTMLElement & {updateComplete: Promise<boolean>}).updateComplete;
268
+
269
+ wrapper.querySelector('form')!.dispatchEvent(
270
+ new CustomEvent('complete', {bubbles: true, detail: {}}));
271
+
272
+ await waitUntil(() => fetchCalls.length === 1);
273
+ expect(fetchCalls[0].uri).to.equal('/payload');
274
+ });
275
+
276
+ it('refreshes once for a composed complete event inside its own shadow root', async () => {
277
+ const wrapper = await fixture(html`<div><div id="host"></div></div>`);
278
+
279
+ const shadow = wrapper.querySelector('#host')!.attachShadow({mode: 'open'});
280
+ const form = document.createElement('form');
281
+ form.setAttribute('action', '/save');
282
+ shadow.appendChild(form);
283
+ const el = document.createElement('zn-preview-frame');
284
+ el.setAttribute('src', 'about:blank');
285
+ el.setAttribute('frame-origin', 'https://site.example');
286
+ el.setAttribute('data-uri', '/payload');
287
+ shadow.appendChild(el);
288
+ await (el as HTMLElement & {updateComplete: Promise<boolean>}).updateComplete;
289
+
290
+ // the shell fires composed events, so this reaches both the shadow root
291
+ // and the document — it must trigger a single refresh
292
+ form.dispatchEvent(new CustomEvent('complete', {bubbles: true, composed: true, detail: {}}));
293
+
294
+ await waitUntil(() => fetchCalls.length === 1);
295
+ await new Promise(resolve => setTimeout(resolve, 50));
296
+ expect(fetchCalls.length).to.equal(1);
297
+ });
298
+
251
299
  it('does not intercept the submit of a refresh-on form', async () => {
252
300
  const wrapper = await fixture(html`
253
301
  <div>
@@ -137,6 +137,21 @@ export default class ZnSlashMenu extends ZincElement {
137
137
  disconnectedCallback() {
138
138
  super.disconnectedCallback();
139
139
  this.stopPositioner();
140
+ this.hidePanelPopover();
141
+ }
142
+
143
+ private showPanelPopover() {
144
+ const panel = this.panel;
145
+ if (typeof panel?.showPopover === 'function' && !panel.matches(':popover-open')) {
146
+ panel.showPopover();
147
+ }
148
+ }
149
+
150
+ private hidePanelPopover() {
151
+ const panel = this.panel;
152
+ if (typeof panel?.hidePopover === 'function' && panel.matches(':popover-open')) {
153
+ panel.hidePopover();
154
+ }
140
155
  }
141
156
 
142
157
  private startPositioner() {
@@ -156,6 +171,8 @@ export default class ZnSlashMenu extends ZincElement {
156
171
  const {anchor, panel} = this;
157
172
  if (!this.open || !anchor || !panel) return;
158
173
 
174
+ this.showPanelPopover();
175
+
159
176
  const {x, y} = await computePosition(anchor, panel, {
160
177
  placement: this.placement,
161
178
  strategy: 'fixed',
@@ -233,20 +250,20 @@ export default class ZnSlashMenu extends ZincElement {
233
250
  protected updated(changed: PropertyValues) {
234
251
  super.updated(changed);
235
252
 
236
- // A new list, or a reopen, starts at the top rather than wherever the last one was scrolled to
237
- if (changed.has('items') || (changed.has('open') && this.open)) {
238
- this.scrollActiveIntoView();
239
- }
240
-
241
253
  if (changed.has('open') || changed.has('anchor')) {
242
254
  if (this.open) {
243
255
  this.startPositioner();
244
256
  } else {
245
257
  this.stopPositioner();
258
+ this.hidePanelPopover();
246
259
  }
247
260
  }
248
261
 
249
262
  if (this.open) void this.position();
263
+
264
+ if (changed.has('items') || (changed.has('open') && this.open)) {
265
+ this.scrollActiveIntoView();
266
+ }
250
267
  }
251
268
 
252
269
  private renderItem(item: SlashMenuItem, index: number, showIcons: boolean) {
@@ -310,6 +327,7 @@ export default class ZnSlashMenu extends ZincElement {
310
327
  <div
311
328
  part="panel"
312
329
  class="slash-menu__panel"
330
+ popover="manual"
313
331
  role="listbox"
314
332
  aria-hidden=${this.open ? 'false' : 'true'}
315
333
  aria-label=${this.query ? `Matches for ${this.query}` : this.heading}>
@@ -15,8 +15,8 @@
15
15
  display: flex;
16
16
  flex-direction: column;
17
17
  position: fixed;
18
- top: 0;
19
- left: 0;
18
+ inset: 0 auto auto 0;
19
+ margin: 0;
20
20
  width: var(--slash-menu-width);
21
21
  max-width: var(--auto-size-available-width, none);
22
22
  max-height: min(var(--slash-menu-max-height), var(--auto-size-available-height, 100vh));