@kubex/zinc 1.1.184 → 1.1.185

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.
@@ -98,6 +98,26 @@ Provide your own submit button by adding the `submit` attribute to a button.
98
98
  </zn-filter-wrapper>
99
99
  ```
100
100
 
101
+ ### Submit on Enter
102
+
103
+ Add the `submit-on-enter` attribute to submit the filters when Enter is pressed in a text field.
104
+
105
+ ```html:preview
106
+ <zn-filter-wrapper submit-on-enter>
107
+ <zn-input name="email" label="Email" placeholder="Press Enter to search"></zn-input>
108
+ </zn-filter-wrapper>
109
+ ```
110
+
111
+ ### Loading Text
112
+
113
+ While results load, the submit button shows a spinner and "Searching...". Change the text with `loading-text`.
114
+
115
+ ```html:preview
116
+ <zn-filter-wrapper loading-text="Filtering...">
117
+ <zn-input name="search" label="Search"></zn-input>
118
+ </zn-filter-wrapper>
119
+ ```
120
+
101
121
  ### Omit Empty Values
102
122
 
103
123
  Use the `omit-empty` attribute on inputs to exclude them from the filter when empty.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubex/zinc",
3
- "version": "1.1.184",
3
+ "version": "1.1.185",
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 @@ export default class ZnButton extends ZincElement implements ZincFormControl {
141
141
  }) loadingTextPosition: 'left' | 'right' | 'center' | string = 'center';
142
142
  @property({type: Boolean}) loading = false;
143
143
 
144
+ /** Shows a spinner and ignores clicks, e.g. while a request is in flight. */
145
+ @property({type: Boolean, reflect: true}) processing = false;
146
+
147
+ /** Replaces the label while processing. */
148
+ @property({attribute: 'processing-text'}) processingText = '';
149
+
144
150
  get validity() {
145
151
  if (this._isButton() && this.button) {
146
152
  return (this.button as HTMLButtonElement).validity;
@@ -242,7 +248,7 @@ export default class ZnButton extends ZincElement implements ZincFormControl {
242
248
  }
243
249
 
244
250
  handleClick = () => {
245
- if (this.disabled) {
251
+ if (this.disabled || this.processing) {
246
252
  return;
247
253
  }
248
254
 
@@ -378,7 +384,7 @@ export default class ZnButton extends ZincElement implements ZincFormControl {
378
384
  const isLink = this._isLink();
379
385
  const showCancel = this.loading && this.autoClick;
380
386
  const resolvedIconSize = this.iconSize ? this.iconSize : 20;
381
- const icon = this.icon && !this.loading ? html`
387
+ const icon = this.icon && !this.loading && !this.processing ? html`
382
388
  <zn-icon part="icon" src="${this.icon}" id="xy2" size="${resolvedIconSize}"
383
389
  color="${ifDefined(this.iconColor)}" fill="${ifDefined(this.iconFill)}"
384
390
  library="${ifDefined(this.iconLibrary)}"></zn-icon>`
@@ -431,6 +437,7 @@ export default class ZnButton extends ZincElement implements ZincFormControl {
431
437
  'button--has-notification': notification !== undefined && notification !== 0,
432
438
  'button--muted-notification': this.mutedNotifications || notification === -2,
433
439
  'button--notification-dot': notification !== undefined && notification < 0,
440
+ 'button--processing': this.processing,
434
441
  })}"
435
442
  type="${ifDefined(this.type)}"
436
443
  href="${ifDefined(this.href)}"
@@ -440,15 +447,19 @@ export default class ZnButton extends ZincElement implements ZincFormControl {
440
447
  gaid="${ifDefined(this.gaid)}"
441
448
  aria-label="${ifDefined(ariaLabel)}"
442
449
  aria-pressed="${ifDefined(isLink ? undefined : this.pressed)}"
450
+ aria-busy="${this.processing ? 'true' : nothing}"
443
451
  data-notification="${ifDefined(notification)}"
444
452
  disabled="${this.disabled || nothing}"
445
453
  style=${styleMap(buttonStyles)}
446
454
  @click="${this.handleClick}">
447
455
  ${this.iconPosition === 'left' ? icon : ''}
456
+ ${this.processing ? html`<span part="spinner" class="button__spinner"></span>` : ''}
457
+ ${this.processing && this.processingText ? html`<span class="button__processing-text">${this.processingText}</span>` : ''}
448
458
  <slot part="label"
449
459
  class="${classMap({
450
460
  'button__label': true,
451
- 'button__label--hidden': this.loading
461
+ 'button__label--hidden': this.loading,
462
+ 'button__label--replaced': this.processing && !!this.processingText
452
463
  })}"
453
464
  style="--loading-duration: ${this.autoClickDelay / 1000}s">
454
465
  ${this.content}
@@ -424,6 +424,31 @@
424
424
  }
425
425
  }
426
426
 
427
+ .button--processing {
428
+ cursor: progress;
429
+ }
430
+
431
+ .button__spinner {
432
+ width: 1em;
433
+ height: 1em;
434
+ margin-inline-end: 8px;
435
+ flex-shrink: 0;
436
+ border: 2px solid currentColor;
437
+ border-right-color: transparent;
438
+ border-radius: 50%;
439
+ animation: button-spin .7s linear infinite;
440
+ }
441
+
442
+ .button__label--replaced {
443
+ display: none;
444
+ }
445
+
446
+ @keyframes button-spin {
447
+ to {
448
+ transform: rotate(360deg);
449
+ }
450
+ }
451
+
427
452
  .button--relative-wrapper {
428
453
  position: relative;
429
454
  display: inline-flex;
@@ -85,13 +85,16 @@ export default class ZnCols extends ZincElement {
85
85
  }
86
86
 
87
87
  /**
88
- * Flags each child that ends its row as laid out, so `divide` only draws between neighbours on the same row
89
- * however the columns have wrapped.
88
+ * Flags each child that ends its row, and each child on the last row, as laid out, so `divide` only draws
89
+ * between neighbours however the columns have wrapped.
90
90
  */
91
91
  private markRowEnds() {
92
92
  const children = Array.from(this.querySelectorAll(':scope > *:not([slot])'));
93
93
  if (!this.divide) {
94
- children.forEach((child) => child.removeAttribute('data-row-end'));
94
+ children.forEach((child) => {
95
+ child.removeAttribute('data-row-end');
96
+ child.removeAttribute('data-last-row');
97
+ });
95
98
  return;
96
99
  }
97
100
 
@@ -101,6 +104,7 @@ export default class ZnCols extends ZincElement {
101
104
  const rowEnd = !rects.some((other) =>
102
105
  other !== rect && other.top < rect.bottom && other.bottom > rect.top && other.left > rect.left);
103
106
  child.toggleAttribute('data-row-end', rowEnd);
107
+ child.toggleAttribute('data-last-row', !rects.some((other) => other.top >= rect.bottom));
104
108
  });
105
109
  }
106
110
 
@@ -67,6 +67,22 @@
67
67
  ::slotted([data-row-end]):after {
68
68
  display: none;
69
69
  }
70
+
71
+ ::slotted(:not([data-last-row])) {
72
+ position: relative;
73
+ }
74
+ }
75
+
76
+ // Border mode owns :before for its own row rules.
77
+ &--divide:not(.cols--border) ::slotted(:not([data-last-row])):before {
78
+ content: "";
79
+ position: absolute;
80
+ display: block;
81
+ height: 1px;
82
+ left: 0;
83
+ right: 0;
84
+ bottom: calc((var(--zn-spacing-small) / 2) * -1);
85
+ background-color: rgb(var(--zn-border-color));
70
86
  }
71
87
 
72
88
  &--no-gap {
@@ -74,8 +90,14 @@
74
90
  }
75
91
 
76
92
  // Without a gap to sit in, the divider goes on the column's own edge.
77
- &--no-gap.cols--divide ::slotted(*:not(:last-child)):after {
78
- right: 0;
93
+ &--no-gap.cols--divide {
94
+ ::slotted(*:not(:last-child)):after {
95
+ right: 0;
96
+ }
97
+
98
+ ::slotted(:not([data-last-row])):before {
99
+ bottom: 0;
100
+ }
79
101
  }
80
102
 
81
103
  // The row rule below each child is drawn by a pseudo reaching to -100vw, so border mode has to clip. Clipping
@@ -155,17 +177,6 @@ $stack-sizes: (sm, smp, ph, md, lg, hd, '3k', '4k');
155
177
  .cols--divide ::slotted(*:not(:last-child)):after {
156
178
  display: none;
157
179
  }
158
-
159
- // Edge-to-edge columns keep their separation as a rule between the stacked rows
160
- .cols--no-gap.cols--divide ::slotted(*:not(:last-child)):after {
161
- display: block;
162
- top: auto;
163
- right: 0;
164
- bottom: 0;
165
- left: 0;
166
- width: auto;
167
- height: 1px;
168
- }
169
180
  }
170
181
  }
171
182
  }
@@ -165,5 +165,21 @@ describe('<zn-cols>', () => {
165
165
 
166
166
  expect(dividerOf(el.querySelector('#a'))).to.equal('none');
167
167
  });
168
+
169
+ it('should rule between wrapped rows', async () => {
170
+ const el = await cols(`
171
+ <zn-cols layout="1,1" divide style="width: 600px">
172
+ <div id="a" style="height: 20px"></div>
173
+ <div id="b" style="height: 20px"></div>
174
+ <div id="c" style="height: 20px"></div>
175
+ <div id="d" style="height: 20px"></div>
176
+ </zn-cols>`);
177
+ const ruleOf = (id: string) => getComputedStyle(el.querySelector(id)!, '::before').content;
178
+
179
+ expect(ruleOf('#a')).to.not.equal('none');
180
+ expect(ruleOf('#b')).to.not.equal('none');
181
+ expect(ruleOf('#c')).to.equal('none');
182
+ expect(ruleOf('#d')).to.equal('none');
183
+ });
168
184
  });
169
185
  });
@@ -431,10 +431,10 @@ export default class ZnDataTable extends ZincElement {
431
431
 
432
432
  requestParams: Record<string, any> = {};
433
433
 
434
- refresh() {
434
+ refresh(): Promise<void> {
435
435
  // Allow manual refresh to trigger the first data load when no-initial-load is set
436
436
  this._initialLoad = false;
437
- this._dataTask.run().then(r => r);
437
+ return this._dataTask.run();
438
438
  }
439
439
 
440
440
  // Headers that make up the visible columns, in header order
@@ -1,9 +1,10 @@
1
1
  import {type CSSResultGroup, html, unsafeCSS} from 'lit';
2
- import {property} from "lit/decorators.js";
2
+ import {property, state} from "lit/decorators.js";
3
3
  import {trimControlValue} from '../../internal/form';
4
4
  import ZincElement from '../../internal/zinc-element';
5
5
 
6
6
  import type ZnDataSelect from "../data-select";
7
+ import type ZnButton from "../button";
7
8
  import type ZnDataTable from "../data-table";
8
9
  import type ZnInput from "../input";
9
10
  import type ZnQueryBuilder from "../query-builder";
@@ -42,12 +43,20 @@ export default class ZnFilterWrapper extends ZincElement {
42
43
 
43
44
  @property() button: string = 'Filter';
44
45
 
45
- private hasSubmitButton: boolean = false;
46
+ /** Submits the filters when Enter is pressed in a text field. */
47
+ @property({attribute: 'submit-on-enter', type: Boolean}) submitOnEnter = false;
48
+
49
+ /** Shown on the submit button while results load. */
50
+ @property({attribute: 'loading-text'}) loadingText = 'Searching...';
51
+
52
+ @state() private loading = false;
53
+
54
+ private submitButton: ZnButton | null = null;
46
55
 
47
56
  handleSubmit = (event: Event) => {
48
57
  event.preventDefault();
49
58
  const dataTable: ZnDataTable | null = this.closest('zn-data-table');
50
- if (dataTable) {
59
+ if (dataTable && !this.loading) {
51
60
  const allowedInputs = ['zn-input', 'zn-select', 'zn-query-builder', 'zn-data-select', 'zn-datepicker', 'input', 'select', 'textarea'];
52
61
  const inputs = Array.from(this.querySelectorAll(allowedInputs.join(','))) as AllowedInputElement[];
53
62
  const params: Record<string, any> = {};
@@ -60,17 +69,41 @@ export default class ZnFilterWrapper extends ZincElement {
60
69
  });
61
70
 
62
71
  dataTable.requestParams = params;
63
- dataTable.refresh();
72
+ this.setLoading(true);
73
+ Promise.resolve(dataTable.refresh()).finally(() => this.setLoading(false));
74
+ }
75
+ }
76
+
77
+ private setLoading(loading: boolean) {
78
+ this.loading = loading;
79
+ if (this.submitButton) {
80
+ this.submitButton.processingText = this.loadingText;
81
+ this.submitButton.processing = loading;
82
+ }
83
+ }
84
+
85
+ handleKeyDown = (event: KeyboardEvent) => {
86
+ if (!this.submitOnEnter || event.key !== 'Enter' || event.defaultPrevented || event.isComposing
87
+ || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
88
+ return;
89
+ }
90
+
91
+ // Only plain text fields: selects, buttons and textareas use Enter themselves
92
+ if (event.composedPath()[0] instanceof HTMLInputElement) {
93
+ this.handleSubmit(event);
64
94
  }
65
95
  }
66
96
 
67
97
  connectedCallback() {
68
98
  super.connectedCallback();
69
- const button: HTMLElement | null = this.querySelector('zn-button[submit]');
70
- if (button) {
71
- this.hasSubmitButton = true;
72
- button.addEventListener('click', this.handleSubmit);
73
- }
99
+ this.addEventListener('keydown', this.handleKeyDown);
100
+ this.submitButton = this.querySelector('zn-button[submit]');
101
+ this.submitButton?.addEventListener('click', this.handleSubmit);
102
+ }
103
+
104
+ disconnectedCallback() {
105
+ super.disconnectedCallback();
106
+ this.removeEventListener('keydown', this.handleKeyDown);
74
107
  }
75
108
 
76
109
  private renderDefaultButton() {
@@ -78,6 +111,8 @@ export default class ZnFilterWrapper extends ZincElement {
78
111
  <zn-button part="search-button"
79
112
  class="filter-wrapper__submit"
80
113
  type="submit"
114
+ ?processing="${this.loading}"
115
+ processing-text="${this.loadingText}"
81
116
  @click="${this.handleSubmit}">
82
117
  ${this.button}
83
118
  </zn-button>
@@ -87,7 +122,7 @@ export default class ZnFilterWrapper extends ZincElement {
87
122
  render() {
88
123
  return html`
89
124
  <slot></slot>
90
- ${this.hasSubmitButton ? '' : this.renderDefaultButton()}
125
+ ${this.submitButton ? '' : this.renderDefaultButton()}
91
126
  `;
92
127
  }
93
128
  }
@@ -1,6 +1,7 @@
1
1
  import '../../../dist/zn.min.js';
2
2
  import { expect, fixture, html } from '@open-wc/testing';
3
3
  import { sendKeys } from '@web/test-runner-commands';
4
+ import type ZnButton from '../button';
4
5
  import type ZnDataTable from '../data-table';
5
6
  import type ZnFilterWrapper from './filter-wrapper.component';
6
7
  import type ZnInput from '../input';
@@ -20,7 +21,7 @@ describe('<zn-filter-wrapper>', () => {
20
21
  </zn-filter-wrapper>
21
22
  </zn-data-table>
22
23
  `);
23
- table.refresh = () => undefined;
24
+ table.refresh = async () => undefined;
24
25
  const wrapper = table.querySelector<ZnFilterWrapper>('zn-filter-wrapper')!;
25
26
  const input = table.querySelector<ZnInput>('zn-input')!;
26
27
  await input.updateComplete;
@@ -40,7 +41,7 @@ describe('<zn-filter-wrapper>', () => {
40
41
  </zn-filter-wrapper>
41
42
  </zn-data-table>
42
43
  `);
43
- table.refresh = () => undefined;
44
+ table.refresh = async () => undefined;
44
45
  const [input, clean] = table.querySelectorAll<ZnInput>('zn-input');
45
46
  await input.updateComplete;
46
47
 
@@ -53,4 +54,59 @@ describe('<zn-filter-wrapper>', () => {
53
54
  expect(table.requestParams.email).to.equal('a@b.com');
54
55
  expect(input.input.scrollWidth).to.equal(clean.input.scrollWidth);
55
56
  });
57
+
58
+ it('submits on Enter only when submit-on-enter is set', async () => {
59
+ const table = await fixture<ZnDataTable>(html`
60
+ <zn-data-table>
61
+ <zn-filter-wrapper slot="filter-top">
62
+ <zn-input name="email"></zn-input>
63
+ </zn-filter-wrapper>
64
+ </zn-data-table>
65
+ `);
66
+ let refreshes = 0;
67
+ table.refresh = async () => { refreshes++; };
68
+ const wrapper = table.querySelector<ZnFilterWrapper>('zn-filter-wrapper')!;
69
+ const input = table.querySelector<ZnInput>('zn-input')!;
70
+ await input.updateComplete;
71
+
72
+ input.focus();
73
+ await sendKeys({type: 'a@b.com'});
74
+ await sendKeys({press: 'Enter'});
75
+ expect(refreshes).to.equal(0);
76
+
77
+ wrapper.submitOnEnter = true;
78
+ await sendKeys({press: 'Enter'});
79
+ expect(refreshes).to.equal(1);
80
+ expect(table.requestParams).to.deep.equal({email: 'a@b.com'});
81
+ });
82
+
83
+ it('shows the submit button as processing until results load', async () => {
84
+ const table = await fixture<ZnDataTable>(html`
85
+ <zn-data-table>
86
+ <zn-filter-wrapper slot="filter-top">
87
+ <zn-input name="email"></zn-input>
88
+ <zn-button submit>Submit</zn-button>
89
+ </zn-filter-wrapper>
90
+ </zn-data-table>
91
+ `);
92
+ let finish!: () => void;
93
+ let refreshes = 0;
94
+ table.refresh = () => {
95
+ refreshes++;
96
+ return new Promise<void>(resolve => { finish = resolve; });
97
+ };
98
+ const button = table.querySelector<ZnButton>('zn-button')!;
99
+ await button.updateComplete;
100
+ const base = button.shadowRoot!.querySelector<HTMLButtonElement>('[part="base"]')!;
101
+
102
+ base.click();
103
+ base.click();
104
+ expect(refreshes).to.equal(1);
105
+ expect(button.processing).to.be.true;
106
+ expect(button.processingText).to.equal('Searching...');
107
+
108
+ finish();
109
+ await new Promise(resolve => setTimeout(resolve));
110
+ expect(button.processing).to.be.false;
111
+ });
56
112
  });