@kubex/zinc 1.1.142 → 1.1.144

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.
@@ -61,6 +61,21 @@ Extra buttons placed in the default slot appear before the standard buttons.
61
61
  </form>
62
62
  ```
63
63
 
64
+ ### Start-Aligned Actions
65
+
66
+ `align="start"` on a slotted child moves it to the start of the row; any number can sit on either side. Write the
67
+ buttons in the order they should be read — the sides are set by CSS ordering, so markup order is what a keyboard
68
+ follows.
69
+
70
+ ```html:preview
71
+ <form>
72
+ <zn-form-actions with-cancel>
73
+ <zn-button align="start" color="transparent" icon="languages@lu">Translate Missing</zn-button>
74
+ <zn-button align="start" color="transparent" icon="refresh-cw@lu">Regenerate Slug</zn-button>
75
+ </zn-form-actions>
76
+ </form>
77
+ ```
78
+
64
79
  ### Targeting a Form by Id
65
80
 
66
81
  When the component can't live inside the form, point it at one with the `form` attribute.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubex/zinc",
3
- "version": "1.1.142",
3
+ "version": "1.1.144",
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",
@@ -82,6 +82,13 @@ zn-form-group ~ zn-form-group {
82
82
  border-top: 1px solid rgb(var(--zn-border-color));
83
83
  }
84
84
 
85
+ // zn-sp's divide draws its own line between the groups.
86
+ zn-sp[divide] > zn-form-group ~ zn-form-group {
87
+ margin-top: 0;
88
+ padding-top: 0;
89
+ border-top: 0;
90
+ }
91
+
85
92
 
86
93
  .form-spacing > * {
87
94
  grid-column: span 6 / span 6;
@@ -9,6 +9,17 @@ import ZnDialog from "../dialog";
9
9
 
10
10
  import styles from './confirm.scss';
11
11
 
12
+ interface PageletAlert {
13
+ action?: string;
14
+ style?: string;
15
+ title?: string;
16
+ }
17
+
18
+ interface PageletResponse {
19
+ status?: number;
20
+ actions?: PageletAlert[];
21
+ }
22
+
12
23
  /**
13
24
  * @summary Short summary of the component's intended use.
14
25
  * @documentation https://zinc.style/components/confirm-modal
@@ -83,6 +94,12 @@ export default class ZnConfirm extends ZincElement {
83
94
  /** Internal loading state used when showLoading is enabled */
84
95
  @state() private loading: boolean = false;
85
96
 
97
+ @state() private failed: boolean = false;
98
+
99
+ @state() private failure: string = '';
100
+
101
+ private submitted: HTMLFormElement | null = null;
102
+
86
103
  protected firstUpdated(_changedProperties: PropertyValues) {
87
104
  super.firstUpdated(_changedProperties);
88
105
  if (this.open) {
@@ -116,15 +133,66 @@ export default class ZnConfirm extends ZincElement {
116
133
  show = (event: Event | undefined = undefined) => {
117
134
  const trigger = event?.target as HTMLButtonElement
118
135
  if (trigger?.disabled) return;
119
- this.loading = false;
136
+ this.reset();
120
137
  this.dialog.show();
121
138
  }
122
139
 
123
140
  hide() {
124
- this.loading = false;
141
+ this.reset();
125
142
  this.dialog.hide();
126
143
  }
127
144
 
145
+ disconnectedCallback() {
146
+ super.disconnectedCallback();
147
+ this.stopWatching();
148
+ }
149
+
150
+ private reset() {
151
+ this.stopWatching();
152
+ this.loading = false;
153
+ this.failed = false;
154
+ this.failure = '';
155
+ if (this.dialog?.closer) {
156
+ this.dialog.closer.disabled = false;
157
+ }
158
+ }
159
+
160
+ private stopWatching() {
161
+ this.submitted?.removeEventListener('complete', this.settle);
162
+ this.submitted?.removeEventListener('error', this.settle);
163
+ this.submitted = null;
164
+ }
165
+
166
+ /**
167
+ * The console dispatches the pagelet lifecycle on the form it submitted. Without it the loading
168
+ * state only ends when a response happens to reload the page, so every failure hangs the dialog.
169
+ */
170
+ private settle = (event: Event) => {
171
+ const response = (event as CustomEvent<{ response?: PageletResponse }>).detail?.response;
172
+ this.stopWatching();
173
+ if (event.type === 'error' || this.responseFailed(response)) {
174
+ this.loading = false;
175
+ this.failed = true;
176
+ this.failure = this.alertTitle(response);
177
+ if (this.dialog?.closer) {
178
+ this.dialog.closer.disabled = false;
179
+ }
180
+ return;
181
+ }
182
+ this.hide();
183
+ }
184
+
185
+ private responseFailed(response?: PageletResponse): boolean {
186
+ if (!response) return true;
187
+ if ((response.status ?? 0) >= 400) return true;
188
+ return (response.actions ?? []).some(action => action.action === 'alert' && action.style === 'error');
189
+ }
190
+
191
+ private alertTitle(response?: PageletResponse): string {
192
+ const alert = (response?.actions ?? []).find(action => action.action === 'alert' && action.style === 'error');
193
+ return alert?.title ?? '';
194
+ }
195
+
128
196
 
129
197
  render() {
130
198
  const src = {
@@ -153,10 +221,11 @@ export default class ZnConfirm extends ZincElement {
153
221
  : ''}
154
222
 
155
223
  <div class="confirm-dialog__content">
156
- ${!this.loading ? html`
224
+ ${this.loading ? html`Loading...` : this.failed ? html`
225
+ <strong>Failed</strong>
226
+ ${this.failure ? html`<p>${this.failure}</p>` : ''}` : html`
157
227
  ${this.content ? html`${this.content}` : ''}
158
- <slot></slot>` : html`
159
- Loading...`}
228
+ <slot></slot>`}
160
229
  </div>
161
230
 
162
231
  <zn-button outline color="${this.type}" slot="footer" dialog-closer disabled=${this.loading || nothing}>
@@ -196,12 +265,18 @@ export default class ZnConfirm extends ZincElement {
196
265
  document.dispatchEvent(new CustomEvent('zn-register-element', {
197
266
  detail: {element: form}
198
267
  }))
268
+ if (this.showLoading) {
269
+ this.failed = false;
270
+ this.failure = '';
271
+ this.loading = true;
272
+ this.dialog.closer.disabled = true;
273
+ this.submitted = form;
274
+ form.addEventListener('complete', this.settle);
275
+ form.addEventListener('error', this.settle);
276
+ }
199
277
  form.requestSubmit();
200
278
  if (!this.showLoading) {
201
279
  this.hide();
202
- } else {
203
- this.loading = true;
204
- this.dialog.closer.disabled = true;
205
280
  }
206
281
  }
207
282
  }
@@ -1,6 +1,38 @@
1
1
  import '../../../dist/zn.min.js';
2
2
  import { expect, fixture, html } from '@open-wc/testing';
3
3
 
4
+ const click = (el: Element) => el.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true}));
5
+
6
+ const settle = (form: HTMLFormElement, type: string, detail: unknown) =>
7
+ form.dispatchEvent(new CustomEvent(type, {detail}));
8
+
9
+ async function submittedConfirm() {
10
+ const el = await fixture<HTMLElement>(html`
11
+ <div>
12
+ <zn-confirm caption="Deploy" show-loading>
13
+ <form action="/deploy" method="post"></form>
14
+ </zn-confirm>
15
+ </div>
16
+ `);
17
+ const confirm = el.querySelector('zn-confirm')!;
18
+ await (confirm as never as {updateComplete: Promise<void>}).updateComplete;
19
+
20
+ const form = confirm.querySelector('form')!;
21
+ form.addEventListener('submit', e => e.preventDefault());
22
+
23
+ (confirm as never as {show: () => void}).show();
24
+ await (confirm as never as {updateComplete: Promise<void>}).updateComplete;
25
+
26
+ const dialog = confirm.shadowRoot!.querySelector('zn-dialog')!;
27
+ const buttons = confirm.shadowRoot!.querySelectorAll('zn-button[slot="footer"]');
28
+ click(buttons[1]);
29
+ await (confirm as never as {updateComplete: Promise<void>}).updateComplete;
30
+
31
+ return {confirm, form, dialog, cancel: buttons[0] as HTMLElement & {disabled: boolean}};
32
+ }
33
+
34
+ const closer = (dialog: Element) => dialog.shadowRoot!.querySelector('.dialog__close')!;
35
+
4
36
  describe('<zn-confirm-modal>', () => {
5
37
  it('should render a component', async () => {
6
38
  const el = await fixture(html` <zn-confirm-modal></zn-confirm-modal> `);
@@ -22,4 +54,56 @@ describe('<zn-confirm-modal>', () => {
22
54
  // An unescaped `#id` selector throws a SyntaxError here, leaving the component unrendered.
23
55
  expect(confirm.shadowRoot!.querySelector('zn-dialog')).to.exist;
24
56
  });
57
+
58
+ it('shows loading until the submitted request settles', async () => {
59
+ const {confirm} = await submittedConfirm();
60
+
61
+ expect(confirm.shadowRoot!.textContent).to.contain('Loading');
62
+ });
63
+
64
+ it('reports failure when the response carries a danger alert', async () => {
65
+ const {confirm, form, dialog, cancel} = await submittedConfirm();
66
+
67
+ settle(form, 'complete', {
68
+ response: {status: 200, actions: [{action: 'alert', style: 'error', title: 'Deployment stopped'}]}
69
+ });
70
+ await (confirm as never as {updateComplete: Promise<void>}).updateComplete;
71
+
72
+ expect(confirm.shadowRoot!.textContent).to.contain('Failed');
73
+ expect(confirm.shadowRoot!.textContent).to.contain('Deployment stopped');
74
+ expect(confirm.shadowRoot!.textContent).to.not.contain('Loading');
75
+ expect(cancel.disabled).to.be.false;
76
+ expect(closer(dialog).disabled).to.be.false;
77
+ });
78
+
79
+ it('reports failure when the request errors', async () => {
80
+ const {confirm, form, dialog} = await submittedConfirm();
81
+
82
+ settle(form, 'error', {});
83
+ await (confirm as never as {updateComplete: Promise<void>}).updateComplete;
84
+
85
+ expect(confirm.shadowRoot!.textContent).to.contain('Failed');
86
+ expect(closer(dialog).disabled).to.be.false;
87
+ });
88
+
89
+ it('closes when the submitted request succeeds', async () => {
90
+ const {confirm, form, dialog} = await submittedConfirm();
91
+
92
+ settle(form, 'complete', {response: {status: 200, actions: [{action: 'alert', style: 'success', title: 'Deployed'}]}});
93
+ await (confirm as never as {updateComplete: Promise<void>}).updateComplete;
94
+
95
+ expect((dialog as HTMLElement & {open: boolean}).open).to.be.false;
96
+ expect(closer(dialog).disabled).to.be.false;
97
+ });
98
+
99
+ it('clears a previous failure when reopened', async () => {
100
+ const {confirm, form} = await submittedConfirm();
101
+ settle(form, 'error', {});
102
+ await (confirm as never as {updateComplete: Promise<void>}).updateComplete;
103
+
104
+ (confirm as never as {show: () => void}).show();
105
+ await (confirm as never as {updateComplete: Promise<void>}).updateComplete;
106
+
107
+ expect(confirm.shadowRoot!.textContent).to.not.contain('Failed');
108
+ });
25
109
  });
@@ -17,7 +17,9 @@ import styles from './form-actions.scss';
17
17
  *
18
18
  * @event zn-cancel - Emitted when the cancel button is clicked.
19
19
  *
20
- * @slot - Extra actions, placed before the buttons.
20
+ * @slot - Extra actions, placed before the buttons. `align="start"` on a child moves it to the start of the row; any
21
+ * number can sit on either side. Write them in the order they should be read — the sides are set by CSS ordering, so
22
+ * markup order is what a keyboard follows.
21
23
  *
22
24
  * @csspart cancel-button - The cancel button.
23
25
  * @csspart reset-button - The reset button.
@@ -81,6 +83,7 @@ export default class ZnFormActions extends ZincElement {
81
83
  render() {
82
84
  return html`
83
85
  <slot></slot>
86
+ <span class="form-actions__spacer"></span>
84
87
  ${this.withCancel ? html`
85
88
  <zn-button part="cancel-button" panel-bg modal-closer icon="${this.cancelIcon}" @click="${this.handleCancel}">
86
89
  ${this.cancelText}
@@ -7,3 +7,20 @@
7
7
  align-items: center;
8
8
  gap: var(--zn-spacing-small);
9
9
  }
10
+
11
+ // The spacer, not an auto margin, is what splits the two sides: an auto margin on every left-hand action would open
12
+ // a gap between each of them, where one flexing element in the middle holds any number on either side together.
13
+ .form-actions__spacer {
14
+ order: 1;
15
+ flex: 1 1 auto;
16
+ }
17
+
18
+ // Slotted extras and the built-in buttons share an order, so the two keep their markup order between them.
19
+ ::slotted(*),
20
+ zn-button {
21
+ order: 2;
22
+ }
23
+
24
+ ::slotted([align='start']) {
25
+ order: 0;
26
+ }
@@ -22,6 +22,26 @@ describe('<zn-form-actions>', () => {
22
22
  expect(await listener).to.exist;
23
23
  });
24
24
 
25
+ it('holds align="start" children left and the rest right, one gap apart on each side', async () => {
26
+ const el = await fixture<HTMLElement>(html`
27
+ <zn-form-actions with-cancel style="width: 600px">
28
+ <zn-button align="start" id="translate">Translate</zn-button>
29
+ <zn-button align="start" id="slug">Slug</zn-button>
30
+ </zn-form-actions>
31
+ `);
32
+ const row = el.getBoundingClientRect();
33
+ const translate = el.querySelector<HTMLElement>('#translate')!.getBoundingClientRect();
34
+ const slug = el.querySelector<HTMLElement>('#slug')!.getBoundingClientRect();
35
+ const cancel = el.shadowRoot!.querySelector<HTMLElement>('[part="cancel-button"]')!.getBoundingClientRect();
36
+ const submit = el.shadowRoot!.querySelector<HTMLElement>('[part="submit-button"]')!.getBoundingClientRect();
37
+
38
+ expect(translate.left).to.be.closeTo(row.left, 1);
39
+ expect(submit.right).to.be.closeTo(row.right, 1);
40
+ // Each side stays one gap apart rather than spreading across the free space.
41
+ expect(slug.left).to.be.closeTo(translate.right + 16, 1);
42
+ expect(submit.left).to.be.closeTo(cancel.right + 16, 1);
43
+ });
44
+
25
45
  it('should close a containing dialog when the cancel button is clicked', async () => {
26
46
  const dialog = await fixture<HTMLElement & { open: boolean }>(html`
27
47
  <zn-dialog open label="Test">