@kubex/zinc 1.1.160 → 1.1.161

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.160",
3
+ "version": "1.1.161",
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",
@@ -128,6 +128,9 @@ export default class ZnButton extends ZincElement implements ZincFormControl {
128
128
  /** Accessible name for the button. Icon-only buttons fall back to `tooltip`. */
129
129
  @property() label: string;
130
130
 
131
+ /** Toggle state, forwarded to the inner button's `aria-pressed`. */
132
+ @property({attribute: 'aria-pressed'}) pressed: 'true' | 'false' | 'mixed';
133
+
131
134
  // Auto Click Specific
132
135
  @property({type: Boolean, attribute: 'auto-click'}) autoClick = false;
133
136
  @property({type: Number, attribute: 'auto-click-delay'}) autoClickDelay = 2000;
@@ -436,6 +439,7 @@ export default class ZnButton extends ZincElement implements ZincFormControl {
436
439
  rel="${ifDefined(isLink ? this.rel : undefined)}"
437
440
  gaid="${ifDefined(this.gaid)}"
438
441
  aria-label="${ifDefined(ariaLabel)}"
442
+ aria-pressed="${ifDefined(isLink ? undefined : this.pressed)}"
439
443
  data-notification="${ifDefined(notification)}"
440
444
  disabled="${this.disabled || nothing}"
441
445
  style=${styleMap(buttonStyles)}
@@ -29,4 +29,14 @@ describe('<zn-button>', () => {
29
29
  expect(el.color).to.equal('default');
30
30
  });
31
31
 
32
+ it('forwards aria-pressed to the inner button', async () => {
33
+ const el = await fixture<ZnButton>(html`<zn-button icon="sun" label="Toggle" aria-pressed="true"></zn-button>`);
34
+ const base = el.shadowRoot!.querySelector('[part~="base"]')!;
35
+ expect(base.getAttribute('aria-pressed')).to.equal('true');
36
+
37
+ el.setAttribute('aria-pressed', 'false');
38
+ await el.updateComplete;
39
+ expect(base.getAttribute('aria-pressed')).to.equal('false');
40
+ });
41
+
32
42
  });
@@ -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;
@@ -19,7 +19,7 @@ describe('<zn-flow-node>', () => {
19
19
  expect(el).to.exist;
20
20
  });
21
21
 
22
- it('should render the type label and one port per output', async () => {
22
+ it('should render the type label and a single output stem', async () => {
23
23
  const el = await fixture<ZnFlowNode>(html`
24
24
  <zn-flow-node></zn-flow-node>`);
25
25
  el.node = node;
@@ -27,8 +27,7 @@ describe('<zn-flow-node>', () => {
27
27
  await el.updateComplete;
28
28
 
29
29
  expect(el.shadowRoot?.querySelector('.title')?.textContent).to.contain('Conditional Split');
30
- expect(el.shadowRoot?.querySelectorAll('.port--out')?.length).to.equal(2);
31
- expect(el.shadowRoot?.querySelector('.port-label')?.textContent).to.contain('TRUE');
30
+ expect(el.shadowRoot?.querySelectorAll('.port--out')?.length).to.equal(1);
32
31
  });
33
32
 
34
33
  it('should emit flow-node-grab when the card body is pressed', async () => {
@@ -1,20 +1,26 @@
1
1
  import '../../../../../dist/zn.min.js';
2
2
  import {expect, fixture, html} from '@open-wc/testing';
3
+ import type ZnCollapsible from '../../../collapsible/collapsible.component';
3
4
  import type ZnFlowStepGroup from './flow-step-group.component';
4
5
 
6
+ async function collapsibleOf(el: ZnFlowStepGroup): Promise<ZnCollapsible> {
7
+ const collapsible = el.shadowRoot!.querySelector<ZnCollapsible>('zn-collapsible')!;
8
+ await collapsible.updateComplete;
9
+ return collapsible;
10
+ }
11
+
5
12
  describe('<zn-flow-step-group>', () => {
6
- it('should render a component with its caption', async () => {
13
+ it('should render a collapsible with its caption', async () => {
7
14
  const el = await fixture<ZnFlowStepGroup>(html`
8
15
  <zn-flow-step-group caption="Contacts"></zn-flow-step-group>`);
9
- expect(el.shadowRoot?.querySelector('.header')?.textContent).to.contain('Contacts');
16
+ const collapsible = await collapsibleOf(el);
17
+ expect(collapsible.caption).to.equal('Contacts');
18
+ expect(collapsible.expanded).to.equal(true);
10
19
  });
11
20
 
12
- it('should toggle collapsed when the header is clicked', async () => {
21
+ it('should start closed when collapsed', async () => {
13
22
  const el = await fixture<ZnFlowStepGroup>(html`
14
- <zn-flow-step-group caption="Contacts"></zn-flow-step-group>`);
15
- const header = el.shadowRoot?.querySelector('.header') as HTMLElement;
16
- header.click();
17
- await el.updateComplete;
18
- expect(el.collapsed).to.equal(true);
23
+ <zn-flow-step-group caption="Contacts" collapsed></zn-flow-step-group>`);
24
+ expect((await collapsibleOf(el)).expanded).to.equal(false);
19
25
  });
20
26
  });
@@ -5,6 +5,9 @@ import { expect, fixture, html, oneEvent } from '@open-wc/testing';
5
5
  const clickButton = (button: HTMLElement) =>
6
6
  button.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true, cancelable: true }));
7
7
 
8
+ // The test harness loads no theme stylesheet, so a layout test stamps on the spacing token a themed page provides.
9
+ const spacingTokens = '--zn-spacing-small: 16px;';
10
+
8
11
  describe('<zn-form-actions>', () => {
9
12
  it('should render a component', async () => {
10
13
  const el = await fixture(html` <zn-form-actions></zn-form-actions> `);
@@ -24,7 +27,7 @@ describe('<zn-form-actions>', () => {
24
27
 
25
28
  it('holds align="start" children left and the rest right, one gap apart on each side', async () => {
26
29
  const el = await fixture<HTMLElement>(html`
27
- <zn-form-actions with-cancel style="width: 600px">
30
+ <zn-form-actions with-cancel style="width: 600px; ${spacingTokens}">
28
31
  <zn-button align="start" id="translate">Translate</zn-button>
29
32
  <zn-button align="start" id="slug">Slug</zn-button>
30
33
  </zn-form-actions>
@@ -54,12 +54,18 @@ export default class ZnFormGroup extends ZincElement {
54
54
  private rebind: boolean = false;
55
55
  private offset: number = 0;
56
56
  private resizeObserver: ResizeObserver | null = null;
57
+ private observedAncestors: Set<HTMLElement> = new Set();
57
58
 
58
59
  connectedCallback() {
59
60
  super.connectedCallback();
60
61
 
61
- // Whether an ancestor scrolls depends on how tall this form has grown.
62
- this.resizeObserver ??= new ResizeObserver(() => this.schedule(true));
62
+ // Whether an ancestor scrolls depends on how tall this form has grown. Re-measured before paint rather than on the
63
+ // next frame, or the label is drawn for a frame against the old layout and flickers.
64
+ this.resizeObserver ??= new ResizeObserver(() => {
65
+ this.rebind = false;
66
+ this.bind();
67
+ this.positionLabel();
68
+ });
63
69
  this.resizeObserver.observe(this);
64
70
  window.addEventListener('resize', this.onViewportResize);
65
71
  }
@@ -67,6 +73,7 @@ export default class ZnFormGroup extends ZincElement {
67
73
  disconnectedCallback() {
68
74
  super.disconnectedCallback();
69
75
  this.resizeObserver?.disconnect();
76
+ this.observedAncestors.clear();
70
77
  window.removeEventListener('resize', this.onViewportResize);
71
78
  this.detach();
72
79
  cancelAnimationFrame(this.frame);
@@ -82,7 +89,7 @@ export default class ZnFormGroup extends ZincElement {
82
89
  return this.shadowRoot?.querySelector('.form-control__text') ?? null;
83
90
  }
84
91
 
85
- /** Coalesces scroll and resize work into one frame, and out of the ResizeObserver callback. */
92
+ /** Coalesces scroll and resize work into one frame. */
86
93
  private schedule(rebind: boolean = false) {
87
94
  this.rebind ||= rebind;
88
95
  if (this.frame) return;
@@ -120,6 +127,7 @@ export default class ZnFormGroup extends ZincElement {
120
127
  // A `top` inset against a box that never scrolls has nothing to hold the label back from:
121
128
  // it only pushes the label down the page, so drop it and stand in for sticky below.
122
129
  column.style.top = '0px';
130
+ this.observeAncestors(column, scroller);
123
131
  if (!scroller) return;
124
132
 
125
133
  this.tracked = scroller;
@@ -154,24 +162,26 @@ export default class ZnFormGroup extends ZincElement {
154
162
  // The stylesheet drops sticky while the columns are stacked, and there is nothing to follow.
155
163
  if (getComputedStyle(column).position !== 'sticky') return null;
156
164
 
165
+ if (!('rangeStart' in Animation.prototype)) return null;
166
+
157
167
  const travel = Math.max(0, fieldset.clientHeight - column.offsetHeight);
158
- const range = scroller.scrollHeight - scroller.clientHeight;
159
- if (travel < 1 || range < 1) return null;
168
+ if (travel < 1) return null;
160
169
 
161
170
  const start = scroller.scrollTop + column.getBoundingClientRect().top
162
171
  - this.visibleTop(scroller) - this.stickyTop;
163
- const held = (scroll: number) => Math.min(Math.max(scroll - start, 0), travel);
164
172
 
165
- const knees = [start, start + travel].filter(scroll => scroll > 0 && scroll < range);
166
- const keyframes = [0, ...knees, range].map(scroll => ({
167
- offset: Math.min(Math.max(scroll / range, 0), 1),
168
- transform: `translateY(${held(scroll)}px)`
169
- }));
170
-
171
- return new Animation(
172
- new KeyframeEffect(column, keyframes, { fill: 'both' }),
173
+ const animation = new Animation(
174
+ new KeyframeEffect(column, [
175
+ { transform: 'translateY(0px)' },
176
+ { transform: `translateY(${travel}px)` }
177
+ ], { fill: 'both' }),
173
178
  new ScrollTimeline({ source: scroller, axis: 'block' })
174
- );
179
+ ) as Animation & { rangeStart: string; rangeEnd: string };
180
+
181
+ // In scroll pixels rather than progress, so the scroller growing or shrinking can't shift the label.
182
+ animation.rangeStart = `${start}px`;
183
+ animation.rangeEnd = `${start + travel}px`;
184
+ return animation;
175
185
  }
176
186
 
177
187
  /** Drops everything this component has put on the label or on the scroller. */
@@ -190,6 +200,26 @@ export default class ZnFormGroup extends ZincElement {
190
200
  column.style.transform = '';
191
201
  }
192
202
 
203
+ /**
204
+ * The label's travel is measured against the scroller, so content growing elsewhere in it — a sibling group revealing
205
+ * more fields — leaves the label at a stale position until something re-measures. Any such growth resizes a box
206
+ * between this group and the scroller.
207
+ */
208
+ private observeAncestors(from: HTMLElement, scroller: HTMLElement | null) {
209
+ const ancestors = this.ancestors(from);
210
+ const end = scroller ? ancestors.indexOf(scroller) : -1;
211
+ const wanted = new Set(ancestors.slice(0, end < 0 ? undefined : end).filter(element => element !== this));
212
+
213
+ // Observing fires once straight away, so re-observing on every bind would rebind every frame.
214
+ for (const element of this.observedAncestors) {
215
+ if (!wanted.has(element)) this.resizeObserver?.unobserve(element);
216
+ }
217
+ for (const element of wanted) {
218
+ if (!this.observedAncestors.has(element)) this.resizeObserver?.observe(element);
219
+ }
220
+ this.observedAncestors = wanted;
221
+ }
222
+
193
223
  /** The document scrolls through the window, every other scroller reports its own events. */
194
224
  private scrollTarget(scroller: HTMLElement | null): EventTarget | null {
195
225
  if (!scroller) return null;
@@ -154,7 +154,7 @@ describe('<zn-navbar>', () => {
154
154
  expect(nav.classList.contains('has-hidden')).to.equal(true);
155
155
  });
156
156
 
157
- it('rounds the last visible nav item when items are hidden behind more', async () => {
157
+ it('rounds the more button instead of the last visible nav item when items are hidden', async () => {
158
158
  const el = await fixture<ZnNavbar>(html`
159
159
  <zn-navbar>
160
160
  <li>One</li>
@@ -187,7 +187,8 @@ describe('<zn-navbar>', () => {
187
187
  expect(nav.classList.contains('has-hidden')).to.equal(true);
188
188
  expect(items[0].classList.contains('last-visible')).to.equal(true);
189
189
  expect(items[1].classList.contains('hidden')).to.equal(true);
190
- expect(getComputedStyle(items[0]).borderTopRightRadius).to.equal('6px');
190
+ expect(getComputedStyle(items[0]).borderTopRightRadius).to.equal('0px');
191
+ expect(getComputedStyle(more).borderTopRightRadius).to.equal('6px');
191
192
  });
192
193
 
193
194
  it('keeps the left border when only the more button is visible', async () => {
@@ -538,7 +538,7 @@ export default class ZnPage extends ZnTabs {
538
538
  </div>
539
539
 
540
540
  <div class="page__tabs">
541
- <div id="content" class="page__content" part="content">
541
+ <div id="content" class="page__content" part="content" @scroll="${this.handlePageScroll}">
542
542
  ${this.tabDefinitions
543
543
  .filter(tab => tab.slotName !== null)
544
544
  .map(tab => html`
@@ -17,9 +17,9 @@ describe('<zn-page>', () => {
17
17
  await aTimeout(20);
18
18
 
19
19
  const header = el.shadowRoot!.querySelector('[part="header"]')!;
20
- const caption = el.shadowRoot!.querySelector('[part="header-caption"]')!;
21
- const description = el.shadowRoot!.querySelector('[part="header-description"]')!;
22
- const headerRight = el.shadowRoot!.querySelector('[part="header-right"]')!;
20
+ const caption = el.shadowRoot!.querySelector('.header__caption')!;
21
+ const description = el.shadowRoot!.querySelector('.header__description')!;
22
+ const headerRight = el.shadowRoot!.querySelector('.header__right')!;
23
23
  const navbar = el.shadowRoot!.querySelector('zn-navbar')!;
24
24
  const navItems = navbar.shadowRoot!.querySelectorAll('li:not(.more)');
25
25
  const actionsSlot = el.shadowRoot!.querySelector<HTMLSlotElement>('slot[name="actions"]')!;
@@ -588,25 +588,25 @@ describe('<zn-page>', () => {
588
588
  `);
589
589
  await aTimeout(20);
590
590
 
591
- const page = el.shadowRoot!.querySelector<HTMLElement>('.page')!;
591
+ const content = el.shadowRoot!.querySelector<HTMLElement>('#content')!;
592
592
  const header = el.shadowRoot!.querySelector<HTMLElement>('.page__header')!;
593
593
 
594
594
  expect(header.classList.contains('page__header--scrolled-no-navigation')).to.equal(false);
595
595
 
596
- page.scrollTop = 10;
597
- page.dispatchEvent(new Event('scroll'));
596
+ content.scrollTop = 10;
597
+ content.dispatchEvent(new Event('scroll'));
598
598
  await el.updateComplete;
599
599
 
600
600
  expect(header.classList.contains('page__header--scrolled-no-navigation')).to.equal(false);
601
601
 
602
- page.scrollTop = 25;
603
- page.dispatchEvent(new Event('scroll'));
602
+ content.scrollTop = 25;
603
+ content.dispatchEvent(new Event('scroll'));
604
604
  await el.updateComplete;
605
605
 
606
606
  expect(header.classList.contains('page__header--scrolled-no-navigation')).to.equal(true);
607
607
 
608
- page.scrollTop = 0;
609
- page.dispatchEvent(new Event('scroll'));
608
+ content.scrollTop = 0;
609
+ content.dispatchEvent(new Event('scroll'));
610
610
  await el.updateComplete;
611
611
 
612
612
  expect(header.classList.contains('page__header--scrolled-no-navigation')).to.equal(false);
@@ -656,18 +656,18 @@ describe('<zn-page>', () => {
656
656
  await aTimeout(20);
657
657
 
658
658
  const page = el.querySelector<ZnPage>('zn-page')!;
659
- const scroller = page.shadowRoot!.querySelector<HTMLElement>('.page')!;
659
+ const content = page.shadowRoot!.querySelector<HTMLElement>('.page__tabs')!;
660
660
  const header = page.shadowRoot!.querySelector<HTMLElement>('.page__header')!;
661
661
 
662
- expect(getComputedStyle(scroller).backgroundColor).to.equal('rgb(12, 34, 56)');
662
+ expect(getComputedStyle(content).backgroundColor).to.equal('rgb(12, 34, 56)');
663
663
  expect(getComputedStyle(header).backgroundColor).to.equal('rgba(12, 34, 56, 0.95)');
664
664
  });
665
665
 
666
- it('uses the panel background when placed inside a panel', async () => {
666
+ it('uses the panel background for a page flagged as sitting in a panel', async () => {
667
667
  const el = await fixture<HTMLElement>(html`
668
668
  <div style="--zn-body: 12, 34, 56; --zn-panel: 1, 2, 3;">
669
669
  <zn-panel flush>
670
- <zn-page caption="Page Title" modal>
670
+ <zn-page caption="Page Title" modal panel panel-head>
671
671
  <zn-tab caption="Overview">Overview Content</zn-tab>
672
672
  </zn-page>
673
673
  </zn-panel>
@@ -676,10 +676,10 @@ describe('<zn-page>', () => {
676
676
  await aTimeout(20);
677
677
 
678
678
  const page = el.querySelector<ZnPage>('zn-page')!;
679
- const scroller = page.shadowRoot!.querySelector<HTMLElement>('.page')!;
679
+ const content = page.shadowRoot!.querySelector<HTMLElement>('.page__tabs')!;
680
680
  const header = page.shadowRoot!.querySelector<HTMLElement>('.page__header')!;
681
681
 
682
- expect(getComputedStyle(scroller).backgroundColor).to.equal('rgb(1, 2, 3)');
682
+ expect(getComputedStyle(content).backgroundColor).to.equal('rgb(1, 2, 3)');
683
683
  expect(getComputedStyle(header).backgroundColor).to.equal('rgba(1, 2, 3, 0.95)');
684
684
  });
685
685
 
@@ -39,10 +39,10 @@ describe('<zn-sp>', () => {
39
39
  expect(getComputedStyle(parent.querySelector('form')!).display).to.equal('block');
40
40
  });
41
41
 
42
- it('should fill its parent container height', async () => {
42
+ it('should fill its parent container height when growing', async () => {
43
43
  const parent = await fixture(html`
44
44
  <div style="height: 300px;">
45
- <zn-sp>
45
+ <zn-sp grow>
46
46
  <div>Content</div>
47
47
  </zn-sp>
48
48
  </div>
@@ -830,8 +830,8 @@ export default class ZnThemeEditor extends ZincElement {
830
830
  icon-size="20"
831
831
  icon="${this.mode === 'dark' ? 'sun@lu' : 'moon@lu'}"
832
832
  data-mode-toggle
833
+ label="${this.mode === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}"
833
834
  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
835
  @click="${this._toggleMode}"></zn-button>
836
836
  </div>
837
837
  <div class="editor__controls-body">
@@ -881,7 +881,8 @@ export default class ZnThemeEditor extends ZincElement {
881
881
  icon-size="20"
882
882
  icon="${d.icon}@lu"
883
883
  data-device="${d.id}"
884
- aria-label="${d.label}"
884
+ label="${d.label}"
885
+ tooltip="${d.label}"
885
886
  aria-pressed="${this.device === d.id ? 'true' : 'false'}"
886
887
  @click="${() => this._setDevice(d.id)}"></zn-button>`)}
887
888
  </zn-button-group>
@@ -19,6 +19,11 @@ function spyOnFrame(el: Element): ThemeCall[] {
19
19
  return calls;
20
20
  }
21
21
 
22
+ // zn-button overrides click() without dispatching a DOM event, so fire a real one.
23
+ function press(el: Element) {
24
+ el.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true}));
25
+ }
26
+
22
27
  describe('<zn-theme-editor>', () => {
23
28
  const FIXTURE = html`
24
29
  <zn-theme-editor src="about:blank" frame-origin="https://site.example" debounce="10">
@@ -174,7 +179,7 @@ describe('<zn-theme-editor>', () => {
174
179
  const el = await fixture(FIXTURE);
175
180
  const calls = spyOnFrame(el);
176
181
 
177
- el.shadowRoot!.querySelector<HTMLButtonElement>('[data-device="mobile"]')!.click();
182
+ press(el.shadowRoot!.querySelector('[data-device="mobile"]')!);
178
183
  await (el as HTMLElement & {updateComplete: Promise<unknown>}).updateComplete;
179
184
 
180
185
  expect((el as HTMLElement & {device: string}).device).to.equal('mobile');
@@ -192,7 +197,7 @@ describe('<zn-theme-editor>', () => {
192
197
  detail = (e as CustomEvent<{device: string}>).detail;
193
198
  });
194
199
 
195
- el.shadowRoot!.querySelector<HTMLButtonElement>('[data-device="tablet"]')!.click();
200
+ press(el.shadowRoot!.querySelector('[data-device="tablet"]')!);
196
201
 
197
202
  await waitUntil(() => detail !== null);
198
203
  expect(detail!.device).to.equal('tablet');
@@ -205,7 +210,7 @@ describe('<zn-theme-editor>', () => {
205
210
  expect(desktop.getAttribute('aria-pressed')).to.equal('true');
206
211
  expect(mobile.getAttribute('aria-pressed')).to.equal('false');
207
212
 
208
- mobile.click();
213
+ press(mobile);
209
214
  await (el as HTMLElement & {updateComplete: Promise<unknown>}).updateComplete;
210
215
 
211
216
  expect(desktop.getAttribute('aria-pressed')).to.equal('false');
@@ -216,7 +221,7 @@ describe('<zn-theme-editor>', () => {
216
221
  const el = await fixture(FIXTURE);
217
222
  const calls = spyOnFrame(el);
218
223
 
219
- el.shadowRoot!.querySelector<HTMLButtonElement>('[data-mode-toggle]')!.click();
224
+ press(el.shadowRoot!.querySelector('[data-mode-toggle]')!);
220
225
 
221
226
  await waitUntil(() => calls.length === 1);
222
227
  expect((el as HTMLElement & {mode: string}).mode).to.equal('dark');
@@ -232,7 +237,7 @@ describe('<zn-theme-editor>', () => {
232
237
  </zn-theme-editor>`);
233
238
  const calls = spyOnFrame(el);
234
239
 
235
- el.shadowRoot!.querySelector<HTMLButtonElement>('[data-mode-toggle]')!.click();
240
+ press(el.shadowRoot!.querySelector('[data-mode-toggle]')!);
236
241
  await waitUntil(() => calls.length === 1);
237
242
 
238
243
  expect(calls[0]['values']).to.deep.equal({background: '#000000'});
@@ -256,7 +261,7 @@ describe('<zn-theme-editor>', () => {
256
261
  const el = await fixture(FIXTURE);
257
262
  const calls = spyOnFrame(el);
258
263
 
259
- el.shadowRoot!.querySelector<HTMLButtonElement>('[data-mode-toggle]')!.click();
264
+ press(el.shadowRoot!.querySelector('[data-mode-toggle]')!);
260
265
  await waitUntil(() => calls.length === 1);
261
266
 
262
267
  expect(calls[0]['values']).to.deep.equal({radius: '8'});
@@ -269,7 +274,7 @@ describe('<zn-theme-editor>', () => {
269
274
  const calls = spyOnFrame(el);
270
275
  const input = el.querySelector('zn-input')! as HTMLElement & {value: string};
271
276
 
272
- el.shadowRoot!.querySelector<HTMLButtonElement>('[data-mode-toggle]')!.click(); // -> dark, radius '8'
277
+ press(el.shadowRoot!.querySelector('[data-mode-toggle]')!); // -> dark, radius '8'
273
278
  await waitUntil(() => calls.length === 1);
274
279
 
275
280
  input.value = '99';
@@ -277,7 +282,7 @@ describe('<zn-theme-editor>', () => {
277
282
  await waitUntil(() => calls.length === 2);
278
283
  expect(calls[1]['values']).to.deep.equal({radius: '99'});
279
284
 
280
- el.shadowRoot!.querySelector<HTMLButtonElement>('[data-mode-toggle]')!.click(); // -> light, untouched
285
+ press(el.shadowRoot!.querySelector('[data-mode-toggle]')!); // -> light, untouched
281
286
  await waitUntil(() => calls.length === 3);
282
287
  expect(calls[2]['values']).to.deep.equal({radius: '8'});
283
288
  expect(input.value).to.equal('8');
@@ -290,7 +295,7 @@ describe('<zn-theme-editor>', () => {
290
295
  </zn-theme-editor>`);
291
296
  const calls = spyOnFrame(el);
292
297
 
293
- el.shadowRoot!.querySelector<HTMLButtonElement>('[data-mode-toggle]')!.click();
298
+ press(el.shadowRoot!.querySelector('[data-mode-toggle]')!);
294
299
  await waitUntil(() => calls.length === 1);
295
300
 
296
301
  expect(calls[0]['values']).to.deep.equal({rounded: true});
@@ -305,7 +310,7 @@ describe('<zn-theme-editor>', () => {
305
310
  </zn-theme-editor>`);
306
311
  const calls = spyOnFrame(el);
307
312
 
308
- el.shadowRoot!.querySelector<HTMLButtonElement>('[data-mode-toggle]')!.click();
313
+ press(el.shadowRoot!.querySelector('[data-mode-toggle]')!);
309
314
  await waitUntil(() => calls.length === 1);
310
315
 
311
316
  expect(calls[0]['values']).to.deep.equal({rounded: true});
@@ -334,7 +339,7 @@ describe('<zn-theme-editor>', () => {
334
339
  input.value = '99';
335
340
  input.dispatchEvent(new CustomEvent('zn-input', {bubbles: true, composed: true}));
336
341
  // toggle before the 50ms push debounce fires - no await in between
337
- el.shadowRoot!.querySelector<HTMLButtonElement>('[data-mode-toggle]')!.click();
342
+ press(el.shadowRoot!.querySelector('[data-mode-toggle]')!);
338
343
 
339
344
  await waitUntil(() => calls.length >= 1);
340
345
  // give the (now-cancelled) original debounce timer a chance to prove it's gone
@@ -572,7 +577,7 @@ describe('<zn-theme-editor>', () => {
572
577
  expect((el as HTMLElement & {values: {light: Record<string, unknown>; dark: Record<string, unknown>}}).values)
573
578
  .to.deep.equal({light: {accent: '#ffffff'}, dark: {accent: '#000000'}});
574
579
 
575
- el.shadowRoot!.querySelector<HTMLButtonElement>('[data-mode-toggle]')!.click();
580
+ press(el.shadowRoot!.querySelector('[data-mode-toggle]')!);
576
581
  await waitUntil(() => calls.length === 1);
577
582
 
578
583
  expect(calls[0]['values']).to.deep.equal({accent: '#000000'});
@@ -1378,7 +1383,7 @@ describe('<zn-theme-editor>', () => {
1378
1383
  </zn-theme-editor>`);
1379
1384
  spyOnFrame(el);
1380
1385
 
1381
- el.shadowRoot!.querySelector<HTMLButtonElement>('[data-mode-toggle]')!.click();
1386
+ press(el.shadowRoot!.querySelector('[data-mode-toggle]')!);
1382
1387
 
1383
1388
  await new Promise(resolve => setTimeout(resolve, 100));
1384
1389
  expect(fetchCalls.length).to.equal(0);
@@ -1400,7 +1405,7 @@ describe('<zn-theme-editor>', () => {
1400
1405
  </zn-theme-editor>`);
1401
1406
  spyOnFrame(el);
1402
1407
 
1403
- el.shadowRoot!.querySelector<HTMLButtonElement>('[data-mode-toggle]')!.click();
1408
+ press(el.shadowRoot!.querySelector('[data-mode-toggle]')!);
1404
1409
 
1405
1410
  await new Promise(resolve => setTimeout(resolve, 100));
1406
1411
  expect(fetchCalls.length).to.equal(0);
@@ -1423,7 +1428,7 @@ describe('<zn-theme-editor>', () => {
1423
1428
  </zn-theme-editor>`);
1424
1429
  const calls = spyOnFrame(el);
1425
1430
 
1426
- el.shadowRoot!.querySelector<HTMLButtonElement>('[data-mode-toggle]')!.click();
1431
+ press(el.shadowRoot!.querySelector('[data-mode-toggle]')!);
1427
1432
 
1428
1433
  await waitUntil(() => calls.length === 1);
1429
1434
  await new Promise(resolve => setTimeout(resolve, 100));
@@ -1443,8 +1448,8 @@ describe('<zn-theme-editor>', () => {
1443
1448
  </zn-theme-editor>`);
1444
1449
  spyOnFrame(el);
1445
1450
 
1446
- el.shadowRoot!.querySelector<HTMLButtonElement>('[data-mode-toggle]')!.click();
1447
- el.shadowRoot!.querySelector<HTMLButtonElement>('[data-device="mobile"]')!.click();
1451
+ press(el.shadowRoot!.querySelector('[data-mode-toggle]')!);
1452
+ press(el.shadowRoot!.querySelector('[data-device="mobile"]')!);
1448
1453
 
1449
1454
  await new Promise(resolve => setTimeout(resolve, 100));
1450
1455
  expect(fetchCalls.length).to.equal(0);
@@ -1522,11 +1527,8 @@ describe('<zn-theme-editor>', () => {
1522
1527
  let fetchCalls: {uri: string; init?: RequestInit}[];
1523
1528
  const realFetch = window.fetch;
1524
1529
 
1525
- // zn-button overrides click() to call its handler directly without dispatching -
1526
- // a real click event is needed for the theme-editor's own @click listener to fire.
1527
1530
  function clickButton(el: Element) {
1528
- const button = el.shadowRoot!.querySelector('zn-button')!;
1529
- button.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true}));
1531
+ press(el.shadowRoot!.querySelector('zn-button.editor__submit')!);
1530
1532
  }
1531
1533
 
1532
1534
  beforeEach(() => {
@@ -1543,7 +1545,7 @@ describe('<zn-theme-editor>', () => {
1543
1545
 
1544
1546
  it('renders no button without submit-label', async () => {
1545
1547
  const el = await fixture(FIXTURE);
1546
- expect(el.shadowRoot!.querySelector('zn-button')).to.not.exist;
1548
+ expect(el.shadowRoot!.querySelector('zn-button.editor__submit')).to.not.exist;
1547
1549
  });
1548
1550
 
1549
1551
  it('renders the button in the toolbar, not the footer, and leaves the footer slot working', async () => {
@@ -1553,11 +1555,11 @@ describe('<zn-theme-editor>', () => {
1553
1555
  <span slot="footer">extra</span>
1554
1556
  </zn-theme-editor>`);
1555
1557
 
1556
- const button = el.shadowRoot!.querySelector('zn-button')!;
1558
+ const button = el.shadowRoot!.querySelector('zn-button.editor__submit')!;
1557
1559
  expect(button).to.exist;
1558
1560
  expect(button.textContent?.trim()).to.equal('Save theme');
1559
- expect(el.shadowRoot!.querySelector('[part="toolbar"] zn-button')).to.equal(button);
1560
- expect(el.shadowRoot!.querySelector('[part="footer"] zn-button')).to.not.exist;
1561
+ expect(el.shadowRoot!.querySelector('[part="toolbar"] zn-button.editor__submit')).to.equal(button);
1562
+ expect(el.shadowRoot!.querySelector('[part="footer"] zn-button.editor__submit')).to.not.exist;
1561
1563
  expect(el.shadowRoot!.querySelector('[part="footer"] slot[name="footer"]')).to.exist;
1562
1564
  });
1563
1565
 
@@ -1567,7 +1569,7 @@ describe('<zn-theme-editor>', () => {
1567
1569
  <zn-input name="radius" label="Radius" value="8"></zn-input>
1568
1570
  </zn-theme-editor>`);
1569
1571
 
1570
- expect(el.shadowRoot!.querySelector('zn-button')).to.exist;
1572
+ expect(el.shadowRoot!.querySelector('zn-button.editor__submit')).to.exist;
1571
1573
  expect(el.shadowRoot!.querySelector('[part="footer"]')).to.not.exist;
1572
1574
  });
1573
1575
 
@@ -1681,7 +1683,7 @@ describe('<zn-theme-editor>', () => {
1681
1683
  await waitUntil(() => fetchCalls.length === 1);
1682
1684
  await (el as HTMLElement & {updateComplete: Promise<unknown>}).updateComplete;
1683
1685
 
1684
- const button = el.shadowRoot!.querySelector('zn-button')! as HTMLElement & {loading: boolean};
1686
+ const button = el.shadowRoot!.querySelector('zn-button.editor__submit')! as HTMLElement & {loading: boolean};
1685
1687
  expect(button.loading).to.be.true;
1686
1688
 
1687
1689
  clickButton(el); // second click while in flight - must not stack
@@ -1729,7 +1731,7 @@ describe('<zn-theme-editor>', () => {
1729
1731
 
1730
1732
  clickButton(el); // flushes '99' and bypasses the save debounce, but a save is already in flight
1731
1733
  await (el as HTMLElement & {updateComplete: Promise<unknown>}).updateComplete;
1732
- const button = el.shadowRoot!.querySelector('zn-button')! as HTMLElement & {loading: boolean};
1734
+ const button = el.shadowRoot!.querySelector('zn-button.editor__submit')! as HTMLElement & {loading: boolean};
1733
1735
  expect(button.loading).to.be.true;
1734
1736
 
1735
1737
  await new Promise(resolve => setTimeout(resolve, 20));
@@ -198,7 +198,6 @@ describe('<zn-translation-group>', () => {
198
198
  </zn-translation-group>`);
199
199
  // The input tokens live in the theme stylesheet, which the bundle under test does not carry.
200
200
  group.style.setProperty('--zn-input-background-color', 'rgb(1, 2, 3)');
201
- group.style.setProperty('--zn-shadow-x-small', '0 1px 2px rgb(7, 8, 9)');
202
201
  group.style.setProperty('--zn-input-border-width', '1px');
203
202
  group.style.setProperty('--zn-input-border-color', 'rgb(9, 9, 9)');
204
203
  await group.updateComplete;
@@ -208,7 +207,6 @@ describe('<zn-translation-group>', () => {
208
207
  const styles = getComputedStyle(select.shadowRoot!.querySelector<HTMLElement>('[part~="combobox"]')!);
209
208
 
210
209
  expect(styles.backgroundColor, 'the fill').to.equal('rgb(1, 2, 3)');
211
- expect(styles.boxShadow, 'the shadow').to.include('rgb(7, 8, 9)');
212
210
  expect(styles.borderTopWidth, 'the border').to.equal('1px');
213
211
  expect(styles.borderTopColor, 'the border').to.equal('rgb(9, 9, 9)');
214
212
  });