@kubex/zinc 1.1.179 → 1.1.181
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 +697 -454
- package/dist/vscode.html-custom-data.json +102 -64
- package/dist/web-types.json +243 -163
- package/dist/zn.d.ts +115 -11
- package/dist/zn.min.js +331 -322
- package/docs/pages/components/chart.md +49 -0
- package/docs/pages/components/stat.md +74 -0
- package/docs/pages/components/tile.md +16 -0
- package/package.json +1 -1
- package/src/components/chart/builders.ts +15 -0
- package/src/components/chart/chart-live.test.ts +82 -0
- package/src/components/chart/chart.component.ts +111 -14
- package/src/components/chart/live.test.ts +96 -0
- package/src/components/chart/live.ts +164 -0
- package/src/components/cols/cols.scss +16 -0
- package/src/components/data-table/data-table.component.ts +3 -2
- package/src/components/data-table-filter/data-table-filter.component.ts +2 -2
- package/src/components/data-table-search/data-table-search.component.ts +2 -2
- package/src/components/defined-label/defined-label.component.ts +1 -1
- package/src/components/filter-container/filter-container.component.ts +1 -1
- package/src/components/filter-wrapper/filter-wrapper.component.ts +2 -0
- package/src/components/filter-wrapper/filter-wrapper.test.ts +46 -0
- package/src/components/icon-picker/icon-picker.component.ts +1 -1
- package/src/components/input/input.component.ts +10 -0
- package/src/components/remarkd-editor/remarkd-editor.component.ts +1 -1
- package/src/components/select/select.component.ts +1 -1
- package/src/components/stat/stat.component.ts +81 -12
- package/src/components/stat/stat.scss +64 -0
- package/src/components/tile/tile.component.ts +3 -0
- package/src/components/tile/tile.scss +30 -0
- package/src/internal/form.ts +18 -3
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { SeriesItem } from './builders';
|
|
2
|
+
|
|
3
|
+
export interface LivePoint { at: number; value: number | null }
|
|
4
|
+
export interface LivePrediction {
|
|
5
|
+
status: string; average_rates?: boolean;
|
|
6
|
+
points: { at: number; expected: number; lower: number; upper: number; available: boolean }[];
|
|
7
|
+
}
|
|
8
|
+
export interface LiveFrame {
|
|
9
|
+
from: number; until: number; server_time: number; expires_at: number;
|
|
10
|
+
resolution_seconds: number; metric: string; points: LivePoint[]; prediction?: LivePrediction;
|
|
11
|
+
}
|
|
12
|
+
export interface LiveGrant { stream_url: string; expires_at: number; server_time: number }
|
|
13
|
+
|
|
14
|
+
const finite = (n: unknown): n is number => typeof n === 'number' && Number.isFinite(n);
|
|
15
|
+
|
|
16
|
+
/** Authoritative bucket replacement, never increment counts on delivery/reconnect. */
|
|
17
|
+
export class LiveWindow {
|
|
18
|
+
frame?: LiveFrame;
|
|
19
|
+
private points = new Map<number, number | null>();
|
|
20
|
+
private prediction?: LivePrediction;
|
|
21
|
+
|
|
22
|
+
apply(value: unknown, snapshot: boolean): LiveFrame {
|
|
23
|
+
const f = value as LiveFrame;
|
|
24
|
+
if (!f || !finite(f.from) || !finite(f.until) || f.until <= f.from || !finite(f.server_time) ||
|
|
25
|
+
!finite(f.expires_at) || !finite(f.resolution_seconds) || f.resolution_seconds <= 0 ||
|
|
26
|
+
!['hits', 'sum', 'average'].includes(f.metric) || !Array.isArray(f.points) || f.points.length > 10000 ||
|
|
27
|
+
f.points.some(p => !p || !finite(p.at) || !(p.value === null || finite(p.value)))) throw new Error('Invalid chart frame');
|
|
28
|
+
if (!snapshot && (!this.frame || this.frame.resolution_seconds !== f.resolution_seconds || this.frame.metric !== f.metric)) throw new Error('Snapshot required');
|
|
29
|
+
if (f.prediction && (!Array.isArray(f.prediction.points) || f.prediction.points.length > 10000 ||
|
|
30
|
+
f.prediction.points.some(p => !p || !finite(p.at) || !finite(p.lower) || !finite(p.upper) || !finite(p.expected) || p.upper < p.lower))) throw new Error('Invalid prediction');
|
|
31
|
+
if (snapshot) { this.points.clear(); this.prediction = f.prediction; }
|
|
32
|
+
else if (f.prediction) this.prediction = f.prediction;
|
|
33
|
+
const start = Math.floor(f.from / (f.resolution_seconds * 1000)) * f.resolution_seconds * 1000;
|
|
34
|
+
for (const p of f.points) if (p.at >= start && p.at < f.until) this.points.set(p.at, p.value);
|
|
35
|
+
for (const at of this.points.keys()) if (at < start || at >= f.until) this.points.delete(at);
|
|
36
|
+
// Never keep an unbounded history, including malformed streams with many timestamps.
|
|
37
|
+
if (this.points.size > 10000) {
|
|
38
|
+
const ordered = [...this.points.keys()].sort((a, b) => a - b);
|
|
39
|
+
for (const at of ordered.slice(0, ordered.length - 10000)) this.points.delete(at);
|
|
40
|
+
}
|
|
41
|
+
this.frame = f;
|
|
42
|
+
return f;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
advance(serverTime: number) {
|
|
46
|
+
if (!this.frame || !finite(serverTime) || serverTime <= this.frame.server_time) return;
|
|
47
|
+
const delta = serverTime - this.frame.server_time;
|
|
48
|
+
this.frame = { ...this.frame, server_time: serverTime, from: this.frame.from + delta, until: this.frame.until + delta };
|
|
49
|
+
const start = Math.floor(this.frame.from / (this.frame.resolution_seconds * 1000)) * this.frame.resolution_seconds * 1000;
|
|
50
|
+
for (const at of this.points.keys()) if (at < start) this.points.delete(at);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
get predictionStatus() { return this.prediction?.status ?? 'unavailable'; }
|
|
54
|
+
|
|
55
|
+
series(hideZero = false): SeriesItem[] {
|
|
56
|
+
const points = [...this.points].sort((a,b) => a[0]-b[0]);
|
|
57
|
+
const series: SeriesItem[] = [];
|
|
58
|
+
if (this.prediction && this.frame) {
|
|
59
|
+
const ps = this.prediction.points.filter(p => p.at < this.frame!.until && p.at >= this.frame!.from - 86400000);
|
|
60
|
+
const name = this.prediction.average_rates ? 'Expected range (average rate)' : 'Expected range';
|
|
61
|
+
const data = (pick: (p: typeof ps[number]) => number) => ps.map(p => ({ name: String(p.at), value: [p.at, p.available ? pick(p) : null] }));
|
|
62
|
+
if (ps.some(p => p.available)) {
|
|
63
|
+
series.push({ id: 'live-range-base', animation: false, name: '_range_base', data: data(p => p.lower), stack: 'expected', stackStrategy: 'all', symbol: 'none', silent: true,
|
|
64
|
+
lineStyle: { opacity: 0 }, areaStyle: { opacity: 0 }, z: 1 });
|
|
65
|
+
series.push({ id: 'live-range', animation: false, name, data: data(p => p.upper - p.lower), stack: 'expected', stackStrategy: 'all', symbol: 'none', silent: true,
|
|
66
|
+
color: '#93c5fd', lineStyle: { opacity: 0 }, areaStyle: { opacity: 0.3 }, z: 1 });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
series.push({ id: 'live-values', animation: false, name: this.frame?.metric ?? 'Values', data: points.map(([at, value]) => ({ name: String(at), value: [at, hideZero && value === 0 ? null : value] })), symbol: 'none', z: 3 });
|
|
70
|
+
return series;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** One transport per chart. Grants renew only through a same-origin login gate. */
|
|
75
|
+
export class LiveTransport {
|
|
76
|
+
private source?: EventSource;
|
|
77
|
+
private timer?: ReturnType<typeof setTimeout>;
|
|
78
|
+
private expiryTimer?: ReturnType<typeof setTimeout>;
|
|
79
|
+
private abort?: AbortController;
|
|
80
|
+
private stopped = true;
|
|
81
|
+
private generation = 0;
|
|
82
|
+
private retry = 0;
|
|
83
|
+
private renewing = false;
|
|
84
|
+
private readonly frame: (data: unknown, snapshot: boolean) => void;
|
|
85
|
+
private readonly status: (status: string) => void;
|
|
86
|
+
private readonly heartbeat?: (serverTime: number) => void;
|
|
87
|
+
constructor(frame: (data: unknown, snapshot: boolean) => void, status: (status: string) => void,
|
|
88
|
+
heartbeat?: (serverTime: number) => void) {
|
|
89
|
+
this.frame = frame; this.status = status; this.heartbeat = heartbeat;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
start(streamURL: string, renewURL: string) {
|
|
93
|
+
this.stop(); this.stopped = false;
|
|
94
|
+
const generation = this.generation;
|
|
95
|
+
if (streamURL) {
|
|
96
|
+
this.deadline(Date.now() + 60000, Date.now(), renewURL, generation);
|
|
97
|
+
this.connect(streamURL, renewURL, generation);
|
|
98
|
+
}
|
|
99
|
+
else if (renewURL) void this.renew(renewURL, generation);
|
|
100
|
+
}
|
|
101
|
+
stop() {
|
|
102
|
+
this.stopped = true; this.generation++;
|
|
103
|
+
this.source?.close(); this.source = undefined;
|
|
104
|
+
clearTimeout(this.timer); clearTimeout(this.expiryTimer); this.abort?.abort();
|
|
105
|
+
this.retry = 0; this.renewing = false;
|
|
106
|
+
}
|
|
107
|
+
private active(generation: number) { return !this.stopped && generation === this.generation; }
|
|
108
|
+
private deadline(expires: number, serverTime: number, renewURL: string, generation: number) {
|
|
109
|
+
if (!finite(expires) || !finite(serverTime)) throw new Error('Invalid grant expiry');
|
|
110
|
+
const remaining = expires - serverTime;
|
|
111
|
+
clearTimeout(this.expiryTimer); clearTimeout(this.timer);
|
|
112
|
+
this.expiryTimer = setTimeout(() => {
|
|
113
|
+
if (!this.active(generation)) return;
|
|
114
|
+
this.source?.close(); this.source = undefined; this.status('expired');
|
|
115
|
+
}, Math.max(0, remaining));
|
|
116
|
+
if (renewURL && !this.renewing) this.timer = setTimeout(() => void this.renew(renewURL, generation), Math.max(0, remaining - 10000));
|
|
117
|
+
}
|
|
118
|
+
private connect(streamURL: string, renewURL: string, generation: number) {
|
|
119
|
+
const url = new URL(streamURL, location.href);
|
|
120
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new Error('Invalid stream URL');
|
|
121
|
+
this.source?.close();
|
|
122
|
+
const source = new EventSource(url.href); // No cross-origin cookies or app credentials.
|
|
123
|
+
this.source = source;
|
|
124
|
+
this.status('connecting');
|
|
125
|
+
source.onopen = () => { if (this.active(generation)) this.status('connected'); };
|
|
126
|
+
for (const event of ['snapshot', 'delta', 'heartbeat']) source.addEventListener(event, (message: MessageEvent<string>) => {
|
|
127
|
+
if (!this.active(generation) || this.source !== source) return;
|
|
128
|
+
try {
|
|
129
|
+
const f = JSON.parse(message.data) as LiveFrame;
|
|
130
|
+
if (event !== 'heartbeat') this.frame(f, event === 'snapshot');
|
|
131
|
+
else this.heartbeat?.(f.server_time);
|
|
132
|
+
this.deadline(f.expires_at, f.server_time, renewURL, generation);
|
|
133
|
+
this.status('connected');
|
|
134
|
+
} catch { this.stop(); this.status('invalid'); }
|
|
135
|
+
});
|
|
136
|
+
source.onerror = () => { if (this.active(generation) && this.source === source) this.status('reconnecting'); };
|
|
137
|
+
}
|
|
138
|
+
private async renew(renewURL: string, generation: number) {
|
|
139
|
+
if (!this.active(generation) || this.renewing) return;
|
|
140
|
+
const url = new URL(renewURL, location.href);
|
|
141
|
+
if (url.origin !== location.origin || url.username || url.password) { this.stop(); this.status('denied'); return; }
|
|
142
|
+
this.renewing = true;
|
|
143
|
+
const controller = new AbortController(); this.abort = controller;
|
|
144
|
+
const timeout = setTimeout(() => controller.abort(), 8000);
|
|
145
|
+
try {
|
|
146
|
+
const response = await fetch(url.href, { credentials: 'same-origin', redirect: 'error', cache: 'no-store', signal: controller.signal, headers: { Accept: 'application/json', 'x-kx-fetch-style': 'raw-json' } });
|
|
147
|
+
if (!this.active(generation)) return;
|
|
148
|
+
if ([401,403].includes(response.status)) { this.stop(); this.status('denied'); return; }
|
|
149
|
+
if (!response.ok) throw new Error('Grant unavailable');
|
|
150
|
+
const grant = await response.json() as LiveGrant;
|
|
151
|
+
if (!this.active(generation)) return;
|
|
152
|
+
if (!finite(grant.expires_at) || !finite(grant.server_time) || grant.expires_at <= grant.server_time || typeof grant.stream_url !== 'string') throw new Error('Invalid grant');
|
|
153
|
+
this.renewing = false;
|
|
154
|
+
this.deadline(grant.expires_at, grant.server_time, renewURL, generation);
|
|
155
|
+
this.connect(grant.stream_url, renewURL, generation); this.retry = 0;
|
|
156
|
+
} catch {
|
|
157
|
+
if (this.active(generation)) {
|
|
158
|
+
if (this.retry >= 4) { this.stop(); this.status('unavailable'); return; }
|
|
159
|
+
this.status('reconnecting');
|
|
160
|
+
this.timer = setTimeout(() => void this.renew(renewURL, generation), Math.min(10000, 1000 * 2 ** Math.min(this.retry++, 4)));
|
|
161
|
+
}
|
|
162
|
+
} finally { clearTimeout(timeout); if (this.active(generation)) this.renewing = false; }
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -69,6 +69,11 @@
|
|
|
69
69
|
gap: 0;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
// Without a gap to sit in, the divider goes on the column's own edge.
|
|
73
|
+
&--no-gap.cols--divide ::slotted(*:not(:last-child)):after {
|
|
74
|
+
right: 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
72
77
|
// The row rule below each child is drawn by a pseudo reaching to -100vw, so border mode has to clip. Clipping
|
|
73
78
|
// every layout would cut the focus ring off any control sitting flush against a column edge.
|
|
74
79
|
&--border {
|
|
@@ -146,6 +151,17 @@ $stack-sizes: (sm, smp, ph, md, lg, hd, '3k', '4k');
|
|
|
146
151
|
.cols--divide ::slotted(*:not(:last-child)):after {
|
|
147
152
|
display: none;
|
|
148
153
|
}
|
|
154
|
+
|
|
155
|
+
// Edge-to-edge columns keep their separation as a rule between the stacked rows
|
|
156
|
+
.cols--no-gap.cols--divide ::slotted(*:not(:last-child)):after {
|
|
157
|
+
display: block;
|
|
158
|
+
top: auto;
|
|
159
|
+
right: 0;
|
|
160
|
+
bottom: 0;
|
|
161
|
+
left: 0;
|
|
162
|
+
width: auto;
|
|
163
|
+
height: 1px;
|
|
164
|
+
}
|
|
149
165
|
}
|
|
150
166
|
}
|
|
151
167
|
}
|
|
@@ -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
|
|
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
|
|
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) {
|
|
@@ -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) {
|
|
@@ -1,26 +1,19 @@
|
|
|
1
1
|
import {classMap} from "lit/directives/class-map.js";
|
|
2
2
|
import {type CSSResultGroup, html, unsafeCSS} from 'lit';
|
|
3
|
+
import {ifDefined} from "lit/directives/if-defined.js";
|
|
3
4
|
import {property} from 'lit/decorators.js';
|
|
4
5
|
import ZincElement from '../../internal/zinc-element';
|
|
6
|
+
import type {IconColor} from '../icon';
|
|
5
7
|
|
|
6
8
|
import styles from './stat.scss';
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
|
-
* @summary
|
|
11
|
+
* @summary Displays a key metric, optionally compared with a previous value.
|
|
10
12
|
* @documentation https://zinc.style/components/stats-tile
|
|
11
13
|
* @status experimental
|
|
12
14
|
* @since 1.0
|
|
13
15
|
*
|
|
14
|
-
* @dependency zn-
|
|
15
|
-
*
|
|
16
|
-
* @event zn-event-name - Emitted as an example.
|
|
17
|
-
*
|
|
18
|
-
* @slot - The default slot.
|
|
19
|
-
* @slot example - An example slot.
|
|
20
|
-
*
|
|
21
|
-
* @csspart base - The component's base wrapper.
|
|
22
|
-
*
|
|
23
|
-
* @cssproperty --example - An example CSS custom property.
|
|
16
|
+
* @dependency zn-icon
|
|
24
17
|
*/
|
|
25
18
|
export default class ZnStatsTile extends ZincElement {
|
|
26
19
|
static styles: CSSResultGroup = unsafeCSS(styles);
|
|
@@ -34,6 +27,25 @@ export default class ZnStatsTile extends ZincElement {
|
|
|
34
27
|
@property({type: Boolean, attribute: 'show-delta'}) showDelta = false;
|
|
35
28
|
@property() color: 'primary' | 'error' | 'info' | 'warning' | 'success' | 'neutral' = 'primary';
|
|
36
29
|
|
|
30
|
+
/** `value-first` puts the amount under the caption with the description beneath it, and tints the amount rather
|
|
31
|
+
* than the description with `color`. */
|
|
32
|
+
@property({reflect: true}) layout: 'default' | 'value-first' = 'default';
|
|
33
|
+
|
|
34
|
+
/** How `show-delta` compares with `previous`: the relative change, or the difference in points. */
|
|
35
|
+
@property({attribute: 'delta-format'}) deltaFormat: 'percent' | 'points' = 'percent';
|
|
36
|
+
|
|
37
|
+
/** An icon shown beside the amount. */
|
|
38
|
+
@property() icon = '';
|
|
39
|
+
|
|
40
|
+
/** The icon's color; without one it takes the amount's. */
|
|
41
|
+
@property({attribute: 'icon-color'}) iconColor: IconColor;
|
|
42
|
+
|
|
43
|
+
/** `large` gives the amount more weight, for a summary row of a few headline figures. */
|
|
44
|
+
@property({reflect: true}) size: 'medium' | 'large' = 'medium';
|
|
45
|
+
|
|
46
|
+
/** Pads the stat, for stats laid edge to edge in a flush container such as `zn-cols no-gap divide`. */
|
|
47
|
+
@property({type: Boolean}) padded = false;
|
|
48
|
+
|
|
37
49
|
calcPercentageDifference() {
|
|
38
50
|
const previous = parseFloat(this.previous);
|
|
39
51
|
const amount = parseFloat(this.amount);
|
|
@@ -93,16 +105,73 @@ export default class ZnStatsTile extends ZincElement {
|
|
|
93
105
|
diffIcon = '-';
|
|
94
106
|
}
|
|
95
107
|
|
|
108
|
+
if (this.deltaFormat === 'points') {
|
|
109
|
+
return html`<p class="diff">${this.pointsDelta()} on Previous Period</p>`;
|
|
110
|
+
}
|
|
111
|
+
|
|
96
112
|
return html`<p class="diff"><span class="${diffClass}">${diffIcon}${diff}%</span> on Previous Period</p>`;
|
|
97
113
|
}
|
|
98
114
|
|
|
99
115
|
return null;
|
|
100
116
|
}
|
|
101
117
|
|
|
118
|
+
private pointsDelta() {
|
|
119
|
+
const diff = Math.round((parseFloat(this.amount) - parseFloat(this.previous)) * 10) / 10;
|
|
120
|
+
if (diff > 0) {
|
|
121
|
+
return html`<span class="positive">+${diff} pts</span>`;
|
|
122
|
+
}
|
|
123
|
+
if (diff < 0) {
|
|
124
|
+
return html`<span class="negative">-${Math.abs(diff)} pts</span>`;
|
|
125
|
+
}
|
|
126
|
+
return html`<span class="neutral">No change</span>`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private renderAmount() {
|
|
130
|
+
return html`${this.getDisplayAmount()}${this.icon ? html`
|
|
131
|
+
<zn-icon class="icon" src="${this.icon}" size="20" color="${ifDefined(this.iconColor)}"></zn-icon>` : null}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private renderValueFirst() {
|
|
135
|
+
const delta = this.showDelta
|
|
136
|
+
? (this.deltaFormat === 'points' ? this.pointsDelta() : this.percentDelta())
|
|
137
|
+
: null;
|
|
138
|
+
|
|
139
|
+
return html`
|
|
140
|
+
<div class="${classMap({
|
|
141
|
+
'stat-tile': true,
|
|
142
|
+
'stat-tile--value-first': true,
|
|
143
|
+
'stat-tile--large': this.size === 'large',
|
|
144
|
+
'stat-tile--padded': this.padded,
|
|
145
|
+
[`stat-tile--${this.color}`]: true
|
|
146
|
+
})}">
|
|
147
|
+
<h3 class="caption">${this.caption}</h3>
|
|
148
|
+
<p class="amount">${this.renderAmount()}${delta ? html`<span class="diff">${delta}</span>` : null}</p>
|
|
149
|
+
${this.description ? html`<p class="description">${this.description}</p>` : null}
|
|
150
|
+
</div>
|
|
151
|
+
`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private percentDelta() {
|
|
155
|
+
const diff = this.calcPercentageDifference();
|
|
156
|
+
if (diff > 0) {
|
|
157
|
+
return html`<span class="positive">+${diff}%</span>`;
|
|
158
|
+
}
|
|
159
|
+
if (diff < 0) {
|
|
160
|
+
return html`<span class="negative">${diff}%</span>`;
|
|
161
|
+
}
|
|
162
|
+
return html`<span class="neutral">No change</span>`;
|
|
163
|
+
}
|
|
164
|
+
|
|
102
165
|
render() {
|
|
166
|
+
if (this.layout === 'value-first') {
|
|
167
|
+
return this.renderValueFirst();
|
|
168
|
+
}
|
|
169
|
+
|
|
103
170
|
return html`
|
|
104
171
|
<div class="${classMap({
|
|
105
172
|
'stat-tile': true,
|
|
173
|
+
'stat-tile--large': this.size === 'large',
|
|
174
|
+
'stat-tile--padded': this.padded,
|
|
106
175
|
[`stat-tile--${this.color}`]: true
|
|
107
176
|
})}">
|
|
108
177
|
<div class="container">
|
|
@@ -111,7 +180,7 @@ export default class ZnStatsTile extends ZincElement {
|
|
|
111
180
|
<h3 class="caption">${this.caption}</h3>
|
|
112
181
|
<p class="description">${this.description}</p>
|
|
113
182
|
</div>
|
|
114
|
-
<p class="amount">${this.
|
|
183
|
+
<p class="amount">${this.renderAmount()}</p>
|
|
115
184
|
<p class="bottom">${this.diffText()}</p>
|
|
116
185
|
</div>
|
|
117
186
|
</div>
|
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
display: contents;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
// A box of its own lets zn-cols draw its dividers between value-first stats.
|
|
9
|
+
:host([layout="value-first"]) {
|
|
10
|
+
display: block;
|
|
11
|
+
}
|
|
12
|
+
|
|
8
13
|
.stat-tile {
|
|
9
14
|
|
|
10
15
|
.caption, .description, .amount, .bottom {
|
|
@@ -68,6 +73,55 @@
|
|
|
68
73
|
}
|
|
69
74
|
}
|
|
70
75
|
|
|
76
|
+
.icon {
|
|
77
|
+
margin-left: var(--zn-spacing-x-small);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
&--value-first {
|
|
81
|
+
display: flex;
|
|
82
|
+
flex-direction: column;
|
|
83
|
+
gap: var(--zn-spacing-2x-small);
|
|
84
|
+
color: var(--zn-text-chart-major-color);
|
|
85
|
+
|
|
86
|
+
.caption {
|
|
87
|
+
margin-bottom: 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.amount {
|
|
91
|
+
flex-grow: 0;
|
|
92
|
+
gap: var(--zn-spacing-x-small);
|
|
93
|
+
color: inherit;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.diff {
|
|
97
|
+
font-size: var(--zn-font-size-small);
|
|
98
|
+
font-weight: 400;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.diff .neutral,
|
|
102
|
+
.description {
|
|
103
|
+
color: rgba(var(--zn-color-text), 0.6);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.description {
|
|
107
|
+
font-size: var(--zn-font-size-x-small);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.icon {
|
|
111
|
+
margin-left: 0;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
&--padded {
|
|
116
|
+
padding: var(--zn-spacing-large);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
&--large .amount {
|
|
120
|
+
font-size: var(--zn-font-size-3x-large);
|
|
121
|
+
font-weight: var(--zn-font-weight-semibold);
|
|
122
|
+
line-height: 1.15;
|
|
123
|
+
}
|
|
124
|
+
|
|
71
125
|
&--chart {
|
|
72
126
|
.amount {
|
|
73
127
|
padding-top: var(--zn-spacing-small);
|
|
@@ -102,6 +156,10 @@
|
|
|
102
156
|
color: rgb(var(--zn-text));
|
|
103
157
|
}
|
|
104
158
|
|
|
159
|
+
&--value-first.stat-tile--neutral {
|
|
160
|
+
color: var(--zn-text-chart-major-color);
|
|
161
|
+
}
|
|
162
|
+
|
|
105
163
|
@container (max-width:550px) {
|
|
106
164
|
::slotted(.zn-col-1) {
|
|
107
165
|
flex-basis: 170px;
|
|
@@ -112,5 +170,11 @@
|
|
|
112
170
|
line-height: 20px;
|
|
113
171
|
padding-top: 5px;
|
|
114
172
|
}
|
|
173
|
+
|
|
174
|
+
&--large .amount {
|
|
175
|
+
font-size: var(--zn-font-size-2x-large);
|
|
176
|
+
line-height: 1.15;
|
|
177
|
+
padding-top: 0;
|
|
178
|
+
}
|
|
115
179
|
}
|
|
116
180
|
}
|
|
@@ -53,6 +53,9 @@ export default class ZnTile extends ZincElement {
|
|
|
53
53
|
/** Renders the caption in the normal table-content weight instead of bold. */
|
|
54
54
|
@property({type: Boolean}) plain: boolean;
|
|
55
55
|
|
|
56
|
+
/** Moves the actions under the caption and description when the tile is narrower than 600px. */
|
|
57
|
+
@property({attribute: 'stack-actions', type: Boolean, reflect: true}) stackActions: boolean;
|
|
58
|
+
|
|
56
59
|
/** Set by `zn-tile-group` to lay the tile out as a shared-column subgrid row. */
|
|
57
60
|
@property({type: Boolean, reflect: true}) grouped: boolean;
|
|
58
61
|
|