@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.
- package/dist/custom-elements.json +84 -3
- package/dist/vscode.html-custom-data.json +1 -1
- package/dist/web-types.json +3 -3
- package/dist/zn.d.ts +16 -1
- package/dist/zn.min.css +1 -1
- package/dist/zn.min.js +52 -50
- package/docs/pages/components/form-actions.md +15 -0
- package/package.json +1 -1
- package/scss/_global-spacing.scss +7 -0
- package/src/components/confirm/confirm.component.ts +83 -8
- package/src/components/confirm/confirm.test.ts +84 -0
- package/src/components/form-actions/form-actions.component.ts +4 -1
- package/src/components/form-actions/form-actions.scss +17 -0
- package/src/components/form-actions/form-actions.test.ts +20 -0
|
@@ -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
|
@@ -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.
|
|
136
|
+
this.reset();
|
|
120
137
|
this.dialog.show();
|
|
121
138
|
}
|
|
122
139
|
|
|
123
140
|
hide() {
|
|
124
|
-
this.
|
|
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
|
-
${
|
|
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>`
|
|
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">
|