@zywave/zui-slider 4.4.0-pre.3 → 4.4.0-pre.5
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 +183 -37
- 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 +368 -115
- 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 +34 -7
- package/src/zui-slider.ts +373 -113
- package/test/zui-slider.test.ts +659 -291
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,23 +62,30 @@ 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 }],
|
|
65
70
|
['endThumb', { visible: false, focused: false }],
|
|
66
71
|
]);
|
|
67
|
-
//
|
|
68
|
-
this.#onThumbFloatingInput = this.#onFloatingInput('thumb', (v) => (this.value = v));
|
|
69
|
-
this.#onStartThumbFloatingInput = this.#onFloatingInput('startThumb', (v) => (this.valueStart = v));
|
|
70
|
-
this.#onEndThumbFloatingInput = this.#onFloatingInput('endThumb', (v) => (this.valueEnd = v));
|
|
71
|
-
// Cached floating input change handlers — flush debounce and dispatch immediately on commit (Enter/blur)
|
|
72
|
+
// Cached floating input change handlers; commit value on Enter or blur
|
|
72
73
|
this.#onThumbFloatingChange = this.#makeFloatingChange('thumb', (v) => (this.value = v), () => this.#onChange());
|
|
73
74
|
this.#onStartThumbFloatingChange = this.#makeFloatingChange('startThumb', (v) => (this.valueStart = v), () => this.#onRangeChange());
|
|
74
75
|
this.#onEndThumbFloatingChange = this.#makeFloatingChange('endThumb', (v) => (this.valueEnd = v), () => this.#onRangeChange());
|
|
76
|
+
// Cached keydown handler: Enter commits the floating input value in both number and text modes.
|
|
77
|
+
this.#onFloatingInputKeydown = (e) => {
|
|
78
|
+
if (e.key === 'Enter') {
|
|
79
|
+
e.preventDefault();
|
|
80
|
+
const input = e.target;
|
|
81
|
+
input.dispatchEvent(new Event('change'));
|
|
82
|
+
input.blur();
|
|
83
|
+
}
|
|
84
|
+
};
|
|
75
85
|
// Cached range drag input handlers
|
|
76
86
|
this.#onRangeStartInput = this.#onRangeInput('start');
|
|
77
87
|
this.#onRangeEndInput = this.#onRangeInput('end');
|
|
78
|
-
// Cached pointer/focus handlers per thumb
|
|
88
|
+
// Cached pointer/focus handlers per thumb; prevents new closures on every render
|
|
79
89
|
this.#h = {
|
|
80
90
|
thumb: {
|
|
81
91
|
show: () => this.#showThumbInput('thumb'),
|
|
@@ -96,26 +106,14 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
96
106
|
blur: () => this.#blurFloatingInput('endThumb'),
|
|
97
107
|
},
|
|
98
108
|
};
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
*/
|
|
109
|
+
this.steps = [];
|
|
110
|
+
this.stepParser = null;
|
|
102
111
|
this.range = false;
|
|
103
|
-
/**
|
|
104
|
-
* Represents the minimum permitted value
|
|
105
|
-
*/
|
|
106
112
|
this.min = 0;
|
|
107
|
-
/**
|
|
108
|
-
* Represents the maximum permitted value
|
|
109
|
-
*/
|
|
110
113
|
this.max = 100;
|
|
111
|
-
/**
|
|
112
|
-
* Represents the stepping interval, used both for user interface and validation purposes
|
|
113
|
-
*/
|
|
114
114
|
this.step = 0;
|
|
115
|
-
/**
|
|
116
|
-
|
|
117
|
-
*/
|
|
118
|
-
this.showMinMax = false;
|
|
115
|
+
/** Displays each step's label beneath its dot on the track. Requires `steps` to be set. */
|
|
116
|
+
this.showStepLabels = false;
|
|
119
117
|
}
|
|
120
118
|
#defaultValue;
|
|
121
119
|
#defaultValueStart;
|
|
@@ -123,19 +121,19 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
123
121
|
#value;
|
|
124
122
|
#valueStart;
|
|
125
123
|
#valueEnd;
|
|
124
|
+
#cachedNormalizedSteps;
|
|
125
|
+
#cachedNumericValues;
|
|
126
126
|
#thumbInputState;
|
|
127
|
-
//
|
|
128
|
-
#onThumbFloatingInput;
|
|
129
|
-
#onStartThumbFloatingInput;
|
|
130
|
-
#onEndThumbFloatingInput;
|
|
131
|
-
// Cached floating input change handlers — flush debounce and dispatch immediately on commit (Enter/blur)
|
|
127
|
+
// Cached floating input change handlers; commit value on Enter or blur
|
|
132
128
|
#onThumbFloatingChange;
|
|
133
129
|
#onStartThumbFloatingChange;
|
|
134
130
|
#onEndThumbFloatingChange;
|
|
131
|
+
// Cached keydown handler: Enter commits the floating input value in both number and text modes.
|
|
132
|
+
#onFloatingInputKeydown;
|
|
135
133
|
// Cached range drag input handlers
|
|
136
134
|
#onRangeStartInput;
|
|
137
135
|
#onRangeEndInput;
|
|
138
|
-
// Cached pointer/focus handlers per thumb
|
|
136
|
+
// Cached pointer/focus handlers per thumb; prevents new closures on every render
|
|
139
137
|
#h;
|
|
140
138
|
static get styles() {
|
|
141
139
|
return [super.styles, style];
|
|
@@ -143,6 +141,17 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
143
141
|
connectedCallback() {
|
|
144
142
|
super.connectedCallback();
|
|
145
143
|
this.updateComplete.then(() => {
|
|
144
|
+
if (this.#stepsMode) {
|
|
145
|
+
this.#syncValuesToSteps();
|
|
146
|
+
if (this.range) {
|
|
147
|
+
this.#defaultValueStart = this.#valueStart;
|
|
148
|
+
this.#defaultValueEnd = this.#valueEnd;
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
this.#defaultValue = this.#value;
|
|
152
|
+
}
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
146
155
|
if (this.range) {
|
|
147
156
|
const [startInput, endInput] = this.#rangeInputs();
|
|
148
157
|
this.#valueStart = startInput.value;
|
|
@@ -162,18 +171,22 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
162
171
|
disconnectedCallback() {
|
|
163
172
|
super.disconnectedCallback();
|
|
164
173
|
this.#clearAllThumbInputState();
|
|
165
|
-
//
|
|
166
|
-
// Lit defers this render until the element reconnects if it is currently disconnected.
|
|
174
|
+
// Re-render to reflect cleared visibility state; Lit defers this until reconnect.
|
|
167
175
|
this.requestUpdate();
|
|
168
176
|
}
|
|
169
177
|
updated(changed) {
|
|
170
178
|
if (changed.has('disabled') && this.disabled) {
|
|
171
179
|
this.#clearAllThumbInputState();
|
|
172
180
|
}
|
|
173
|
-
if (changed.has('
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
181
|
+
if (changed.has('steps')) {
|
|
182
|
+
this.#cachedNormalizedSteps = null;
|
|
183
|
+
this.#cachedNumericValues = null;
|
|
184
|
+
if (this.#stepsMode) {
|
|
185
|
+
this.#syncValuesToSteps();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (!this.#stepsMode && (changed.has('min') || changed.has('max'))) {
|
|
189
|
+
// Native inputs clamp/snap to new bounds; read back and sync state. Re-render if value changed.
|
|
177
190
|
if (this.range) {
|
|
178
191
|
const [startInput, endInput] = this.#rangeInputs();
|
|
179
192
|
const newStart = startInput.value;
|
|
@@ -210,8 +223,7 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
210
223
|
}
|
|
211
224
|
formResetCallback() {
|
|
212
225
|
this.#clearAllThumbInputState();
|
|
213
|
-
//
|
|
214
|
-
// reset value equals the current value (the named setter requestUpdate would be a no-op).
|
|
226
|
+
// Force re-render: if the reset value equals the current value, the setter's requestUpdate is a no-op.
|
|
215
227
|
this.requestUpdate();
|
|
216
228
|
if (this.range) {
|
|
217
229
|
this.valueStart = this.#defaultValueStart;
|
|
@@ -224,22 +236,59 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
224
236
|
#clearAllThumbInputState() {
|
|
225
237
|
for (const entry of this.#thumbInputState.values()) {
|
|
226
238
|
clearTimeout(entry.timer);
|
|
227
|
-
clearTimeout(entry.debounceTimer);
|
|
228
239
|
entry.timer = undefined;
|
|
229
|
-
entry.debounceTimer = undefined;
|
|
230
240
|
entry.visible = false;
|
|
231
241
|
entry.focused = false;
|
|
232
242
|
}
|
|
233
243
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
244
|
+
#syncValuesToSteps() {
|
|
245
|
+
const lastIdx = this.#normalizedSteps.length - 1;
|
|
246
|
+
if (this.range) {
|
|
247
|
+
if (this.#stepsIndexOf(this.#valueStart) < 0) {
|
|
248
|
+
this.#valueStart = this.#stepAt(0);
|
|
249
|
+
this.requestUpdate();
|
|
250
|
+
}
|
|
251
|
+
if (this.#stepsIndexOf(this.#valueEnd) < 0) {
|
|
252
|
+
this.#valueEnd = this.#stepAt(lastIdx);
|
|
253
|
+
this.requestUpdate();
|
|
254
|
+
}
|
|
255
|
+
this._setFormValue(`${this.#valueStart},${this.#valueEnd}`);
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
if (this.#stepsIndexOf(this.#value) < 0) {
|
|
259
|
+
this.#value = this.#stepAt(0);
|
|
260
|
+
this.requestUpdate();
|
|
261
|
+
}
|
|
262
|
+
this._setFormValue(this.#value);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
/** Steps mode: snaps to nearest step. Normal mode: clamps to [min, max]. Empty string passes through. */
|
|
239
266
|
#clampToRange(rawVal) {
|
|
240
267
|
if (rawVal === '') {
|
|
241
268
|
return rawVal;
|
|
242
269
|
}
|
|
270
|
+
if (this.#stepsMode) {
|
|
271
|
+
if (this.#stepsIndexOf(rawVal) >= 0) {
|
|
272
|
+
return rawVal;
|
|
273
|
+
}
|
|
274
|
+
let n = null;
|
|
275
|
+
if (this.stepParser) {
|
|
276
|
+
const result = this.stepParser(rawVal);
|
|
277
|
+
if (typeof result === 'number') {
|
|
278
|
+
n = result;
|
|
279
|
+
}
|
|
280
|
+
else if (typeof result === 'string' && this.#stepsIndexOf(result) >= 0) {
|
|
281
|
+
return result;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (n === null) {
|
|
285
|
+
n = parseFloat(rawVal);
|
|
286
|
+
}
|
|
287
|
+
if (!isNaN(n)) {
|
|
288
|
+
return this.#snapToSteps(n);
|
|
289
|
+
}
|
|
290
|
+
return this.#stepAt(0);
|
|
291
|
+
}
|
|
243
292
|
const num = parseFloat(rawVal);
|
|
244
293
|
if (isNaN(num)) {
|
|
245
294
|
return rawVal;
|
|
@@ -288,6 +337,99 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
288
337
|
#isVisible(flag) {
|
|
289
338
|
return this.#thumbInputState.get(flag).visible && !this.disabled;
|
|
290
339
|
}
|
|
340
|
+
// ─── Steps helpers ───────────────────────────────────────────────────────────
|
|
341
|
+
get #stepsMode() {
|
|
342
|
+
return this.steps.length > 0;
|
|
343
|
+
}
|
|
344
|
+
get #nativeRangeAttrs() {
|
|
345
|
+
return {
|
|
346
|
+
nativeMin: this.#stepsMode ? '0' : String(this.min),
|
|
347
|
+
nativeMax: this.#stepsMode ? String(this.#normalizedSteps.length - 1) : String(this.max),
|
|
348
|
+
nativeStep: this.#stepsMode ? '1' : this.step > 0 ? String(this.step) : '1',
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
/** Normalizes each StepInput to `{ value, label }`. Cached after first access. */
|
|
352
|
+
get #normalizedSteps() {
|
|
353
|
+
if (!this.#cachedNormalizedSteps) {
|
|
354
|
+
this.#cachedNormalizedSteps = this.steps.map((s) => {
|
|
355
|
+
if (typeof s === 'number') {
|
|
356
|
+
return { value: s, label: String(s) };
|
|
357
|
+
}
|
|
358
|
+
if (typeof s === 'string') {
|
|
359
|
+
return { value: s, label: s };
|
|
360
|
+
}
|
|
361
|
+
return { value: s.value, label: s.label ?? String(s.value) };
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
return this.#cachedNormalizedSteps;
|
|
365
|
+
}
|
|
366
|
+
#stepsIndexOf(val) {
|
|
367
|
+
return this.#normalizedSteps.findIndex((s) => s.label === val);
|
|
368
|
+
}
|
|
369
|
+
#stepAt(index) {
|
|
370
|
+
const normalized = this.#normalizedSteps;
|
|
371
|
+
return normalized[Math.max(0, Math.min(normalized.length - 1, index))].label;
|
|
372
|
+
}
|
|
373
|
+
/** Numeric value per step for #snapToSteps. Strings parsed via parseFloat; unparseable strings map to Infinity. */
|
|
374
|
+
get #stepsNumericValues() {
|
|
375
|
+
if (!this.#cachedNumericValues) {
|
|
376
|
+
this.#cachedNumericValues = this.#normalizedSteps.map((step) => {
|
|
377
|
+
if (typeof step.value === 'number') {
|
|
378
|
+
return step.value;
|
|
379
|
+
}
|
|
380
|
+
const sv = step.value;
|
|
381
|
+
const n = parseFloat(sv);
|
|
382
|
+
return isNaN(n) ? Infinity : n;
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
return this.#cachedNumericValues;
|
|
386
|
+
}
|
|
387
|
+
/** Snaps n to the nearest step by numeric value; overflow steps win only past the last finite value. */
|
|
388
|
+
#snapToSteps(n) {
|
|
389
|
+
const numericValues = this.#stepsNumericValues;
|
|
390
|
+
let lastFiniteValue = -Infinity;
|
|
391
|
+
for (const v of numericValues) {
|
|
392
|
+
if (isFinite(v) && v > lastFiniteValue) {
|
|
393
|
+
lastFiniteValue = v;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
let bestIdx = 0;
|
|
397
|
+
let bestDist = Infinity;
|
|
398
|
+
for (let i = 0; i < numericValues.length; i++) {
|
|
399
|
+
const v = numericValues[i];
|
|
400
|
+
const dist = v === Infinity ? (n > lastFiniteValue ? 0 : Math.abs(n - lastFiniteValue) + 1) : Math.abs(n - v);
|
|
401
|
+
if (dist < bestDist) {
|
|
402
|
+
bestDist = dist;
|
|
403
|
+
bestIdx = i;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return this.#stepAt(bestIdx);
|
|
407
|
+
}
|
|
408
|
+
/** Resolves a user-typed string to a step label; returns null if unresolvable. Steps mode only. */
|
|
409
|
+
#resolveFloatingInput(raw) {
|
|
410
|
+
// Exact label match wins; a valid label is never rejected by a stepParser that doesn't recognize it.
|
|
411
|
+
const exactIdx = this.#stepsIndexOf(raw);
|
|
412
|
+
if (exactIdx >= 0) {
|
|
413
|
+
return this.#stepAt(exactIdx);
|
|
414
|
+
}
|
|
415
|
+
if (this.stepParser) {
|
|
416
|
+
const result = this.stepParser(raw);
|
|
417
|
+
if (result === null) {
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
if (typeof result === 'number') {
|
|
421
|
+
return this.#snapToSteps(result);
|
|
422
|
+
}
|
|
423
|
+
// String result must be a valid step label
|
|
424
|
+
return this.#stepsIndexOf(result) >= 0 ? result : null;
|
|
425
|
+
}
|
|
426
|
+
const n = parseFloat(raw);
|
|
427
|
+
if (isNaN(n)) {
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
return this.#snapToSteps(n);
|
|
431
|
+
}
|
|
432
|
+
// ─── Progress ────────────────────────────────────────────────────────────────
|
|
291
433
|
get progress() {
|
|
292
434
|
return this.#computeProgress(this.#value);
|
|
293
435
|
}
|
|
@@ -298,6 +440,14 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
298
440
|
return this.#computeProgress(this.#valueEnd);
|
|
299
441
|
}
|
|
300
442
|
#computeProgress(rawValue) {
|
|
443
|
+
if (this.#stepsMode) {
|
|
444
|
+
const total = this.#normalizedSteps.length - 1;
|
|
445
|
+
if (total <= 0) {
|
|
446
|
+
return 0;
|
|
447
|
+
}
|
|
448
|
+
const idx = this.#stepsIndexOf(rawValue);
|
|
449
|
+
return idx < 0 ? 0 : parseFloat(((idx / total) * 100).toFixed(4));
|
|
450
|
+
}
|
|
301
451
|
if (this.#range === 0) {
|
|
302
452
|
return 0;
|
|
303
453
|
}
|
|
@@ -315,21 +465,25 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
315
465
|
const endStop = _a.#thumbPositionCSS(progressEnd);
|
|
316
466
|
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
467
|
}
|
|
468
|
+
// ─── Render ──────────────────────────────────────────────────────────────────
|
|
318
469
|
render() {
|
|
319
470
|
return html `${this.range ? this.#renderRange() : this.#renderSingle()}${this.#renderMinMaxLabels()}`;
|
|
320
471
|
}
|
|
321
472
|
#renderSingle() {
|
|
322
473
|
const progress = this.progress;
|
|
474
|
+
const { nativeMin, nativeMax, nativeStep } = this.#nativeRangeAttrs;
|
|
475
|
+
// live() required: direct DOM writes during drag don't trigger a state change, so Lit won't re-sync without it.
|
|
476
|
+
const nativeValue = this.#stepsMode ? String(Math.max(0, this.#stepsIndexOf(this.#value))) : this.#value;
|
|
323
477
|
return html `
|
|
324
478
|
<div class="single-wrapper">
|
|
325
479
|
<input
|
|
326
480
|
aria-label="Slider value"
|
|
327
481
|
style=${styleMap({ '--zui-slider-track-bg': this.#singleTrackBackground(progress) })}
|
|
328
482
|
type="range"
|
|
329
|
-
.min="${
|
|
330
|
-
.max="${
|
|
331
|
-
.step="${
|
|
332
|
-
.value="${
|
|
483
|
+
.min="${nativeMin}"
|
|
484
|
+
.max="${nativeMax}"
|
|
485
|
+
.step="${nativeStep}"
|
|
486
|
+
.value="${live(nativeValue)}"
|
|
333
487
|
?disabled="${this.disabled || this.readOnly}"
|
|
334
488
|
@input="${this.#onInput}"
|
|
335
489
|
@change="${this.#onChange}"
|
|
@@ -338,7 +492,7 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
338
492
|
@focus="${this.#h.thumb.show}"
|
|
339
493
|
@blur="${this.#h.thumb.hide}"
|
|
340
494
|
/>
|
|
341
|
-
${this.#renderFloatingInput(this.#value, this.#
|
|
495
|
+
${this.#renderFloatingInput(this.#value, this.#onThumbFloatingChange, 'thumb', this.#isVisible('thumb'), progress)}
|
|
342
496
|
${this.#renderStepDots()}
|
|
343
497
|
</div>
|
|
344
498
|
`;
|
|
@@ -349,9 +503,9 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
349
503
|
return html `
|
|
350
504
|
<div class="range-wrapper" @click="${this.#onTrackClick}">
|
|
351
505
|
${this.#renderRangeInput('start', this.#rangeTrackBackground(progressStart, progressEnd))}
|
|
352
|
-
${this.#renderFloatingInput(this.#valueStart, this.#
|
|
506
|
+
${this.#renderFloatingInput(this.#valueStart, this.#onStartThumbFloatingChange, 'startThumb', this.#isVisible('startThumb'), progressStart)}
|
|
353
507
|
${this.#renderRangeInput('end')}
|
|
354
|
-
${this.#renderFloatingInput(this.#valueEnd, this.#
|
|
508
|
+
${this.#renderFloatingInput(this.#valueEnd, this.#onEndThumbFloatingChange, 'endThumb', this.#isVisible('endThumb'), progressEnd)}
|
|
355
509
|
${this.#renderStepDots()}
|
|
356
510
|
</div>
|
|
357
511
|
`;
|
|
@@ -361,19 +515,19 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
361
515
|
const val = which === 'start' ? this.#valueStart : this.#valueEnd;
|
|
362
516
|
const onInput = which === 'start' ? this.#onRangeStartInput : this.#onRangeEndInput;
|
|
363
517
|
const h = this.#h[flag];
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
//
|
|
518
|
+
const { nativeMin, nativeMax, nativeStep } = this.#nativeRangeAttrs;
|
|
519
|
+
const nativeValue = this.#stepsMode ? String(Math.max(0, this.#stepsIndexOf(val))) : val;
|
|
520
|
+
// live() required: direct DOM writes during drag don't trigger a state change, so Lit won't re-sync without it.
|
|
367
521
|
return html `
|
|
368
522
|
<input
|
|
369
523
|
aria-label="${which === 'start' ? 'Range start' : 'Range end'}"
|
|
370
524
|
class="range-${which}"
|
|
371
525
|
type="range"
|
|
372
526
|
style=${trackBg ? styleMap({ '--zui-slider-track-bg': trackBg }) : nothing}
|
|
373
|
-
.min="${
|
|
374
|
-
.max="${
|
|
375
|
-
.step="${
|
|
376
|
-
.value="${live(
|
|
527
|
+
.min="${nativeMin}"
|
|
528
|
+
.max="${nativeMax}"
|
|
529
|
+
.step="${nativeStep}"
|
|
530
|
+
.value="${live(nativeValue)}"
|
|
377
531
|
?disabled="${this.disabled || this.readOnly}"
|
|
378
532
|
@input="${onInput}"
|
|
379
533
|
@change="${this.#onRangeChange}"
|
|
@@ -384,11 +538,11 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
384
538
|
/>
|
|
385
539
|
`;
|
|
386
540
|
}
|
|
387
|
-
#renderFloatingInput(val,
|
|
541
|
+
#renderFloatingInput(val, onFloatingChange, flag, visible, progress) {
|
|
388
542
|
const h = this.#h[flag];
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
|
|
543
|
+
// type="text" in steps mode to allow label and stepParser input.
|
|
544
|
+
// live() required: commits that snap/clamp to the current value skip reactive updates, so Lit won't re-sync without it.
|
|
545
|
+
const ariaLabel = flag === 'startThumb' ? 'Range start value' : flag === 'endThumb' ? 'Range end value' : 'Slider value';
|
|
392
546
|
return html `
|
|
393
547
|
<div
|
|
394
548
|
class=${classMap({ 'thumb-input': true, 'thumb-input--visible': visible })}
|
|
@@ -397,19 +551,15 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
397
551
|
@pointerleave="${h.hide}"
|
|
398
552
|
>
|
|
399
553
|
<input
|
|
400
|
-
aria-label="${
|
|
401
|
-
|
|
402
|
-
: flag === 'endThumb'
|
|
403
|
-
? 'Range end value'
|
|
404
|
-
: 'Slider value'}"
|
|
405
|
-
type="number"
|
|
554
|
+
aria-label="${ariaLabel}"
|
|
555
|
+
type="${this.#stepsMode ? 'text' : 'number'}"
|
|
406
556
|
.value="${live(val)}"
|
|
407
|
-
.min="${String(this.min)}"
|
|
408
|
-
.max="${String(this.max)}"
|
|
409
|
-
.step="${this.step > 0 ? String(this.step) : '1'}"
|
|
557
|
+
.min="${this.#stepsMode ? '' : String(this.min)}"
|
|
558
|
+
.max="${this.#stepsMode ? '' : String(this.max)}"
|
|
559
|
+
.step="${this.#stepsMode ? '' : this.step > 0 ? String(this.step) : '1'}"
|
|
410
560
|
?disabled="${this.disabled}"
|
|
411
561
|
?readonly="${this.readOnly}"
|
|
412
|
-
@
|
|
562
|
+
@keydown="${this.#onFloatingInputKeydown}"
|
|
413
563
|
@change="${onFloatingChange}"
|
|
414
564
|
@focus="${h.focus}"
|
|
415
565
|
@blur="${h.blur}"
|
|
@@ -418,6 +568,28 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
418
568
|
`;
|
|
419
569
|
}
|
|
420
570
|
#renderStepDots() {
|
|
571
|
+
if (this.#stepsMode) {
|
|
572
|
+
const normalized = this.#normalizedSteps;
|
|
573
|
+
const total = normalized.length - 1;
|
|
574
|
+
if (total <= 0 || normalized.length > 100) {
|
|
575
|
+
return nothing;
|
|
576
|
+
}
|
|
577
|
+
const stepDots = normalized.map((step, i) => {
|
|
578
|
+
const left = i === 0
|
|
579
|
+
? 'var(--zui-slider-thumb-size)'
|
|
580
|
+
: i === total
|
|
581
|
+
? 'calc(100% - var(--zui-slider-thumb-size))'
|
|
582
|
+
: _a.#thumbPositionCSS((i / total) * 100);
|
|
583
|
+
return html `<span
|
|
584
|
+
class=${classMap({ 'step-dot': true, 'step-dot--last': i === total && this.showStepLabels })}
|
|
585
|
+
style="left: ${left}"
|
|
586
|
+
></span>
|
|
587
|
+
${this.showStepLabels
|
|
588
|
+
? html `<span class="step-dot-label" style="left: ${left}">${step.label}</span>`
|
|
589
|
+
: nothing}`;
|
|
590
|
+
});
|
|
591
|
+
return html `<div class="step-dots">${stepDots}</div>`;
|
|
592
|
+
}
|
|
421
593
|
if (this.step <= 0 || this.#range === 0) {
|
|
422
594
|
return nothing;
|
|
423
595
|
}
|
|
@@ -443,21 +615,33 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
443
615
|
return html `<div class="step-dots">${dots}</div>`;
|
|
444
616
|
}
|
|
445
617
|
#renderMinMaxLabels() {
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
618
|
+
const normalized = this.#normalizedSteps;
|
|
619
|
+
const minLabel = this.#stepsMode ? normalized[0]?.label ?? '' : String(this.min);
|
|
620
|
+
const maxLabel = this.#stepsMode ? normalized[normalized.length - 1]?.label ?? '' : String(this.max);
|
|
621
|
+
const hidden = this.showStepLabels && this.#stepsMode;
|
|
449
622
|
return html `
|
|
450
|
-
<div
|
|
451
|
-
|
|
452
|
-
|
|
623
|
+
<div
|
|
624
|
+
class="min-max-labels"
|
|
625
|
+
aria-hidden="${ifDefined(hidden ? 'true' : undefined)}"
|
|
626
|
+
style=${hidden ? styleMap({ visibility: 'hidden' }) : nothing}
|
|
627
|
+
>
|
|
628
|
+
<span class="min-max-label">${minLabel}</span>
|
|
629
|
+
<span class="min-max-label">${maxLabel}</span>
|
|
453
630
|
</div>
|
|
454
631
|
`;
|
|
455
632
|
}
|
|
633
|
+
// ─── Event handlers ──────────────────────────────────────────────────────────
|
|
456
634
|
#onInput(e) {
|
|
457
635
|
if (this.readOnly) {
|
|
458
636
|
return;
|
|
459
637
|
}
|
|
460
|
-
|
|
638
|
+
const input = e.target;
|
|
639
|
+
if (this.#stepsMode) {
|
|
640
|
+
this.value = this.#stepAt(parseInt(input.value, 10));
|
|
641
|
+
}
|
|
642
|
+
else {
|
|
643
|
+
this.value = input.value;
|
|
644
|
+
}
|
|
461
645
|
}
|
|
462
646
|
#onChange() {
|
|
463
647
|
this.dispatchEvent(new CustomEvent('change', { detail: this.#value, bubbles: true }));
|
|
@@ -468,6 +652,15 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
468
652
|
const clamped = Math.min(this.max, Math.max(this.min, stepped));
|
|
469
653
|
return stepDecimals > 0 ? clamped.toFixed(stepDecimals) : String(Math.round(clamped));
|
|
470
654
|
}
|
|
655
|
+
#currentValueForFlag(flag) {
|
|
656
|
+
if (flag === 'startThumb') {
|
|
657
|
+
return this.#valueStart;
|
|
658
|
+
}
|
|
659
|
+
if (flag === 'endThumb') {
|
|
660
|
+
return this.#valueEnd;
|
|
661
|
+
}
|
|
662
|
+
return this.#value;
|
|
663
|
+
}
|
|
471
664
|
#makeFloatingChange(flag, setter, dispatch) {
|
|
472
665
|
return (e) => {
|
|
473
666
|
if (this.readOnly) {
|
|
@@ -475,28 +668,30 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
475
668
|
}
|
|
476
669
|
const input = e.target;
|
|
477
670
|
if (input.value === '') {
|
|
671
|
+
input.value = this.#currentValueForFlag(flag);
|
|
478
672
|
return;
|
|
479
673
|
}
|
|
480
|
-
const
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
674
|
+
const before = this.#currentValueForFlag(flag);
|
|
675
|
+
if (this.#stepsMode) {
|
|
676
|
+
const resolved = this.#resolveFloatingInput(input.value);
|
|
677
|
+
if (resolved !== null) {
|
|
678
|
+
setter(resolved);
|
|
679
|
+
this.requestUpdate();
|
|
680
|
+
if (this.#currentValueForFlag(flag) !== before) {
|
|
681
|
+
dispatch();
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
else {
|
|
685
|
+
input.value = this.#currentValueForFlag(flag);
|
|
686
|
+
}
|
|
491
687
|
}
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
688
|
+
else {
|
|
689
|
+
setter(this.#processFloatingValue(parseFloat(input.value)));
|
|
690
|
+
this.requestUpdate();
|
|
691
|
+
if (this.#currentValueForFlag(flag) !== before) {
|
|
692
|
+
dispatch();
|
|
693
|
+
}
|
|
495
694
|
}
|
|
496
|
-
const raw = parseFloat(input.value);
|
|
497
|
-
const entry = this.#thumbInputState.get(flag);
|
|
498
|
-
clearTimeout(entry.debounceTimer);
|
|
499
|
-
entry.debounceTimer = setTimeout(() => setter(this.#processFloatingValue(raw)), 300);
|
|
500
695
|
};
|
|
501
696
|
}
|
|
502
697
|
#onRangeInput(which) {
|
|
@@ -505,19 +700,40 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
505
700
|
return;
|
|
506
701
|
}
|
|
507
702
|
const input = e.target;
|
|
508
|
-
if (
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
703
|
+
if (this.#stepsMode) {
|
|
704
|
+
const idx = parseInt(input.value, 10);
|
|
705
|
+
const startIdx = Math.max(0, this.#stepsIndexOf(this.#valueStart));
|
|
706
|
+
const endIdx = Math.max(0, this.#stepsIndexOf(this.#valueEnd));
|
|
707
|
+
if (which === 'start') {
|
|
708
|
+
if (idx >= endIdx) {
|
|
709
|
+
input.value = String(startIdx);
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
this.valueStart = this.#stepAt(idx);
|
|
713
|
+
}
|
|
714
|
+
else {
|
|
715
|
+
if (idx <= startIdx) {
|
|
716
|
+
input.value = String(endIdx);
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
this.valueEnd = this.#stepAt(idx);
|
|
512
720
|
}
|
|
513
|
-
this.valueStart = input.value;
|
|
514
721
|
}
|
|
515
722
|
else {
|
|
516
|
-
if (
|
|
517
|
-
input.value
|
|
518
|
-
|
|
723
|
+
if (which === 'start') {
|
|
724
|
+
if (parseFloat(input.value) >= parseFloat(this.#valueEnd)) {
|
|
725
|
+
input.value = this.#valueStart;
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
this.valueStart = input.value;
|
|
729
|
+
}
|
|
730
|
+
else {
|
|
731
|
+
if (parseFloat(input.value) <= parseFloat(this.#valueStart)) {
|
|
732
|
+
input.value = this.#valueEnd;
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
this.valueEnd = input.value;
|
|
519
736
|
}
|
|
520
|
-
this.valueEnd = input.value;
|
|
521
737
|
}
|
|
522
738
|
};
|
|
523
739
|
}
|
|
@@ -538,9 +754,37 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
538
754
|
const trackLeft = rect.left + thumbRadius;
|
|
539
755
|
const effectiveWidth = rect.width - 2 * thumbRadius;
|
|
540
756
|
const fraction = Math.max(0, Math.min(1, (e.clientX - trackLeft) / effectiveWidth));
|
|
757
|
+
if (this.#stepsMode) {
|
|
758
|
+
const total = this.#normalizedSteps.length - 1;
|
|
759
|
+
if (total <= 0) {
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
const clickedIdx = Math.round(fraction * total);
|
|
763
|
+
const startIdx = Math.max(0, this.#stepsIndexOf(this.#valueStart));
|
|
764
|
+
const endIdx = Math.max(0, this.#stepsIndexOf(this.#valueEnd));
|
|
765
|
+
// Ignore clicks within a thumb's hit area
|
|
766
|
+
const startX = trackLeft + (startIdx / total) * effectiveWidth;
|
|
767
|
+
const endX = trackLeft + (endIdx / total) * effectiveWidth;
|
|
768
|
+
if (Math.abs(e.clientX - startX) <= thumbRadius || Math.abs(e.clientX - endX) <= thumbRadius) {
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
// Move whichever thumb is closer by index distance; prefer start on a tie
|
|
772
|
+
if (Math.abs(clickedIdx - startIdx) <= Math.abs(clickedIdx - endIdx)) {
|
|
773
|
+
if (clickedIdx < endIdx) {
|
|
774
|
+
this.valueStart = this.#stepAt(clickedIdx);
|
|
775
|
+
this.#onRangeChange();
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
else {
|
|
779
|
+
if (clickedIdx > startIdx) {
|
|
780
|
+
this.valueEnd = this.#stepAt(clickedIdx);
|
|
781
|
+
this.#onRangeChange();
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
541
786
|
const rawValue = this.min + fraction * (this.max - this.min);
|
|
542
|
-
// Ignore clicks
|
|
543
|
-
// interactions and should not be treated as track clicks.
|
|
787
|
+
// Ignore clicks within a thumb's hit area; those are native input interactions, not track clicks.
|
|
544
788
|
const startX = trackLeft + (this.progressStart / 100) * effectiveWidth;
|
|
545
789
|
const endX = trackLeft + (this.progressEnd / 100) * effectiveWidth;
|
|
546
790
|
if (Math.abs(e.clientX - startX) <= thumbRadius || Math.abs(e.clientX - endX) <= thumbRadius) {
|
|
@@ -596,9 +840,7 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
596
840
|
entry.focused = false;
|
|
597
841
|
this.#scheduleHideThumbInput(flag);
|
|
598
842
|
}
|
|
599
|
-
// Maps
|
|
600
|
-
// `progress% + thumbSize * offset` lands on the thumb center within the inset wrapper.
|
|
601
|
-
// At 0%: offset=1.5 → thumbSize*1.5 (thumb center at min). At 100%: offset=-1.5 → -thumbSize*1.5 (thumb center at max).
|
|
843
|
+
// 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.
|
|
602
844
|
static #thumbCenterOffset(progress) {
|
|
603
845
|
return 1.5 - (3 * progress) / 100;
|
|
604
846
|
}
|
|
@@ -607,6 +849,17 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
607
849
|
}
|
|
608
850
|
}
|
|
609
851
|
_a = ZuiSlider;
|
|
852
|
+
__decorate([
|
|
853
|
+
property({
|
|
854
|
+
converter: {
|
|
855
|
+
fromAttribute: (value) => (value ? value.split(',').map((s) => s.trim()) : []),
|
|
856
|
+
toAttribute: (value) => value.map((s) => (typeof s === 'object' ? s.label ?? String(s.value) : String(s))).join(','),
|
|
857
|
+
},
|
|
858
|
+
})
|
|
859
|
+
], ZuiSlider.prototype, "steps", void 0);
|
|
860
|
+
__decorate([
|
|
861
|
+
property({ attribute: false })
|
|
862
|
+
], ZuiSlider.prototype, "stepParser", void 0);
|
|
610
863
|
__decorate([
|
|
611
864
|
property()
|
|
612
865
|
], ZuiSlider.prototype, "value", null);
|
|
@@ -629,7 +882,7 @@ __decorate([
|
|
|
629
882
|
property({ type: Number })
|
|
630
883
|
], ZuiSlider.prototype, "step", void 0);
|
|
631
884
|
__decorate([
|
|
632
|
-
property({ type: Boolean, attribute: 'show-
|
|
633
|
-
], ZuiSlider.prototype, "
|
|
885
|
+
property({ type: Boolean, attribute: 'show-step-labels' })
|
|
886
|
+
], ZuiSlider.prototype, "showStepLabels", void 0);
|
|
634
887
|
window.customElements.define('zui-slider', ZuiSlider);
|
|
635
888
|
//# sourceMappingURL=zui-slider.js.map
|