@kubex/zinc 1.1.84 → 1.1.86

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubex/zinc",
3
- "version": "1.1.84",
3
+ "version": "1.1.86",
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",
@@ -141,6 +141,12 @@
141
141
  // rather than the navbar's default body colour.
142
142
  --zn-active-nav-background: var(--zn-panel);
143
143
 
144
+ // Themes with a glass page tab bar (e.g. Aura) frost inactive tabs on every
145
+ // navbar under a zn-page — over the panel the translucent white just reads
146
+ // as white. Pin the standard tab tint instead.
147
+ --navbar-page-inactive-tab-background: rgb(var(--zn-color-tab));
148
+ --navbar-page-inactive-tab-filter: none;
149
+
144
150
  border-right: 1px solid rgb(var(--zn-border-color));
145
151
 
146
152
  // The registry-driven steps panel fills the column below the title block.
@@ -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>
@@ -5,6 +5,7 @@ import { MutationController } from '@lit-labs/observers/mutation-controller.js';
5
5
  import { property, query, queryAll, state } from 'lit/decorators.js';
6
6
  import ZincElement from '../../internal/zinc-element';
7
7
  import ZnButton from '../button';
8
+ import ZnButtonGroup from '../button-group';
8
9
  import ZnCollapsible from '../collapsible';
9
10
  import ZnIcon from '../icon';
10
11
  import ZnNavbar from '../navbar';
@@ -84,6 +85,7 @@ interface DerivedStructure {
84
85
  * @dependency zn-preview-frame
85
86
  * @dependency zn-icon
86
87
  * @dependency zn-button
88
+ * @dependency zn-button-group
87
89
  * @dependency zn-tabs
88
90
  * @dependency zn-navbar
89
91
  * @dependency zn-select
@@ -135,6 +137,7 @@ export default class ZnThemeEditor extends ZincElement {
135
137
  'zn-preview-frame': ZnPreviewFrame,
136
138
  'zn-icon': ZnIcon,
137
139
  'zn-button': ZnButton,
140
+ 'zn-button-group': ZnButtonGroup,
138
141
  'zn-tabs': ZnTabs,
139
142
  'zn-navbar': ZnNavbar,
140
143
  'zn-select': ZnSelect,
@@ -822,13 +825,14 @@ export default class ZnThemeEditor extends ZincElement {
822
825
  <div class="editor__controls-inner">
823
826
  <div part="controls-header" class="editor__controls-header">
824
827
  ${this.controlsCaption ? html`<span class="editor__caption">${this.controlsCaption}</span>` : nothing}
825
- <button type="button"
826
- class="editor__mode"
827
- data-mode-toggle
828
- aria-label="${this.mode === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}"
829
- @click="${this._toggleMode}">
830
- <zn-icon src="${this.mode === 'dark' ? 'sun' : 'moon'}" library="lucide" size="16"></zn-icon>
831
- </button>
828
+ <zn-button class="editor__mode"
829
+ icon-button="small"
830
+ icon-size="20"
831
+ icon="${this.mode === 'dark' ? 'sun@lu' : 'moon@lu'}"
832
+ data-mode-toggle
833
+ tooltip="${this.mode === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}"
834
+ aria-label="${this.mode === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}"
835
+ @click="${this._toggleMode}"></zn-button>
832
836
  </div>
833
837
  <div class="editor__controls-body">
834
838
  <div
@@ -870,17 +874,17 @@ export default class ZnThemeEditor extends ZincElement {
870
874
  <div part="toolbar" class="editor__toolbar">
871
875
  ${this.previewCaption ? html`<span class="editor__caption">${this.previewCaption}</span>` : nothing}
872
876
  <div class="editor__toolbar-actions">
873
- <div class="editor__devices" role="group" aria-label="Preview width">
877
+ <zn-button-group class="editor__devices" aria-label="Preview width">
874
878
  ${DEVICES.map(d => html`
875
- <button type="button"
876
- class="editor__device"
877
- data-device="${d.id}"
878
- aria-label="${d.label}"
879
- aria-pressed="${this.device === d.id ? 'true' : 'false'}"
880
- @click="${() => this._setDevice(d.id)}">
881
- <zn-icon src="${d.icon}" library="lucide" size="16"></zn-icon>
882
- </button>`)}
883
- </div>
879
+ <zn-button class="editor__device"
880
+ icon-button="small"
881
+ icon-size="20"
882
+ icon="${d.icon}@lu"
883
+ data-device="${d.id}"
884
+ aria-label="${d.label}"
885
+ aria-pressed="${this.device === d.id ? 'true' : 'false'}"
886
+ @click="${() => this._setDevice(d.id)}"></zn-button>`)}
887
+ </zn-button-group>
884
888
  ${this._sourcesSafe().length > 0 ? html`
885
889
  <zn-select
886
890
  class="editor__sources"
@@ -70,6 +70,12 @@
70
70
  // rather than the navbar's default body colour (flow-builder pattern).
71
71
  --zn-active-nav-background: var(--zn-panel);
72
72
 
73
+ // Themes with a glass page tab bar (e.g. Aura) frost inactive tabs on every
74
+ // navbar under a zn-page — over the editor's opaque panel the translucent
75
+ // white just reads as white. Pin the standard tab tint instead.
76
+ --navbar-page-inactive-tab-background: rgb(var(--zn-color-tab));
77
+ --navbar-page-inactive-tab-filter: none;
78
+
73
79
  position: relative;
74
80
  display: flex;
75
81
  flex-direction: column;
@@ -235,44 +241,21 @@
235
241
  }
236
242
  }
237
243
 
238
- .editor__devices {
239
- display: flex;
240
- border: 1px solid rgb(var(--zn-border-color));
241
- border-radius: 4px;
242
- overflow: hidden;
243
- }
244
-
245
- .editor__device,
246
- .editor__mode {
247
- display: flex;
248
- align-items: center;
249
- justify-content: center;
250
- width: 30px;
251
- height: 28px;
252
- padding: 0;
253
- border: 0;
254
- background: transparent;
255
- color: rgb(var(--zn-text));
256
- cursor: pointer;
257
- }
258
-
259
- .editor__device + .editor__device {
260
- border-left: 1px solid rgb(var(--zn-border-color));
261
- }
262
-
263
- .editor__device:hover,
264
- .editor__mode:hover {
265
- background: rgba(var(--zn-border-color), 0.5);
266
- }
267
-
244
+ // The device switcher is zn-button[icon-button="small"]s joined into a
245
+ // segmented control by zn-button-group; only the active fill is styled here.
268
246
  .editor__device[aria-pressed="true"] {
269
- background: rgb(var(--zn-primary));
270
- color: white;
247
+ // The button's shadow border-color is !important, so it can only be
248
+ // overridden through its own custom property.
249
+ --zn-button-border-color: rgb(var(--zn-primary));
250
+
251
+ // !important so the fill also beats the button's shadow hover background.
252
+ &::part(base) {
253
+ background: rgb(var(--zn-primary)) !important;
254
+ color: white !important;
255
+ }
271
256
  }
272
257
 
273
258
  .editor__mode {
274
- border: 1px solid rgb(var(--zn-border-color));
275
- border-radius: 4px;
276
259
  // Sole occupant of .editor__controls-header's right side, beside the caption.
277
260
  margin-left: auto;
278
261
  }