@kubex/zinc 1.1.178 → 1.1.180

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.
@@ -187,6 +187,19 @@ Use the `xaxis` attribute to specify the x-axis type. Common values include `dat
187
187
  When using `xaxis="datetime"`, provide data in the format `[{x: timestamp, y: value}]` where `x` is a Unix timestamp in milliseconds. The chart will automatically format the dates on the x-axis.
188
188
  :::
189
189
 
190
+ ### Expected Range Bands
191
+
192
+ Each series accepts optional `stack`, `areaStyle`, `lineStyle`, `symbol`, `silent` and `z` settings, which apply to that series only. Draw a shaded band with two series sharing a `stack`: an invisible base holding the lower bound, then a filled series holding the band's height. Giving both the same `name` lets the legend toggle the band as one. `silent` series ignore the mouse and stay out of tooltips.
193
+
194
+ ```html:preview
195
+ <zn-chart
196
+ type="line"
197
+ xaxis="datetime"
198
+ data="[{&quot;name&quot;:&quot;Expected range&quot;,&quot;data&quot;:[[1609459200000,20],[1609462800000,25],[1609466400000,28],[1609470000000,30],[1609473600000,26],[1609477200000,22]],&quot;stack&quot;:&quot;band&quot;,&quot;areaStyle&quot;:{&quot;opacity&quot;:0},&quot;lineStyle&quot;:{&quot;opacity&quot;:0},&quot;symbol&quot;:&quot;none&quot;,&quot;silent&quot;:true,&quot;color&quot;:&quot;#93c5fd&quot;},{&quot;name&quot;:&quot;Expected range&quot;,&quot;data&quot;:[[1609459200000,20],[1609462800000,20],[1609466400000,22],[1609470000000,24],[1609473600000,22],[1609477200000,20]],&quot;stack&quot;:&quot;band&quot;,&quot;areaStyle&quot;:{&quot;opacity&quot;:0.25},&quot;lineStyle&quot;:{&quot;opacity&quot;:0},&quot;symbol&quot;:&quot;none&quot;,&quot;silent&quot;:true,&quot;color&quot;:&quot;#93c5fd&quot;},{&quot;name&quot;:&quot;Traffic&quot;,&quot;data&quot;:[[1609459200000,30],[1609462800000,38],[1609466400000,70],[1609470000000,44],[1609473600000,40],[1609477200000,33]],&quot;color&quot;:&quot;#2563eb&quot;}]"
199
+ height="300">
200
+ </zn-chart>
201
+ ```
202
+
190
203
  ### Enable Animations
191
204
 
192
205
  By default, animations are disabled for better performance. Add the `enable-animations` attribute to animate on first render — bars grow from the baseline and lines draw in. Pass a number (milliseconds) to control the duration; bare `enable-animations` defaults to `1500`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubex/zinc",
3
- "version": "1.1.178",
3
+ "version": "1.1.180",
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",
@@ -123,6 +123,38 @@ describe('buildLineOption', () => {
123
123
  });
124
124
  });
125
125
 
126
+ describe('per-series options', () => {
127
+ const band = (overrides: Partial<BuilderProps> = {}) => buildLineOption({
128
+ ...baseProps,
129
+ xAxisType: 'datetime',
130
+ data: [
131
+ { name: 'Expected range', data: [[1, 10], [2, 12]], stack: 'band', areaStyle: { opacity: 0 }, lineStyle: { opacity: 0 }, symbol: 'none', silent: true },
132
+ { name: 'Expected range', data: [[1, 5], [2, 6]], stack: 'band', areaStyle: { opacity: 0.15 }, lineStyle: { opacity: 0 }, symbol: 'none', silent: true },
133
+ { name: 'Events', data: [[1, 12], [2, 30]] },
134
+ ],
135
+ ...overrides,
136
+ });
137
+
138
+ it('draws a band from stacked series without styling other series', () => {
139
+ const series = band().series as any[];
140
+ expect(series[0].stack).to.equal('band');
141
+ expect(series[1].stack).to.equal('band');
142
+ expect(series[1].areaStyle).to.deep.equal({ opacity: 0.15 });
143
+ expect(series[0].symbol).to.equal('none');
144
+ expect(series[0].silent).to.equal(true);
145
+ expect(series[0].tooltip).to.deep.equal({ show: false });
146
+ expect(series[2].stack).to.be.undefined;
147
+ expect(series[2].areaStyle).to.be.undefined;
148
+ expect(series[2].silent).to.be.undefined;
149
+ });
150
+
151
+ it('keeps a per-series stack when the chart is stacked', () => {
152
+ const series = band({ stacked: true }).series as any[];
153
+ expect(series[0].stack).to.equal('band');
154
+ expect(series[2].stack).to.equal('total');
155
+ });
156
+ });
157
+
126
158
  describe('buildAreaOption', () => {
127
159
  const baseAreaProps: BuilderProps = {
128
160
  type: 'area',
@@ -20,6 +20,15 @@ export interface SeriesItem {
20
20
  name: string;
21
21
  data: any[];
22
22
  color?: string;
23
+ /** Series sharing a stack ID are stacked, overriding the chart-wide `stacked` setting. */
24
+ stack?: string;
25
+ /** Fill below a line, for example `{ opacity: 0.15 }`; `{ opacity: 0 }` keeps a stacked base invisible. */
26
+ areaStyle?: Record<string, unknown>;
27
+ lineStyle?: Record<string, unknown>;
28
+ symbol?: string;
29
+ /** Excludes the series from mouse interaction and tooltips. */
30
+ silent?: boolean;
31
+ z?: number;
23
32
  }
24
33
 
25
34
  export interface SankeyEdge {
@@ -168,14 +177,28 @@ function seriesFromProps(
168
177
  name: s.name,
169
178
  data: normalizeData(s.data),
170
179
  ...(s.color ? { itemStyle: { color: s.color } } : {}),
171
- ...(props.stacked ? { stack: 'total' } : {}),
180
+ ...(s.stack ? { stack: s.stack } : props.stacked ? { stack: 'total' } : {}),
172
181
  ...(props.enableAnimations !== false && props.enableAnimations !== 0 && seriesType === 'bar'
173
182
  ? { animationDelay: (idx: number) => idx * 50 }
174
183
  : {}),
175
184
  ...extra(s),
185
+ ...seriesStyle(s),
176
186
  }));
177
187
  }
178
188
 
189
+ // seriesStyle applies a series' own display options, which take precedence
190
+ // over chart-wide defaults. Bands are drawn as an invisible stacked base
191
+ // followed by a filled series holding the band's height.
192
+ function seriesStyle(s: SeriesItem): Record<string, unknown> {
193
+ return {
194
+ ...(s.areaStyle ? { areaStyle: s.areaStyle } : {}),
195
+ ...(s.lineStyle ? { lineStyle: s.lineStyle } : {}),
196
+ ...(s.symbol ? { symbol: s.symbol } : {}),
197
+ ...(s.silent ? { silent: true, tooltip: { show: false } } : {}),
198
+ ...(s.z !== undefined ? { z: s.z } : {}),
199
+ };
200
+ }
201
+
179
202
  export function buildBarOption(props: BuilderProps): EChartsOption {
180
203
  return {
181
204
  ...commonOption(props),
@@ -368,7 +368,8 @@ export default class ZnDataTable extends ZincElement {
368
368
  inputs.forEach((input) => {
369
369
  const allowedInputs = ['zn-input', 'zn-select', 'zn-query-builder', 'zn-multiselect', 'zn-params-select', 'zn-datepicker', 'input', 'select', 'textarea'];
370
370
  if (allowedInputs.includes(input.tagName.toLowerCase())) {
371
- const value = (input as AllowedInputElement).value as string || input.getAttribute('value');
371
+ const raw = (input as AllowedInputElement).value as string || input.getAttribute('value');
372
+ const value = typeof raw === 'string' ? raw.trim() : raw;
372
373
  const name = (input as AllowedInputElement).name || input.getAttribute('name');
373
374
  if (name) {
374
375
  params[name] = value;
@@ -836,7 +837,7 @@ export default class ZnDataTable extends ZincElement {
836
837
  searchChangeListener = (e: ZnSearchChangeEvent) => {
837
838
  const target = e.target as ZnDataTableSearch | null;
838
839
  if (target && target.tagName === 'ZN-DATA-TABLE-SEARCH') {
839
- this.search = target.value as string;
840
+ this.search = (target.value as string).trim();
840
841
 
841
842
  // Get form data and search URI from event detail if available
842
843
  if (e.detail) {
@@ -278,8 +278,8 @@ export default class ZnDataTableFilter extends ZincElement implements ZincFormCo
278
278
 
279
279
  private emitChange() {
280
280
  const data = this._active
281
- .filter(item => item.value.length > 0)
282
- .map(item => ({key: item.key, comparator: item.comparator, value: item.value}));
281
+ .filter(item => item.value.trim().length > 0)
282
+ .map(item => ({key: item.key, comparator: item.comparator, value: item.value.trim()}));
283
283
 
284
284
  this.value = data.length > 0 ? btoa(JSON.stringify(data)) : '';
285
285
  this._formController.updateValidity();
@@ -114,7 +114,7 @@ export default class ZnDataTableSearch extends ZincElement implements ZincFormCo
114
114
  const collect = (element: Element): void => {
115
115
  if (!allowedInputs.includes(element.tagName.toLowerCase())) return;
116
116
  const input = element as AllowedInputElement;
117
- const value = input.value as string;
117
+ const value: unknown = typeof input.value === 'string' ? input.value.trim() : input.value;
118
118
  const name = input.name || element.getAttribute('name');
119
119
  if (name) {
120
120
  params[name] = value;
@@ -174,7 +174,7 @@ export default class ZnDataTableSearch extends ZincElement implements ZincFormCo
174
174
  const formData = this.getFormData();
175
175
  this.emit('zn-search-change', {
176
176
  detail: {
177
- value: this.value,
177
+ value: this.value.trim(),
178
178
  formData: formData,
179
179
  searchUri: this.searchUri
180
180
  }
@@ -112,7 +112,7 @@ export default class ZnDefinedLabel extends ZincElement implements ZincFormContr
112
112
  }
113
113
 
114
114
  private getFilteredLabels(): PredefinedLabel[] {
115
- const filter = this.value.toLowerCase();
115
+ const filter = this.value.trim().toLowerCase();
116
116
  return this.predefinedLabels
117
117
  .map(label => typeof label === 'string' ? {name: label} : label)
118
118
  .filter((label): label is PredefinedLabel => Boolean(label?.name))
@@ -29,7 +29,7 @@ export default class ZnFilterContainer extends ZincElement {
29
29
  private _filterFrame = 0;
30
30
 
31
31
  public handleSearchChange(event: Event) {
32
- const searchTerm = (event.target as HTMLInputElement).value.toLowerCase();
32
+ const searchTerm = (event.target as HTMLInputElement).value.trim().toLowerCase();
33
33
  cancelAnimationFrame(this._filterFrame);
34
34
  this._filterFrame = requestAnimationFrame(() => this.applyFilter(searchTerm));
35
35
  }
@@ -1,5 +1,6 @@
1
1
  import {type CSSResultGroup, html, unsafeCSS} from 'lit';
2
2
  import {property} from "lit/decorators.js";
3
+ import {trimControlValue} from '../../internal/form';
3
4
  import ZincElement from '../../internal/zinc-element';
4
5
 
5
6
  import type ZnDataSelect from "../data-select";
@@ -51,6 +52,7 @@ export default class ZnFilterWrapper extends ZincElement {
51
52
  const inputs = Array.from(this.querySelectorAll(allowedInputs.join(','))) as AllowedInputElement[];
52
53
  const params: Record<string, any> = {};
53
54
  inputs.forEach(input => {
55
+ trimControlValue(input);
54
56
  if (!input.value && input.hasAttribute('omit-empty')) {
55
57
  return;
56
58
  }
@@ -1,5 +1,9 @@
1
1
  import '../../../dist/zn.min.js';
2
2
  import { expect, fixture, html } from '@open-wc/testing';
3
+ import { sendKeys } from '@web/test-runner-commands';
4
+ import type ZnDataTable from '../data-table';
5
+ import type ZnFilterWrapper from './filter-wrapper.component';
6
+ import type ZnInput from '../input';
3
7
 
4
8
  describe('<zn-filter-wrapper>', () => {
5
9
  it('should render a component', async () => {
@@ -7,4 +11,46 @@ describe('<zn-filter-wrapper>', () => {
7
11
 
8
12
  expect(el).to.exist;
9
13
  });
14
+
15
+ it('trims input values on submit', async () => {
16
+ const table = await fixture<ZnDataTable>(html`
17
+ <zn-data-table>
18
+ <zn-filter-wrapper>
19
+ <zn-input name="external_reference" value=" abc "></zn-input>
20
+ </zn-filter-wrapper>
21
+ </zn-data-table>
22
+ `);
23
+ table.refresh = () => undefined;
24
+ const wrapper = table.querySelector<ZnFilterWrapper>('zn-filter-wrapper')!;
25
+ const input = table.querySelector<ZnInput>('zn-input')!;
26
+ await input.updateComplete;
27
+
28
+ wrapper.handleSubmit(new Event('click'));
29
+
30
+ expect(table.requestParams).to.deep.equal({external_reference: 'abc'});
31
+ expect(input.value).to.equal('abc');
32
+ });
33
+
34
+ it('clears displayed whitespace from a focused email input on submit', async () => {
35
+ const table = await fixture<ZnDataTable>(html`
36
+ <zn-data-table>
37
+ <zn-filter-wrapper slot="filter-top">
38
+ <zn-input name="email" type="email" style="width: 40px"></zn-input>
39
+ <zn-input name="clean" type="email" value="a@b.com" style="width: 40px"></zn-input>
40
+ </zn-filter-wrapper>
41
+ </zn-data-table>
42
+ `);
43
+ table.refresh = () => undefined;
44
+ const [input, clean] = table.querySelectorAll<ZnInput>('zn-input');
45
+ await input.updateComplete;
46
+
47
+ input.focus();
48
+ await sendKeys({type: ' a@b.com'});
49
+ expect(input.input.scrollWidth).to.be.greaterThan(clean.input.scrollWidth);
50
+
51
+ table.querySelector<ZnFilterWrapper>('zn-filter-wrapper')!.handleSubmit(new Event('click'));
52
+
53
+ expect(table.requestParams.email).to.equal('a@b.com');
54
+ expect(input.input.scrollWidth).to.equal(clean.input.scrollWidth);
55
+ });
10
56
  });
@@ -264,7 +264,7 @@ export default class ZnIconPicker extends ZincElement implements ZincFormControl
264
264
 
265
265
  private handleSearchInput(e: Event) {
266
266
  const input = e.target as HTMLInputElement;
267
- this._searchQuery = input.value.toLowerCase();
267
+ this._searchQuery = input.value.trim().toLowerCase();
268
268
  this.filterIcons();
269
269
  }
270
270
 
@@ -607,6 +607,7 @@ export default class ZnInput extends ZincElement implements ZincFormControl {
607
607
  // If invalid, keep the previous value
608
608
  } else {
609
609
  this.value = this.input.value;
610
+ this.syncEmailDisplay();
610
611
  }
611
612
 
612
613
  this.emit('zn-change');
@@ -644,6 +645,13 @@ export default class ZnInput extends ZincElement implements ZincFormControl {
644
645
  this.formControlController.emitInvalidEvent(event);
645
646
  }
646
647
 
648
+ // Email inputs already strip whitespace from .value but keep it on screen
649
+ private syncEmailDisplay() {
650
+ if (this.type === 'email') {
651
+ this.input.value = this.value;
652
+ }
653
+ }
654
+
647
655
  private handleKeyDown(event: KeyboardEvent) {
648
656
  // The slash menu claims its own keys; while it is open Escape closes it and Enter picks an item,
649
657
  // rather than blurring the field or submitting the form
@@ -660,6 +668,8 @@ export default class ZnInput extends ZincElement implements ZincFormControl {
660
668
  // Pressing enter when focused on the input should submit the form like a native input, be we wait a tick before
661
669
  // submitting to allow users to cancel the keydown event if they need to
662
670
  if (event.key === 'Enter' && !hasModifier) {
671
+ this.syncEmailDisplay();
672
+
663
673
  // When using a Input Method Editor (IME), pressing enter will cause the form to submit unexpectedly. One way
664
674
  // to check for this is to look at event.isComposing, which will be true when the IME is open.
665
675
  if (!event.defaultPrevented && !event.isComposing) {
@@ -11,6 +11,28 @@ describe('<zn-input>', () => {
11
11
  expect(el).to.exist;
12
12
  });
13
13
 
14
+ describe('form data', () => {
15
+ it('trims surrounding whitespace, except for passwords', async () => {
16
+ const form = await fixture<HTMLFormElement>(html`
17
+ <form>
18
+ <zn-input name="search" value=" foo bar "></zn-input>
19
+ <zn-input name="pass" type="password" value=" secret "></zn-input>
20
+ </form>
21
+ `);
22
+ const [search, pass] = form.querySelectorAll<ZnInput>('zn-input');
23
+ await search.updateComplete;
24
+
25
+ const data = new FormData(form);
26
+ expect(data.get('search')).to.equal('foo bar');
27
+ expect(data.get('pass')).to.equal(' secret ');
28
+
29
+ await search.updateComplete;
30
+ expect(search.value).to.equal('foo bar');
31
+ expect(search.input.value).to.equal('foo bar');
32
+ expect(pass.value).to.equal(' secret ');
33
+ });
34
+ });
35
+
14
36
  describe('Escape key', () => {
15
37
  it('blurs the inner input and stops propagation when focused', async () => {
16
38
  const wrapper = await fixture<HTMLDivElement>(html`
@@ -1314,7 +1314,7 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
1314
1314
  clearTimeout(this.linkSearchTimer);
1315
1315
  const token = ++this.linkSearchToken;
1316
1316
  this.linkSearchTimer = setTimeout(() => {
1317
- const url = `${this.linkUrl}${this.linkUrl.includes('?') ? '&' : '?'}q=${encodeURIComponent(term)}`;
1317
+ const url = `${this.linkUrl}${this.linkUrl.includes('?') ? '&' : '?'}q=${encodeURIComponent(term.trim())}`;
1318
1318
  fetch(url, {headers: {Accept: 'application/json'}})
1319
1319
  .then(res => {
1320
1320
  if (!res.ok) throw new Error(`link search failed: ${res.status}`);
@@ -788,7 +788,7 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
788
788
  this._addOptionActive = false;
789
789
 
790
790
  this._searchDisplayValue = this.displayInput.value;
791
- this._searchQuery = this._searchDisplayValue.toLowerCase();
791
+ this._searchQuery = this._searchDisplayValue.trim().toLowerCase();
792
792
 
793
793
  // Open the dropdown when the user starts typing
794
794
  if (!this.open) {
@@ -193,6 +193,22 @@ function removeStoredValuesByPrefix(storage: Storage, keyPrefix: string) {
193
193
  keys.forEach(key => storage.removeItem(key));
194
194
  }
195
195
 
196
+ /** Trims a control's string value in place, so what's shown matches what's submitted. */
197
+ export function trimControlValue(control: Element & { value?: unknown; type?: string; input?: unknown }) {
198
+ if (typeof control.value !== 'string' || control.type === 'password') return;
199
+
200
+ const trimmed = control.value.trim();
201
+ if (trimmed !== control.value) {
202
+ control.value = trimmed;
203
+ }
204
+
205
+ // Email inputs already strip whitespace from .value but keep it on screen
206
+ const field = control instanceof HTMLInputElement ? control : control.input;
207
+ if (control.type === 'email' && field instanceof HTMLInputElement) {
208
+ field.value = trimmed;
209
+ }
210
+ }
211
+
196
212
  export function clearFormStoreValues(formOrStoreKey: HTMLFormElement | string) {
197
213
  const keyPrefix = typeof formOrStoreKey === 'string' ? formOrStoreKey : getFormStoreKey(formOrStoreKey);
198
214
 
@@ -429,8 +445,10 @@ export class FormControlController implements ReactiveController {
429
445
  name.length > 0 &&
430
446
  typeof value !== 'undefined'
431
447
  ) {
448
+ const isPassword = (this.host as HTMLElement & { type?: string }).type === 'password';
432
449
  const appendPrimitive = (val: string | number | boolean) => {
433
- event.formData.append(name, val.toString());
450
+ const str = val.toString();
451
+ event.formData.append(name, isPassword ? str : str.trim());
434
452
  };
435
453
  const appendFile = (file: Blob, filename?: string) => {
436
454
  // If a filename is provided or it's a File (has name), FormData will use it properly
@@ -473,6 +491,10 @@ export class FormControlController implements ReactiveController {
473
491
  });
474
492
  } else {
475
493
  appendPrimitive(value as string | number | boolean);
494
+
495
+ if ((this.host as HTMLElement & { value?: unknown }).value === value) {
496
+ trimControlValue(this.host);
497
+ }
476
498
  }
477
499
 
478
500
  this.host.dispatchEvent(new CustomEvent('zn-formdata', {bubbles: true, composed: true}));