@zywave/zui-slider 4.4.0-pre.2 → 4.4.0-pre.4
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 +201 -7
- package/dist/zui-slider-css.js +1 -1
- package/dist/zui-slider-css.js.map +1 -1
- package/dist/zui-slider.d.ts +16 -21
- package/dist/zui-slider.js +408 -90
- package/dist/zui-slider.js.map +1 -1
- package/docs/demo.html +99 -78
- package/lab.html +415 -15
- package/package.json +2 -2
- package/src/zui-slider-css.js +1 -1
- package/src/zui-slider.scss +38 -7
- package/src/zui-slider.ts +420 -88
- package/test/zui-slider.test.ts +671 -131
package/dist/zui-slider.js
CHANGED
|
@@ -9,6 +9,7 @@ import { ZuiFormAssociatedElement } from '@zywave/zui-base';
|
|
|
9
9
|
import { html, nothing } from 'lit';
|
|
10
10
|
import { property } from 'lit/decorators.js';
|
|
11
11
|
import { classMap } from 'lit/directives/class-map.js';
|
|
12
|
+
import { ifDefined } from 'lit/directives/if-defined.js';
|
|
12
13
|
import { live } from 'lit/directives/live.js';
|
|
13
14
|
import { styleMap } from 'lit/directives/style-map.js';
|
|
14
15
|
import { style } from './zui-slider-css.js';
|
|
@@ -27,8 +28,8 @@ import { style } from './zui-slider-css.js';
|
|
|
27
28
|
* @attr {number} [min=0] - Represents the minimum permitted value
|
|
28
29
|
* @attr {number} [max=100] - Represents the maximum permitted value
|
|
29
30
|
* @attr {number} [step=0] - Represents the stepping interval; 0 means any value is allowed
|
|
30
|
-
* @attr {
|
|
31
|
-
*
|
|
31
|
+
* @attr {string} [steps=''] - Comma-separated step labels; overrides min/max/step. Labels containing commas must be set via the property instead.
|
|
32
|
+
* @attr {boolean} [show-step-labels=false] - When set, displays each step's label beneath its dot on the track
|
|
32
33
|
* @prop {string | null} [name=null] - The name of this element that is associated with form submission
|
|
33
34
|
* @prop {boolean} [disabled=false] - Represents whether a user can make changes to this element; if true, the value of this element will be excluded from the form submission
|
|
34
35
|
* @prop {boolean} [readOnly=false] - Represents whether a user can make changes to this element; the value of this element will still be included in the form submission
|
|
@@ -44,7 +45,9 @@ import { style } from './zui-slider-css.js';
|
|
|
44
45
|
* @prop {number} [min=0] - Represents the minimum permitted value
|
|
45
46
|
* @prop {number} [max=100] - Represents the maximum permitted value
|
|
46
47
|
* @prop {number} [step=0] - Represents the stepping interval; 0 means any value is allowed
|
|
47
|
-
* @prop {
|
|
48
|
+
* @prop {StepInput[]} [steps=[]] - Custom step values; overrides min/max/step. Thumbs at equal visual intervals. Accepts number, string, or { value, label? }. Use { value: Infinity, label: '...' } for an unbounded overflow step. Labels with commas must be set via the property.
|
|
49
|
+
* @prop {((input: string) => number | string | null) | null} [stepParser=null] - Resolves user-typed strings to a step value; returns a number (snap to nearest), a valid step label, or null to reject. Must be set via the property.
|
|
50
|
+
* @prop {boolean} [showStepLabels=false] - When true, displays each step's label (or its value if no label was provided) beneath the corresponding dot on the track
|
|
48
51
|
*
|
|
49
52
|
* @cssprop [--zui-slider-input-width=7ch] - Width of the floating value input above each slider thumb
|
|
50
53
|
*
|
|
@@ -59,6 +62,8 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
59
62
|
this.#value = '50';
|
|
60
63
|
this.#valueStart = '0';
|
|
61
64
|
this.#valueEnd = '100';
|
|
65
|
+
this.#cachedNormalizedSteps = null;
|
|
66
|
+
this.#cachedNumericValues = null;
|
|
62
67
|
this.#thumbInputState = new Map([
|
|
63
68
|
['thumb', { visible: false, focused: false }],
|
|
64
69
|
['startThumb', { visible: false, focused: false }],
|
|
@@ -68,14 +73,14 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
68
73
|
this.#onThumbFloatingInput = this.#onFloatingInput('thumb', (v) => (this.value = v));
|
|
69
74
|
this.#onStartThumbFloatingInput = this.#onFloatingInput('startThumb', (v) => (this.valueStart = v));
|
|
70
75
|
this.#onEndThumbFloatingInput = this.#onFloatingInput('endThumb', (v) => (this.valueEnd = v));
|
|
71
|
-
// Cached floating input change handlers
|
|
76
|
+
// Cached floating input change handlers; flush debounce and dispatch immediately on commit (Enter/blur)
|
|
72
77
|
this.#onThumbFloatingChange = this.#makeFloatingChange('thumb', (v) => (this.value = v), () => this.#onChange());
|
|
73
78
|
this.#onStartThumbFloatingChange = this.#makeFloatingChange('startThumb', (v) => (this.valueStart = v), () => this.#onRangeChange());
|
|
74
79
|
this.#onEndThumbFloatingChange = this.#makeFloatingChange('endThumb', (v) => (this.valueEnd = v), () => this.#onRangeChange());
|
|
75
80
|
// Cached range drag input handlers
|
|
76
81
|
this.#onRangeStartInput = this.#onRangeInput('start');
|
|
77
82
|
this.#onRangeEndInput = this.#onRangeInput('end');
|
|
78
|
-
// Cached pointer/focus handlers per thumb
|
|
83
|
+
// Cached pointer/focus handlers per thumb; prevents new closures on every render
|
|
79
84
|
this.#h = {
|
|
80
85
|
thumb: {
|
|
81
86
|
show: () => this.#showThumbInput('thumb'),
|
|
@@ -96,26 +101,14 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
96
101
|
blur: () => this.#blurFloatingInput('endThumb'),
|
|
97
102
|
},
|
|
98
103
|
};
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
*/
|
|
104
|
+
this.steps = [];
|
|
105
|
+
this.stepParser = null;
|
|
102
106
|
this.range = false;
|
|
103
|
-
/**
|
|
104
|
-
* Represents the minimum permitted value
|
|
105
|
-
*/
|
|
106
107
|
this.min = 0;
|
|
107
|
-
/**
|
|
108
|
-
* Represents the maximum permitted value
|
|
109
|
-
*/
|
|
110
108
|
this.max = 100;
|
|
111
|
-
/**
|
|
112
|
-
* Represents the stepping interval, used both for user interface and validation purposes
|
|
113
|
-
*/
|
|
114
109
|
this.step = 0;
|
|
115
|
-
/**
|
|
116
|
-
|
|
117
|
-
*/
|
|
118
|
-
this.showMinMax = false;
|
|
110
|
+
/** Displays each step's label beneath its dot on the track. Requires `steps` to be set. */
|
|
111
|
+
this.showStepLabels = false;
|
|
119
112
|
}
|
|
120
113
|
#defaultValue;
|
|
121
114
|
#defaultValueStart;
|
|
@@ -123,19 +116,21 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
123
116
|
#value;
|
|
124
117
|
#valueStart;
|
|
125
118
|
#valueEnd;
|
|
119
|
+
#cachedNormalizedSteps;
|
|
120
|
+
#cachedNumericValues;
|
|
126
121
|
#thumbInputState;
|
|
127
122
|
// Pre-bound floating input handlers cached to avoid new function references on every render
|
|
128
123
|
#onThumbFloatingInput;
|
|
129
124
|
#onStartThumbFloatingInput;
|
|
130
125
|
#onEndThumbFloatingInput;
|
|
131
|
-
// Cached floating input change handlers
|
|
126
|
+
// Cached floating input change handlers; flush debounce and dispatch immediately on commit (Enter/blur)
|
|
132
127
|
#onThumbFloatingChange;
|
|
133
128
|
#onStartThumbFloatingChange;
|
|
134
129
|
#onEndThumbFloatingChange;
|
|
135
130
|
// Cached range drag input handlers
|
|
136
131
|
#onRangeStartInput;
|
|
137
132
|
#onRangeEndInput;
|
|
138
|
-
// Cached pointer/focus handlers per thumb
|
|
133
|
+
// Cached pointer/focus handlers per thumb; prevents new closures on every render
|
|
139
134
|
#h;
|
|
140
135
|
static get styles() {
|
|
141
136
|
return [super.styles, style];
|
|
@@ -143,6 +138,17 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
143
138
|
connectedCallback() {
|
|
144
139
|
super.connectedCallback();
|
|
145
140
|
this.updateComplete.then(() => {
|
|
141
|
+
if (this.#stepsMode) {
|
|
142
|
+
this.#syncValuesToSteps();
|
|
143
|
+
if (this.range) {
|
|
144
|
+
this.#defaultValueStart = this.#valueStart;
|
|
145
|
+
this.#defaultValueEnd = this.#valueEnd;
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
this.#defaultValue = this.#value;
|
|
149
|
+
}
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
146
152
|
if (this.range) {
|
|
147
153
|
const [startInput, endInput] = this.#rangeInputs();
|
|
148
154
|
this.#valueStart = startInput.value;
|
|
@@ -162,18 +168,22 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
162
168
|
disconnectedCallback() {
|
|
163
169
|
super.disconnectedCallback();
|
|
164
170
|
this.#clearAllThumbInputState();
|
|
165
|
-
//
|
|
166
|
-
// Lit defers this render until the element reconnects if it is currently disconnected.
|
|
171
|
+
// Re-render to reflect cleared visibility state; Lit defers this until reconnect.
|
|
167
172
|
this.requestUpdate();
|
|
168
173
|
}
|
|
169
174
|
updated(changed) {
|
|
170
175
|
if (changed.has('disabled') && this.disabled) {
|
|
171
176
|
this.#clearAllThumbInputState();
|
|
172
177
|
}
|
|
173
|
-
if (changed.has('
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
178
|
+
if (changed.has('steps')) {
|
|
179
|
+
this.#cachedNormalizedSteps = null;
|
|
180
|
+
this.#cachedNumericValues = null;
|
|
181
|
+
if (this.#stepsMode) {
|
|
182
|
+
this.#syncValuesToSteps();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (!this.#stepsMode && (changed.has('min') || changed.has('max'))) {
|
|
186
|
+
// Native inputs clamp/snap to new bounds; read back and sync state. Re-render if value changed.
|
|
177
187
|
if (this.range) {
|
|
178
188
|
const [startInput, endInput] = this.#rangeInputs();
|
|
179
189
|
const newStart = startInput.value;
|
|
@@ -210,8 +220,7 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
210
220
|
}
|
|
211
221
|
formResetCallback() {
|
|
212
222
|
this.#clearAllThumbInputState();
|
|
213
|
-
//
|
|
214
|
-
// reset value equals the current value (the named setter requestUpdate would be a no-op).
|
|
223
|
+
// Force re-render: if the reset value equals the current value, the setter's requestUpdate is a no-op.
|
|
215
224
|
this.requestUpdate();
|
|
216
225
|
if (this.range) {
|
|
217
226
|
this.valueStart = this.#defaultValueStart;
|
|
@@ -231,15 +240,54 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
231
240
|
entry.focused = false;
|
|
232
241
|
}
|
|
233
242
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
243
|
+
#syncValuesToSteps() {
|
|
244
|
+
const lastIdx = this.#normalizedSteps.length - 1;
|
|
245
|
+
if (this.range) {
|
|
246
|
+
if (this.#stepsIndexOf(this.#valueStart) < 0) {
|
|
247
|
+
this.#valueStart = this.#stepAt(0);
|
|
248
|
+
this.requestUpdate();
|
|
249
|
+
}
|
|
250
|
+
if (this.#stepsIndexOf(this.#valueEnd) < 0) {
|
|
251
|
+
this.#valueEnd = this.#stepAt(lastIdx);
|
|
252
|
+
this.requestUpdate();
|
|
253
|
+
}
|
|
254
|
+
this._setFormValue(`${this.#valueStart},${this.#valueEnd}`);
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
if (this.#stepsIndexOf(this.#value) < 0) {
|
|
258
|
+
this.#value = this.#stepAt(0);
|
|
259
|
+
this.requestUpdate();
|
|
260
|
+
}
|
|
261
|
+
this._setFormValue(this.#value);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/** Steps mode: snaps to nearest step. Normal mode: clamps to [min, max]. Empty string passes through. */
|
|
239
265
|
#clampToRange(rawVal) {
|
|
240
266
|
if (rawVal === '') {
|
|
241
267
|
return rawVal;
|
|
242
268
|
}
|
|
269
|
+
if (this.#stepsMode) {
|
|
270
|
+
if (this.#stepsIndexOf(rawVal) >= 0) {
|
|
271
|
+
return rawVal;
|
|
272
|
+
}
|
|
273
|
+
let n = null;
|
|
274
|
+
if (this.stepParser) {
|
|
275
|
+
const result = this.stepParser(rawVal);
|
|
276
|
+
if (typeof result === 'number') {
|
|
277
|
+
n = result;
|
|
278
|
+
}
|
|
279
|
+
else if (typeof result === 'string' && this.#stepsIndexOf(result) >= 0) {
|
|
280
|
+
return result;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (n === null) {
|
|
284
|
+
n = parseFloat(rawVal);
|
|
285
|
+
}
|
|
286
|
+
if (!isNaN(n)) {
|
|
287
|
+
return this.#snapToSteps(n);
|
|
288
|
+
}
|
|
289
|
+
return this.#stepAt(0);
|
|
290
|
+
}
|
|
243
291
|
const num = parseFloat(rawVal);
|
|
244
292
|
if (isNaN(num)) {
|
|
245
293
|
return rawVal;
|
|
@@ -288,6 +336,99 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
288
336
|
#isVisible(flag) {
|
|
289
337
|
return this.#thumbInputState.get(flag).visible && !this.disabled;
|
|
290
338
|
}
|
|
339
|
+
// ─── Steps helpers ───────────────────────────────────────────────────────────
|
|
340
|
+
get #stepsMode() {
|
|
341
|
+
return this.steps.length > 0;
|
|
342
|
+
}
|
|
343
|
+
get #nativeRangeAttrs() {
|
|
344
|
+
return {
|
|
345
|
+
nativeMin: this.#stepsMode ? '0' : String(this.min),
|
|
346
|
+
nativeMax: this.#stepsMode ? String(this.#normalizedSteps.length - 1) : String(this.max),
|
|
347
|
+
nativeStep: this.#stepsMode ? '1' : this.step > 0 ? String(this.step) : '1',
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
/** Normalizes each StepInput to `{ value, label }`. Cached after first access. */
|
|
351
|
+
get #normalizedSteps() {
|
|
352
|
+
if (!this.#cachedNormalizedSteps) {
|
|
353
|
+
this.#cachedNormalizedSteps = this.steps.map((s) => {
|
|
354
|
+
if (typeof s === 'number') {
|
|
355
|
+
return { value: s, label: String(s) };
|
|
356
|
+
}
|
|
357
|
+
if (typeof s === 'string') {
|
|
358
|
+
return { value: s, label: s };
|
|
359
|
+
}
|
|
360
|
+
return { value: s.value, label: s.label ?? String(s.value) };
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
return this.#cachedNormalizedSteps;
|
|
364
|
+
}
|
|
365
|
+
#stepsIndexOf(val) {
|
|
366
|
+
return this.#normalizedSteps.findIndex((s) => s.label === val);
|
|
367
|
+
}
|
|
368
|
+
#stepAt(index) {
|
|
369
|
+
const normalized = this.#normalizedSteps;
|
|
370
|
+
return normalized[Math.max(0, Math.min(normalized.length - 1, index))].label;
|
|
371
|
+
}
|
|
372
|
+
/** Numeric value per step for #snapToSteps. Strings parsed via parseFloat; unparseable strings map to Infinity. */
|
|
373
|
+
get #stepsNumericValues() {
|
|
374
|
+
if (!this.#cachedNumericValues) {
|
|
375
|
+
this.#cachedNumericValues = this.#normalizedSteps.map((step) => {
|
|
376
|
+
if (typeof step.value === 'number') {
|
|
377
|
+
return step.value;
|
|
378
|
+
}
|
|
379
|
+
const sv = step.value;
|
|
380
|
+
const n = parseFloat(sv);
|
|
381
|
+
return isNaN(n) ? Infinity : n;
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
return this.#cachedNumericValues;
|
|
385
|
+
}
|
|
386
|
+
/** Snaps n to the nearest step by numeric value; overflow steps win only past the last finite value. */
|
|
387
|
+
#snapToSteps(n) {
|
|
388
|
+
const numericValues = this.#stepsNumericValues;
|
|
389
|
+
let lastFiniteValue = -Infinity;
|
|
390
|
+
for (const v of numericValues) {
|
|
391
|
+
if (isFinite(v) && v > lastFiniteValue) {
|
|
392
|
+
lastFiniteValue = v;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
let bestIdx = 0;
|
|
396
|
+
let bestDist = Infinity;
|
|
397
|
+
for (let i = 0; i < numericValues.length; i++) {
|
|
398
|
+
const v = numericValues[i];
|
|
399
|
+
const dist = v === Infinity ? (n > lastFiniteValue ? 0 : Math.abs(n - lastFiniteValue) + 1) : Math.abs(n - v);
|
|
400
|
+
if (dist < bestDist) {
|
|
401
|
+
bestDist = dist;
|
|
402
|
+
bestIdx = i;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return this.#stepAt(bestIdx);
|
|
406
|
+
}
|
|
407
|
+
/** Resolves a user-typed string to a step label; returns null if unresolvable. Steps mode only. */
|
|
408
|
+
#resolveFloatingInput(raw) {
|
|
409
|
+
// Exact label match wins; a valid label is never rejected by a stepParser that doesn't recognize it.
|
|
410
|
+
const exactIdx = this.#stepsIndexOf(raw);
|
|
411
|
+
if (exactIdx >= 0) {
|
|
412
|
+
return this.#stepAt(exactIdx);
|
|
413
|
+
}
|
|
414
|
+
if (this.stepParser) {
|
|
415
|
+
const result = this.stepParser(raw);
|
|
416
|
+
if (result === null) {
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
if (typeof result === 'number') {
|
|
420
|
+
return this.#snapToSteps(result);
|
|
421
|
+
}
|
|
422
|
+
// String result must be a valid step label
|
|
423
|
+
return this.#stepsIndexOf(result) >= 0 ? result : null;
|
|
424
|
+
}
|
|
425
|
+
const n = parseFloat(raw);
|
|
426
|
+
if (isNaN(n)) {
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
return this.#snapToSteps(n);
|
|
430
|
+
}
|
|
431
|
+
// ─── Progress ────────────────────────────────────────────────────────────────
|
|
291
432
|
get progress() {
|
|
292
433
|
return this.#computeProgress(this.#value);
|
|
293
434
|
}
|
|
@@ -298,6 +439,14 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
298
439
|
return this.#computeProgress(this.#valueEnd);
|
|
299
440
|
}
|
|
300
441
|
#computeProgress(rawValue) {
|
|
442
|
+
if (this.#stepsMode) {
|
|
443
|
+
const total = this.#normalizedSteps.length - 1;
|
|
444
|
+
if (total <= 0) {
|
|
445
|
+
return 0;
|
|
446
|
+
}
|
|
447
|
+
const idx = this.#stepsIndexOf(rawValue);
|
|
448
|
+
return idx < 0 ? 0 : parseFloat(((idx / total) * 100).toFixed(4));
|
|
449
|
+
}
|
|
301
450
|
if (this.#range === 0) {
|
|
302
451
|
return 0;
|
|
303
452
|
}
|
|
@@ -315,21 +464,24 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
315
464
|
const endStop = _a.#thumbPositionCSS(progressEnd);
|
|
316
465
|
return `linear-gradient(to right, transparent var(--zui-slider-thumb-size), var(--zui-gray-200) var(--zui-slider-thumb-size), var(--zui-gray-200) ${startStop}, ${c} ${startStop}, ${c} ${endStop}, var(--zui-gray-200) ${endStop}, var(--zui-gray-200) calc(100% - var(--zui-slider-thumb-size)), transparent calc(100% - var(--zui-slider-thumb-size)))`;
|
|
317
466
|
}
|
|
467
|
+
// ─── Render ──────────────────────────────────────────────────────────────────
|
|
318
468
|
render() {
|
|
319
469
|
return html `${this.range ? this.#renderRange() : this.#renderSingle()}${this.#renderMinMaxLabels()}`;
|
|
320
470
|
}
|
|
321
471
|
#renderSingle() {
|
|
322
472
|
const progress = this.progress;
|
|
473
|
+
const { nativeMin, nativeMax, nativeStep } = this.#nativeRangeAttrs;
|
|
474
|
+
const nativeValue = this.#stepsMode ? String(Math.max(0, this.#stepsIndexOf(this.#value))) : this.#value;
|
|
323
475
|
return html `
|
|
324
476
|
<div class="single-wrapper">
|
|
325
477
|
<input
|
|
326
478
|
aria-label="Slider value"
|
|
327
479
|
style=${styleMap({ '--zui-slider-track-bg': this.#singleTrackBackground(progress) })}
|
|
328
480
|
type="range"
|
|
329
|
-
.min="${
|
|
330
|
-
.max="${
|
|
331
|
-
.step="${
|
|
332
|
-
.value="${
|
|
481
|
+
.min="${nativeMin}"
|
|
482
|
+
.max="${nativeMax}"
|
|
483
|
+
.step="${nativeStep}"
|
|
484
|
+
.value="${nativeValue}"
|
|
333
485
|
?disabled="${this.disabled || this.readOnly}"
|
|
334
486
|
@input="${this.#onInput}"
|
|
335
487
|
@change="${this.#onChange}"
|
|
@@ -347,7 +499,7 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
347
499
|
const progressStart = this.progressStart;
|
|
348
500
|
const progressEnd = this.progressEnd;
|
|
349
501
|
return html `
|
|
350
|
-
<div class="range-wrapper">
|
|
502
|
+
<div class="range-wrapper" @click="${this.#onTrackClick}">
|
|
351
503
|
${this.#renderRangeInput('start', this.#rangeTrackBackground(progressStart, progressEnd))}
|
|
352
504
|
${this.#renderFloatingInput(this.#valueStart, this.#onStartThumbFloatingInput, this.#onStartThumbFloatingChange, 'startThumb', this.#isVisible('startThumb'), progressStart)}
|
|
353
505
|
${this.#renderRangeInput('end')}
|
|
@@ -361,19 +513,19 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
361
513
|
const val = which === 'start' ? this.#valueStart : this.#valueEnd;
|
|
362
514
|
const onInput = which === 'start' ? this.#onRangeStartInput : this.#onRangeEndInput;
|
|
363
515
|
const h = this.#h[flag];
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
//
|
|
516
|
+
const { nativeMin, nativeMax, nativeStep } = this.#nativeRangeAttrs;
|
|
517
|
+
const nativeValue = this.#stepsMode ? String(Math.max(0, this.#stepsIndexOf(val))) : val;
|
|
518
|
+
// live() required: direct DOM writes during drag don't trigger a state change, so Lit won't re-sync without it.
|
|
367
519
|
return html `
|
|
368
520
|
<input
|
|
369
521
|
aria-label="${which === 'start' ? 'Range start' : 'Range end'}"
|
|
370
522
|
class="range-${which}"
|
|
371
523
|
type="range"
|
|
372
524
|
style=${trackBg ? styleMap({ '--zui-slider-track-bg': trackBg }) : nothing}
|
|
373
|
-
.min="${
|
|
374
|
-
.max="${
|
|
375
|
-
.step="${
|
|
376
|
-
.value="${live(
|
|
525
|
+
.min="${nativeMin}"
|
|
526
|
+
.max="${nativeMax}"
|
|
527
|
+
.step="${nativeStep}"
|
|
528
|
+
.value="${live(nativeValue)}"
|
|
377
529
|
?disabled="${this.disabled || this.readOnly}"
|
|
378
530
|
@input="${onInput}"
|
|
379
531
|
@change="${this.#onRangeChange}"
|
|
@@ -386,9 +538,9 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
386
538
|
}
|
|
387
539
|
#renderFloatingInput(val, onInput, onFloatingChange, flag, visible, progress) {
|
|
388
540
|
const h = this.#h[flag];
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
|
|
541
|
+
// type="text" in steps mode to allow label and stepParser input.
|
|
542
|
+
// live() required: same-value debounce resolutions skip reactive updates, so Lit won't re-sync without it.
|
|
543
|
+
const ariaLabel = flag === 'startThumb' ? 'Range start value' : flag === 'endThumb' ? 'Range end value' : 'Slider value';
|
|
392
544
|
return html `
|
|
393
545
|
<div
|
|
394
546
|
class=${classMap({ 'thumb-input': true, 'thumb-input--visible': visible })}
|
|
@@ -397,16 +549,12 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
397
549
|
@pointerleave="${h.hide}"
|
|
398
550
|
>
|
|
399
551
|
<input
|
|
400
|
-
aria-label="${
|
|
401
|
-
|
|
402
|
-
: flag === 'endThumb'
|
|
403
|
-
? 'Range end value'
|
|
404
|
-
: 'Slider value'}"
|
|
405
|
-
type="number"
|
|
552
|
+
aria-label="${ariaLabel}"
|
|
553
|
+
type="${this.#stepsMode ? 'text' : 'number'}"
|
|
406
554
|
.value="${live(val)}"
|
|
407
|
-
.min="${String(this.min)}"
|
|
408
|
-
.max="${String(this.max)}"
|
|
409
|
-
.step="${this.step > 0 ? String(this.step) : '1'}"
|
|
555
|
+
.min="${this.#stepsMode ? '' : String(this.min)}"
|
|
556
|
+
.max="${this.#stepsMode ? '' : String(this.max)}"
|
|
557
|
+
.step="${this.#stepsMode ? '' : this.step > 0 ? String(this.step) : '1'}"
|
|
410
558
|
?disabled="${this.disabled}"
|
|
411
559
|
?readonly="${this.readOnly}"
|
|
412
560
|
@input="${onInput}"
|
|
@@ -418,6 +566,28 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
418
566
|
`;
|
|
419
567
|
}
|
|
420
568
|
#renderStepDots() {
|
|
569
|
+
if (this.#stepsMode) {
|
|
570
|
+
const normalized = this.#normalizedSteps;
|
|
571
|
+
const total = normalized.length - 1;
|
|
572
|
+
if (total <= 0 || normalized.length > 100) {
|
|
573
|
+
return nothing;
|
|
574
|
+
}
|
|
575
|
+
const stepDots = normalized.map((step, i) => {
|
|
576
|
+
const left = i === 0
|
|
577
|
+
? 'var(--zui-slider-thumb-size)'
|
|
578
|
+
: i === total
|
|
579
|
+
? 'calc(100% - var(--zui-slider-thumb-size))'
|
|
580
|
+
: _a.#thumbPositionCSS((i / total) * 100);
|
|
581
|
+
return html `<span
|
|
582
|
+
class=${classMap({ 'step-dot': true, 'step-dot--last': i === total && this.showStepLabels })}
|
|
583
|
+
style="left: ${left}"
|
|
584
|
+
></span>
|
|
585
|
+
${this.showStepLabels
|
|
586
|
+
? html `<span class="step-dot-label" style="left: ${left}">${step.label}</span>`
|
|
587
|
+
: nothing}`;
|
|
588
|
+
});
|
|
589
|
+
return html `<div class="step-dots">${stepDots}</div>`;
|
|
590
|
+
}
|
|
421
591
|
if (this.step <= 0 || this.#range === 0) {
|
|
422
592
|
return nothing;
|
|
423
593
|
}
|
|
@@ -443,21 +613,33 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
443
613
|
return html `<div class="step-dots">${dots}</div>`;
|
|
444
614
|
}
|
|
445
615
|
#renderMinMaxLabels() {
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
616
|
+
const normalized = this.#normalizedSteps;
|
|
617
|
+
const minLabel = this.#stepsMode ? normalized[0]?.label ?? '' : String(this.min);
|
|
618
|
+
const maxLabel = this.#stepsMode ? normalized[normalized.length - 1]?.label ?? '' : String(this.max);
|
|
619
|
+
const hidden = this.showStepLabels && this.#stepsMode;
|
|
449
620
|
return html `
|
|
450
|
-
<div
|
|
451
|
-
|
|
452
|
-
|
|
621
|
+
<div
|
|
622
|
+
class="min-max-labels"
|
|
623
|
+
aria-hidden="${ifDefined(hidden ? 'true' : undefined)}"
|
|
624
|
+
style=${hidden ? styleMap({ visibility: 'hidden' }) : nothing}
|
|
625
|
+
>
|
|
626
|
+
<span class="min-max-label">${minLabel}</span>
|
|
627
|
+
<span class="min-max-label">${maxLabel}</span>
|
|
453
628
|
</div>
|
|
454
629
|
`;
|
|
455
630
|
}
|
|
631
|
+
// ─── Event handlers ──────────────────────────────────────────────────────────
|
|
456
632
|
#onInput(e) {
|
|
457
633
|
if (this.readOnly) {
|
|
458
634
|
return;
|
|
459
635
|
}
|
|
460
|
-
|
|
636
|
+
const input = e.target;
|
|
637
|
+
if (this.#stepsMode) {
|
|
638
|
+
this.value = this.#stepAt(parseInt(input.value, 10));
|
|
639
|
+
}
|
|
640
|
+
else {
|
|
641
|
+
this.value = input.value;
|
|
642
|
+
}
|
|
461
643
|
}
|
|
462
644
|
#onChange() {
|
|
463
645
|
this.dispatchEvent(new CustomEvent('change', { detail: this.#value, bubbles: true }));
|
|
@@ -468,20 +650,44 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
468
650
|
const clamped = Math.min(this.max, Math.max(this.min, stepped));
|
|
469
651
|
return stepDecimals > 0 ? clamped.toFixed(stepDecimals) : String(Math.round(clamped));
|
|
470
652
|
}
|
|
653
|
+
#currentValueForFlag(flag) {
|
|
654
|
+
if (flag === 'startThumb') {
|
|
655
|
+
return this.#valueStart;
|
|
656
|
+
}
|
|
657
|
+
if (flag === 'endThumb') {
|
|
658
|
+
return this.#valueEnd;
|
|
659
|
+
}
|
|
660
|
+
return this.#value;
|
|
661
|
+
}
|
|
471
662
|
#makeFloatingChange(flag, setter, dispatch) {
|
|
472
663
|
return (e) => {
|
|
473
664
|
if (this.readOnly) {
|
|
474
665
|
return;
|
|
475
666
|
}
|
|
476
667
|
const input = e.target;
|
|
477
|
-
if (input.value === '') {
|
|
478
|
-
return;
|
|
479
|
-
}
|
|
480
668
|
const entry = this.#thumbInputState.get(flag);
|
|
481
669
|
clearTimeout(entry.debounceTimer);
|
|
482
670
|
entry.debounceTimer = undefined;
|
|
483
|
-
|
|
484
|
-
|
|
671
|
+
if (input.value === '') {
|
|
672
|
+
input.value = this.#currentValueForFlag(flag);
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
if (this.#stepsMode) {
|
|
676
|
+
const resolved = this.#resolveFloatingInput(input.value);
|
|
677
|
+
if (resolved !== null) {
|
|
678
|
+
setter(resolved);
|
|
679
|
+
this.requestUpdate();
|
|
680
|
+
dispatch();
|
|
681
|
+
}
|
|
682
|
+
else {
|
|
683
|
+
input.value = this.#currentValueForFlag(flag);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
else {
|
|
687
|
+
setter(this.#processFloatingValue(parseFloat(input.value)));
|
|
688
|
+
this.requestUpdate();
|
|
689
|
+
dispatch();
|
|
690
|
+
}
|
|
485
691
|
};
|
|
486
692
|
}
|
|
487
693
|
#onFloatingInput(flag, setter) {
|
|
@@ -490,13 +696,30 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
490
696
|
return;
|
|
491
697
|
}
|
|
492
698
|
const input = e.target;
|
|
699
|
+
const entry = this.#thumbInputState.get(flag);
|
|
700
|
+
clearTimeout(entry.debounceTimer);
|
|
701
|
+
entry.debounceTimer = undefined;
|
|
493
702
|
if (input.value === '') {
|
|
494
703
|
return;
|
|
495
704
|
}
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
705
|
+
if (this.#stepsMode) {
|
|
706
|
+
// Capture raw string at event time so the debounce closure reads the right value
|
|
707
|
+
const raw = input.value;
|
|
708
|
+
entry.debounceTimer = setTimeout(() => {
|
|
709
|
+
const resolved = this.#resolveFloatingInput(raw);
|
|
710
|
+
if (resolved !== null) {
|
|
711
|
+
setter(resolved);
|
|
712
|
+
this.requestUpdate();
|
|
713
|
+
}
|
|
714
|
+
}, 500);
|
|
715
|
+
}
|
|
716
|
+
else {
|
|
717
|
+
const raw = parseFloat(input.value);
|
|
718
|
+
entry.debounceTimer = setTimeout(() => {
|
|
719
|
+
setter(this.#processFloatingValue(raw));
|
|
720
|
+
this.requestUpdate();
|
|
721
|
+
}, 500);
|
|
722
|
+
}
|
|
500
723
|
};
|
|
501
724
|
}
|
|
502
725
|
#onRangeInput(which) {
|
|
@@ -505,19 +728,40 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
505
728
|
return;
|
|
506
729
|
}
|
|
507
730
|
const input = e.target;
|
|
508
|
-
if (
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
731
|
+
if (this.#stepsMode) {
|
|
732
|
+
const idx = parseInt(input.value, 10);
|
|
733
|
+
const startIdx = Math.max(0, this.#stepsIndexOf(this.#valueStart));
|
|
734
|
+
const endIdx = Math.max(0, this.#stepsIndexOf(this.#valueEnd));
|
|
735
|
+
if (which === 'start') {
|
|
736
|
+
if (idx >= endIdx) {
|
|
737
|
+
input.value = String(startIdx);
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
this.valueStart = this.#stepAt(idx);
|
|
741
|
+
}
|
|
742
|
+
else {
|
|
743
|
+
if (idx <= startIdx) {
|
|
744
|
+
input.value = String(endIdx);
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
this.valueEnd = this.#stepAt(idx);
|
|
512
748
|
}
|
|
513
|
-
this.valueStart = input.value;
|
|
514
749
|
}
|
|
515
750
|
else {
|
|
516
|
-
if (
|
|
517
|
-
input.value
|
|
518
|
-
|
|
751
|
+
if (which === 'start') {
|
|
752
|
+
if (parseFloat(input.value) >= parseFloat(this.#valueEnd)) {
|
|
753
|
+
input.value = this.#valueStart;
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
this.valueStart = input.value;
|
|
757
|
+
}
|
|
758
|
+
else {
|
|
759
|
+
if (parseFloat(input.value) <= parseFloat(this.#valueStart)) {
|
|
760
|
+
input.value = this.#valueEnd;
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
this.valueEnd = input.value;
|
|
519
764
|
}
|
|
520
|
-
this.valueEnd = input.value;
|
|
521
765
|
}
|
|
522
766
|
};
|
|
523
767
|
}
|
|
@@ -527,6 +771,71 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
527
771
|
bubbles: true,
|
|
528
772
|
}));
|
|
529
773
|
}
|
|
774
|
+
#onTrackClick(e) {
|
|
775
|
+
if (this.disabled || this.readOnly) {
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
const wrapper = e.currentTarget;
|
|
779
|
+
const rect = wrapper.getBoundingClientRect();
|
|
780
|
+
// Thumb diameter is 3× the custom property; radius is 1.5×. Track is inset by one radius each side.
|
|
781
|
+
const thumbRadius = 1.5 * parseFloat(getComputedStyle(this).getPropertyValue('--zui-slider-thumb-size'));
|
|
782
|
+
const trackLeft = rect.left + thumbRadius;
|
|
783
|
+
const effectiveWidth = rect.width - 2 * thumbRadius;
|
|
784
|
+
const fraction = Math.max(0, Math.min(1, (e.clientX - trackLeft) / effectiveWidth));
|
|
785
|
+
if (this.#stepsMode) {
|
|
786
|
+
const total = this.#normalizedSteps.length - 1;
|
|
787
|
+
if (total <= 0) {
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
const clickedIdx = Math.round(fraction * total);
|
|
791
|
+
const startIdx = Math.max(0, this.#stepsIndexOf(this.#valueStart));
|
|
792
|
+
const endIdx = Math.max(0, this.#stepsIndexOf(this.#valueEnd));
|
|
793
|
+
// Ignore clicks within a thumb's hit area
|
|
794
|
+
const startX = trackLeft + (startIdx / total) * effectiveWidth;
|
|
795
|
+
const endX = trackLeft + (endIdx / total) * effectiveWidth;
|
|
796
|
+
if (Math.abs(e.clientX - startX) <= thumbRadius || Math.abs(e.clientX - endX) <= thumbRadius) {
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
// Move whichever thumb is closer by index distance; prefer start on a tie
|
|
800
|
+
if (Math.abs(clickedIdx - startIdx) <= Math.abs(clickedIdx - endIdx)) {
|
|
801
|
+
if (clickedIdx < endIdx) {
|
|
802
|
+
this.valueStart = this.#stepAt(clickedIdx);
|
|
803
|
+
this.#onRangeChange();
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
else {
|
|
807
|
+
if (clickedIdx > startIdx) {
|
|
808
|
+
this.valueEnd = this.#stepAt(clickedIdx);
|
|
809
|
+
this.#onRangeChange();
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
const rawValue = this.min + fraction * (this.max - this.min);
|
|
815
|
+
// Ignore clicks within a thumb's hit area; those are native input interactions, not track clicks.
|
|
816
|
+
const startX = trackLeft + (this.progressStart / 100) * effectiveWidth;
|
|
817
|
+
const endX = trackLeft + (this.progressEnd / 100) * effectiveWidth;
|
|
818
|
+
if (Math.abs(e.clientX - startX) <= thumbRadius || Math.abs(e.clientX - endX) <= thumbRadius) {
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
const snappedStr = this.#processFloatingValue(rawValue);
|
|
822
|
+
const snapped = parseFloat(snappedStr);
|
|
823
|
+
const startNum = parseFloat(this.#valueStart);
|
|
824
|
+
const endNum = parseFloat(this.#valueEnd);
|
|
825
|
+
// Move whichever thumb is closer to the click; prefer start on a tie
|
|
826
|
+
if (Math.abs(rawValue - startNum) <= Math.abs(rawValue - endNum)) {
|
|
827
|
+
if (snapped < endNum) {
|
|
828
|
+
this.valueStart = snappedStr;
|
|
829
|
+
this.#onRangeChange();
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
else {
|
|
833
|
+
if (snapped > startNum) {
|
|
834
|
+
this.valueEnd = snappedStr;
|
|
835
|
+
this.#onRangeChange();
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
}
|
|
530
839
|
#showThumbInput(flag) {
|
|
531
840
|
if (this.disabled) {
|
|
532
841
|
return;
|
|
@@ -559,9 +868,7 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
559
868
|
entry.focused = false;
|
|
560
869
|
this.#scheduleHideThumbInput(flag);
|
|
561
870
|
}
|
|
562
|
-
// Maps
|
|
563
|
-
// `progress% + thumbSize * offset` lands on the thumb center within the inset wrapper.
|
|
564
|
-
// At 0%: offset=1.5 → thumbSize*1.5 (thumb center at min). At 100%: offset=-1.5 → -thumbSize*1.5 (thumb center at max).
|
|
871
|
+
// Maps progress 0-100 to a --zui-slider-thumb-size multiplier (1.5 at 0%, -1.5 at 100%) that centers the thumb on the track.
|
|
565
872
|
static #thumbCenterOffset(progress) {
|
|
566
873
|
return 1.5 - (3 * progress) / 100;
|
|
567
874
|
}
|
|
@@ -570,6 +877,17 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
570
877
|
}
|
|
571
878
|
}
|
|
572
879
|
_a = ZuiSlider;
|
|
880
|
+
__decorate([
|
|
881
|
+
property({
|
|
882
|
+
converter: {
|
|
883
|
+
fromAttribute: (value) => (value ? value.split(',').map((s) => s.trim()) : []),
|
|
884
|
+
toAttribute: (value) => value.map((s) => (typeof s === 'object' ? s.label ?? String(s.value) : String(s))).join(','),
|
|
885
|
+
},
|
|
886
|
+
})
|
|
887
|
+
], ZuiSlider.prototype, "steps", void 0);
|
|
888
|
+
__decorate([
|
|
889
|
+
property({ attribute: false })
|
|
890
|
+
], ZuiSlider.prototype, "stepParser", void 0);
|
|
573
891
|
__decorate([
|
|
574
892
|
property()
|
|
575
893
|
], ZuiSlider.prototype, "value", null);
|
|
@@ -592,7 +910,7 @@ __decorate([
|
|
|
592
910
|
property({ type: Number })
|
|
593
911
|
], ZuiSlider.prototype, "step", void 0);
|
|
594
912
|
__decorate([
|
|
595
|
-
property({ type: Boolean, attribute: 'show-
|
|
596
|
-
], ZuiSlider.prototype, "
|
|
913
|
+
property({ type: Boolean, attribute: 'show-step-labels' })
|
|
914
|
+
], ZuiSlider.prototype, "showStepLabels", void 0);
|
|
597
915
|
window.customElements.define('zui-slider', ZuiSlider);
|
|
598
916
|
//# sourceMappingURL=zui-slider.js.map
|