@zywave/zui-slider 4.4.0-pre.3 → 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 +188 -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 +372 -91
- 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 +32 -7
- package/src/zui-slider.ts +380 -89
- package/test/zui-slider.test.ts +561 -131
package/src/zui-slider.ts
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { ZuiFormAssociatedElement } from '@zywave/zui-base';
|
|
2
|
-
import { html, nothing
|
|
2
|
+
import { html, nothing } from 'lit';
|
|
3
3
|
import { property } from 'lit/decorators.js';
|
|
4
4
|
import { classMap } from 'lit/directives/class-map.js';
|
|
5
|
+
import { ifDefined } from 'lit/directives/if-defined.js';
|
|
5
6
|
import { live } from 'lit/directives/live.js';
|
|
6
7
|
import { styleMap } from 'lit/directives/style-map.js';
|
|
7
8
|
import { style } from './zui-slider-css.js';
|
|
8
9
|
|
|
9
10
|
type ThumbFlag = 'thumb' | 'startThumb' | 'endThumb';
|
|
10
11
|
|
|
12
|
+
/** Number, string, or `{ value, label? }` object. `label` is the display value; `value` drives snapping. */
|
|
13
|
+
type StepInput = number | string | { value: number | string; label?: string };
|
|
14
|
+
|
|
11
15
|
/**
|
|
12
16
|
* A range form control for choosing values along a slider.
|
|
13
17
|
* @element zui-slider
|
|
@@ -23,8 +27,8 @@ type ThumbFlag = 'thumb' | 'startThumb' | 'endThumb';
|
|
|
23
27
|
* @attr {number} [min=0] - Represents the minimum permitted value
|
|
24
28
|
* @attr {number} [max=100] - Represents the maximum permitted value
|
|
25
29
|
* @attr {number} [step=0] - Represents the stepping interval; 0 means any value is allowed
|
|
26
|
-
* @attr {
|
|
27
|
-
*
|
|
30
|
+
* @attr {string} [steps=''] - Comma-separated step labels; overrides min/max/step. Labels containing commas must be set via the property instead.
|
|
31
|
+
* @attr {boolean} [show-step-labels=false] - When set, displays each step's label beneath its dot on the track
|
|
28
32
|
* @prop {string | null} [name=null] - The name of this element that is associated with form submission
|
|
29
33
|
* @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
|
|
30
34
|
* @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
|
|
@@ -40,7 +44,9 @@ type ThumbFlag = 'thumb' | 'startThumb' | 'endThumb';
|
|
|
40
44
|
* @prop {number} [min=0] - Represents the minimum permitted value
|
|
41
45
|
* @prop {number} [max=100] - Represents the maximum permitted value
|
|
42
46
|
* @prop {number} [step=0] - Represents the stepping interval; 0 means any value is allowed
|
|
43
|
-
* @prop {
|
|
47
|
+
* @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.
|
|
48
|
+
* @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.
|
|
49
|
+
* @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
|
|
44
50
|
*
|
|
45
51
|
* @cssprop [--zui-slider-input-width=7ch] - Width of the floating value input above each slider thumb
|
|
46
52
|
*
|
|
@@ -55,6 +61,9 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
55
61
|
#valueStart: string = '0';
|
|
56
62
|
#valueEnd: string = '100';
|
|
57
63
|
|
|
64
|
+
#cachedNormalizedSteps: { value: number | string; label: string }[] | null = null;
|
|
65
|
+
#cachedNumericValues: number[] | null = null;
|
|
66
|
+
|
|
58
67
|
#thumbInputState = new Map<
|
|
59
68
|
ThumbFlag,
|
|
60
69
|
{
|
|
@@ -74,7 +83,7 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
74
83
|
#onStartThumbFloatingInput = this.#onFloatingInput('startThumb', (v) => (this.valueStart = v));
|
|
75
84
|
#onEndThumbFloatingInput = this.#onFloatingInput('endThumb', (v) => (this.valueEnd = v));
|
|
76
85
|
|
|
77
|
-
// Cached floating input change handlers
|
|
86
|
+
// Cached floating input change handlers; flush debounce and dispatch immediately on commit (Enter/blur)
|
|
78
87
|
#onThumbFloatingChange = this.#makeFloatingChange(
|
|
79
88
|
'thumb',
|
|
80
89
|
(v) => (this.value = v),
|
|
@@ -95,7 +104,7 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
95
104
|
#onRangeStartInput = this.#onRangeInput('start');
|
|
96
105
|
#onRangeEndInput = this.#onRangeInput('end');
|
|
97
106
|
|
|
98
|
-
// Cached pointer/focus handlers per thumb
|
|
107
|
+
// Cached pointer/focus handlers per thumb; prevents new closures on every render
|
|
99
108
|
#h: Record<ThumbFlag, { show: () => void; hide: () => void; focus: () => void; blur: () => void }> = {
|
|
100
109
|
thumb: {
|
|
101
110
|
show: () => this.#showThumbInput('thumb'),
|
|
@@ -121,9 +130,30 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
121
130
|
return [super.styles, style];
|
|
122
131
|
}
|
|
123
132
|
|
|
133
|
+
@property({
|
|
134
|
+
converter: {
|
|
135
|
+
fromAttribute: (value: string | null) => (value ? value.split(',').map((s) => s.trim()) : []),
|
|
136
|
+
toAttribute: (value: StepInput[]) =>
|
|
137
|
+
value.map((s) => (typeof s === 'object' ? s.label ?? String(s.value) : String(s))).join(','),
|
|
138
|
+
},
|
|
139
|
+
})
|
|
140
|
+
steps: StepInput[] = [];
|
|
141
|
+
|
|
142
|
+
@property({ attribute: false }) stepParser: ((input: string) => number | string | null) | null = null;
|
|
143
|
+
|
|
124
144
|
connectedCallback() {
|
|
125
145
|
super.connectedCallback();
|
|
126
146
|
this.updateComplete.then(() => {
|
|
147
|
+
if (this.#stepsMode) {
|
|
148
|
+
this.#syncValuesToSteps();
|
|
149
|
+
if (this.range) {
|
|
150
|
+
this.#defaultValueStart = this.#valueStart;
|
|
151
|
+
this.#defaultValueEnd = this.#valueEnd;
|
|
152
|
+
} else {
|
|
153
|
+
this.#defaultValue = this.#value;
|
|
154
|
+
}
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
127
157
|
if (this.range) {
|
|
128
158
|
const [startInput, endInput] = this.#rangeInputs();
|
|
129
159
|
this.#valueStart = startInput.value;
|
|
@@ -143,8 +173,7 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
143
173
|
disconnectedCallback() {
|
|
144
174
|
super.disconnectedCallback();
|
|
145
175
|
this.#clearAllThumbInputState();
|
|
146
|
-
//
|
|
147
|
-
// Lit defers this render until the element reconnects if it is currently disconnected.
|
|
176
|
+
// Re-render to reflect cleared visibility state; Lit defers this until reconnect.
|
|
148
177
|
this.requestUpdate();
|
|
149
178
|
}
|
|
150
179
|
|
|
@@ -152,10 +181,15 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
152
181
|
if (changed.has('disabled') && this.disabled) {
|
|
153
182
|
this.#clearAllThumbInputState();
|
|
154
183
|
}
|
|
155
|
-
if (changed.has('
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
184
|
+
if (changed.has('steps')) {
|
|
185
|
+
this.#cachedNormalizedSteps = null;
|
|
186
|
+
this.#cachedNumericValues = null;
|
|
187
|
+
if (this.#stepsMode) {
|
|
188
|
+
this.#syncValuesToSteps();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (!this.#stepsMode && (changed.has('min') || changed.has('max'))) {
|
|
192
|
+
// Native inputs clamp/snap to new bounds; read back and sync state. Re-render if value changed.
|
|
159
193
|
if (this.range) {
|
|
160
194
|
const [startInput, endInput] = this.#rangeInputs();
|
|
161
195
|
const newStart = startInput.value;
|
|
@@ -196,8 +230,7 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
196
230
|
|
|
197
231
|
protected formResetCallback() {
|
|
198
232
|
this.#clearAllThumbInputState();
|
|
199
|
-
//
|
|
200
|
-
// reset value equals the current value (the named setter requestUpdate would be a no-op).
|
|
233
|
+
// Force re-render: if the reset value equals the current value, the setter's requestUpdate is a no-op.
|
|
201
234
|
this.requestUpdate();
|
|
202
235
|
if (this.range) {
|
|
203
236
|
this.valueStart = this.#defaultValueStart;
|
|
@@ -218,15 +251,53 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
218
251
|
}
|
|
219
252
|
}
|
|
220
253
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
254
|
+
#syncValuesToSteps() {
|
|
255
|
+
const lastIdx = this.#normalizedSteps.length - 1;
|
|
256
|
+
if (this.range) {
|
|
257
|
+
if (this.#stepsIndexOf(this.#valueStart) < 0) {
|
|
258
|
+
this.#valueStart = this.#stepAt(0);
|
|
259
|
+
this.requestUpdate();
|
|
260
|
+
}
|
|
261
|
+
if (this.#stepsIndexOf(this.#valueEnd) < 0) {
|
|
262
|
+
this.#valueEnd = this.#stepAt(lastIdx);
|
|
263
|
+
this.requestUpdate();
|
|
264
|
+
}
|
|
265
|
+
this._setFormValue(`${this.#valueStart},${this.#valueEnd}`);
|
|
266
|
+
} else {
|
|
267
|
+
if (this.#stepsIndexOf(this.#value) < 0) {
|
|
268
|
+
this.#value = this.#stepAt(0);
|
|
269
|
+
this.requestUpdate();
|
|
270
|
+
}
|
|
271
|
+
this._setFormValue(this.#value);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Steps mode: snaps to nearest step. Normal mode: clamps to [min, max]. Empty string passes through. */
|
|
226
276
|
#clampToRange(rawVal: string): string {
|
|
227
277
|
if (rawVal === '') {
|
|
228
278
|
return rawVal;
|
|
229
279
|
}
|
|
280
|
+
if (this.#stepsMode) {
|
|
281
|
+
if (this.#stepsIndexOf(rawVal) >= 0) {
|
|
282
|
+
return rawVal;
|
|
283
|
+
}
|
|
284
|
+
let n: number | null = null;
|
|
285
|
+
if (this.stepParser) {
|
|
286
|
+
const result = this.stepParser(rawVal);
|
|
287
|
+
if (typeof result === 'number') {
|
|
288
|
+
n = result;
|
|
289
|
+
} else if (typeof result === 'string' && this.#stepsIndexOf(result) >= 0) {
|
|
290
|
+
return result;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (n === null) {
|
|
294
|
+
n = parseFloat(rawVal);
|
|
295
|
+
}
|
|
296
|
+
if (!isNaN(n)) {
|
|
297
|
+
return this.#snapToSteps(n);
|
|
298
|
+
}
|
|
299
|
+
return this.#stepAt(0);
|
|
300
|
+
}
|
|
230
301
|
const num = parseFloat(rawVal);
|
|
231
302
|
if (isNaN(num)) {
|
|
232
303
|
return rawVal;
|
|
@@ -251,9 +322,6 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
251
322
|
return parseFloat(this.#value);
|
|
252
323
|
}
|
|
253
324
|
|
|
254
|
-
/**
|
|
255
|
-
* Enables range mode with two thumbs for selecting a value range
|
|
256
|
-
*/
|
|
257
325
|
@property({ type: Boolean }) range = false;
|
|
258
326
|
|
|
259
327
|
@property({ attribute: 'value-start' })
|
|
@@ -282,25 +350,14 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
282
350
|
this.requestUpdate('valueEnd', oldVal);
|
|
283
351
|
}
|
|
284
352
|
|
|
285
|
-
/**
|
|
286
|
-
* Represents the minimum permitted value
|
|
287
|
-
*/
|
|
288
353
|
@property({ type: Number }) min = 0;
|
|
289
354
|
|
|
290
|
-
/**
|
|
291
|
-
* Represents the maximum permitted value
|
|
292
|
-
*/
|
|
293
355
|
@property({ type: Number }) max = 100;
|
|
294
356
|
|
|
295
|
-
/**
|
|
296
|
-
* Represents the stepping interval, used both for user interface and validation purposes
|
|
297
|
-
*/
|
|
298
357
|
@property({ type: Number }) step = 0;
|
|
299
358
|
|
|
300
|
-
/**
|
|
301
|
-
|
|
302
|
-
*/
|
|
303
|
-
@property({ type: Boolean, attribute: 'show-min-max' }) showMinMax = false;
|
|
359
|
+
/** Displays each step's label beneath its dot on the track. Requires `steps` to be set. */
|
|
360
|
+
@property({ type: Boolean, attribute: 'show-step-labels' }) showStepLabels = false;
|
|
304
361
|
|
|
305
362
|
get #range() {
|
|
306
363
|
return this.max - this.min;
|
|
@@ -314,6 +371,109 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
314
371
|
return this.#thumbInputState.get(flag)!.visible && !this.disabled;
|
|
315
372
|
}
|
|
316
373
|
|
|
374
|
+
// ─── Steps helpers ───────────────────────────────────────────────────────────
|
|
375
|
+
|
|
376
|
+
get #stepsMode(): boolean {
|
|
377
|
+
return this.steps.length > 0;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
get #nativeRangeAttrs(): { nativeMin: string; nativeMax: string; nativeStep: string } {
|
|
381
|
+
return {
|
|
382
|
+
nativeMin: this.#stepsMode ? '0' : String(this.min),
|
|
383
|
+
nativeMax: this.#stepsMode ? String(this.#normalizedSteps.length - 1) : String(this.max),
|
|
384
|
+
nativeStep: this.#stepsMode ? '1' : this.step > 0 ? String(this.step) : '1',
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Normalizes each StepInput to `{ value, label }`. Cached after first access. */
|
|
389
|
+
get #normalizedSteps(): { value: number | string; label: string }[] {
|
|
390
|
+
if (!this.#cachedNormalizedSteps) {
|
|
391
|
+
this.#cachedNormalizedSteps = this.steps.map((s) => {
|
|
392
|
+
if (typeof s === 'number') {
|
|
393
|
+
return { value: s, label: String(s) };
|
|
394
|
+
}
|
|
395
|
+
if (typeof s === 'string') {
|
|
396
|
+
return { value: s, label: s };
|
|
397
|
+
}
|
|
398
|
+
return { value: s.value, label: s.label ?? String(s.value) };
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
return this.#cachedNormalizedSteps;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
#stepsIndexOf(val: string): number {
|
|
405
|
+
return this.#normalizedSteps.findIndex((s) => s.label === val);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
#stepAt(index: number): string {
|
|
409
|
+
const normalized = this.#normalizedSteps;
|
|
410
|
+
return normalized[Math.max(0, Math.min(normalized.length - 1, index))].label;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Numeric value per step for #snapToSteps. Strings parsed via parseFloat; unparseable strings map to Infinity. */
|
|
414
|
+
get #stepsNumericValues(): number[] {
|
|
415
|
+
if (!this.#cachedNumericValues) {
|
|
416
|
+
this.#cachedNumericValues = this.#normalizedSteps.map((step) => {
|
|
417
|
+
if (typeof step.value === 'number') {
|
|
418
|
+
return step.value;
|
|
419
|
+
}
|
|
420
|
+
const sv = step.value as string;
|
|
421
|
+
const n = parseFloat(sv);
|
|
422
|
+
return isNaN(n) ? Infinity : n;
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
return this.#cachedNumericValues;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Snaps n to the nearest step by numeric value; overflow steps win only past the last finite value. */
|
|
429
|
+
#snapToSteps(n: number): string {
|
|
430
|
+
const numericValues = this.#stepsNumericValues;
|
|
431
|
+
let lastFiniteValue = -Infinity;
|
|
432
|
+
for (const v of numericValues) {
|
|
433
|
+
if (isFinite(v) && v > lastFiniteValue) {
|
|
434
|
+
lastFiniteValue = v;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
let bestIdx = 0;
|
|
438
|
+
let bestDist = Infinity;
|
|
439
|
+
for (let i = 0; i < numericValues.length; i++) {
|
|
440
|
+
const v = numericValues[i];
|
|
441
|
+
const dist = v === Infinity ? (n > lastFiniteValue ? 0 : Math.abs(n - lastFiniteValue) + 1) : Math.abs(n - v);
|
|
442
|
+
if (dist < bestDist) {
|
|
443
|
+
bestDist = dist;
|
|
444
|
+
bestIdx = i;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return this.#stepAt(bestIdx);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Resolves a user-typed string to a step label; returns null if unresolvable. Steps mode only. */
|
|
451
|
+
#resolveFloatingInput(raw: string): string | null {
|
|
452
|
+
// Exact label match wins; a valid label is never rejected by a stepParser that doesn't recognize it.
|
|
453
|
+
const exactIdx = this.#stepsIndexOf(raw);
|
|
454
|
+
if (exactIdx >= 0) {
|
|
455
|
+
return this.#stepAt(exactIdx);
|
|
456
|
+
}
|
|
457
|
+
if (this.stepParser) {
|
|
458
|
+
const result = this.stepParser(raw);
|
|
459
|
+
if (result === null) {
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
462
|
+
if (typeof result === 'number') {
|
|
463
|
+
return this.#snapToSteps(result);
|
|
464
|
+
}
|
|
465
|
+
// String result must be a valid step label
|
|
466
|
+
return this.#stepsIndexOf(result) >= 0 ? result : null;
|
|
467
|
+
}
|
|
468
|
+
const n = parseFloat(raw);
|
|
469
|
+
if (isNaN(n)) {
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
return this.#snapToSteps(n);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// ─── Progress ────────────────────────────────────────────────────────────────
|
|
476
|
+
|
|
317
477
|
get progress() {
|
|
318
478
|
return this.#computeProgress(this.#value);
|
|
319
479
|
}
|
|
@@ -327,6 +487,14 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
327
487
|
}
|
|
328
488
|
|
|
329
489
|
#computeProgress(rawValue: string): number {
|
|
490
|
+
if (this.#stepsMode) {
|
|
491
|
+
const total = this.#normalizedSteps.length - 1;
|
|
492
|
+
if (total <= 0) {
|
|
493
|
+
return 0;
|
|
494
|
+
}
|
|
495
|
+
const idx = this.#stepsIndexOf(rawValue);
|
|
496
|
+
return idx < 0 ? 0 : parseFloat(((idx / total) * 100).toFixed(4));
|
|
497
|
+
}
|
|
330
498
|
if (this.#range === 0) {
|
|
331
499
|
return 0;
|
|
332
500
|
}
|
|
@@ -347,22 +515,26 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
347
515
|
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)))`;
|
|
348
516
|
}
|
|
349
517
|
|
|
518
|
+
// ─── Render ──────────────────────────────────────────────────────────────────
|
|
519
|
+
|
|
350
520
|
render() {
|
|
351
521
|
return html`${this.range ? this.#renderRange() : this.#renderSingle()}${this.#renderMinMaxLabels()}`;
|
|
352
522
|
}
|
|
353
523
|
|
|
354
524
|
#renderSingle() {
|
|
355
525
|
const progress = this.progress;
|
|
526
|
+
const { nativeMin, nativeMax, nativeStep } = this.#nativeRangeAttrs;
|
|
527
|
+
const nativeValue = this.#stepsMode ? String(Math.max(0, this.#stepsIndexOf(this.#value))) : this.#value;
|
|
356
528
|
return html`
|
|
357
529
|
<div class="single-wrapper">
|
|
358
530
|
<input
|
|
359
531
|
aria-label="Slider value"
|
|
360
532
|
style=${styleMap({ '--zui-slider-track-bg': this.#singleTrackBackground(progress) })}
|
|
361
533
|
type="range"
|
|
362
|
-
.min="${
|
|
363
|
-
.max="${
|
|
364
|
-
.step="${
|
|
365
|
-
.value="${
|
|
534
|
+
.min="${nativeMin}"
|
|
535
|
+
.max="${nativeMax}"
|
|
536
|
+
.step="${nativeStep}"
|
|
537
|
+
.value="${nativeValue}"
|
|
366
538
|
?disabled="${this.disabled || this.readOnly}"
|
|
367
539
|
@input="${this.#onInput}"
|
|
368
540
|
@change="${this.#onChange}"
|
|
@@ -417,19 +589,19 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
417
589
|
const val = which === 'start' ? this.#valueStart : this.#valueEnd;
|
|
418
590
|
const onInput = which === 'start' ? this.#onRangeStartInput : this.#onRangeEndInput;
|
|
419
591
|
const h = this.#h[flag];
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
//
|
|
592
|
+
const { nativeMin, nativeMax, nativeStep } = this.#nativeRangeAttrs;
|
|
593
|
+
const nativeValue = this.#stepsMode ? String(Math.max(0, this.#stepsIndexOf(val))) : val;
|
|
594
|
+
// live() required: direct DOM writes during drag don't trigger a state change, so Lit won't re-sync without it.
|
|
423
595
|
return html`
|
|
424
596
|
<input
|
|
425
597
|
aria-label="${which === 'start' ? 'Range start' : 'Range end'}"
|
|
426
598
|
class="range-${which}"
|
|
427
599
|
type="range"
|
|
428
600
|
style=${trackBg ? styleMap({ '--zui-slider-track-bg': trackBg }) : nothing}
|
|
429
|
-
.min="${
|
|
430
|
-
.max="${
|
|
431
|
-
.step="${
|
|
432
|
-
.value="${live(
|
|
601
|
+
.min="${nativeMin}"
|
|
602
|
+
.max="${nativeMax}"
|
|
603
|
+
.step="${nativeStep}"
|
|
604
|
+
.value="${live(nativeValue)}"
|
|
433
605
|
?disabled="${this.disabled || this.readOnly}"
|
|
434
606
|
@input="${onInput}"
|
|
435
607
|
@change="${this.#onRangeChange}"
|
|
@@ -450,9 +622,10 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
450
622
|
progress: number
|
|
451
623
|
) {
|
|
452
624
|
const h = this.#h[flag];
|
|
453
|
-
//
|
|
454
|
-
//
|
|
455
|
-
|
|
625
|
+
// type="text" in steps mode to allow label and stepParser input.
|
|
626
|
+
// live() required: same-value debounce resolutions skip reactive updates, so Lit won't re-sync without it.
|
|
627
|
+
const ariaLabel =
|
|
628
|
+
flag === 'startThumb' ? 'Range start value' : flag === 'endThumb' ? 'Range end value' : 'Slider value';
|
|
456
629
|
return html`
|
|
457
630
|
<div
|
|
458
631
|
class=${classMap({ 'thumb-input': true, 'thumb-input--visible': visible })}
|
|
@@ -461,16 +634,12 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
461
634
|
@pointerleave="${h.hide}"
|
|
462
635
|
>
|
|
463
636
|
<input
|
|
464
|
-
aria-label="${
|
|
465
|
-
|
|
466
|
-
: flag === 'endThumb'
|
|
467
|
-
? 'Range end value'
|
|
468
|
-
: 'Slider value'}"
|
|
469
|
-
type="number"
|
|
637
|
+
aria-label="${ariaLabel}"
|
|
638
|
+
type="${this.#stepsMode ? 'text' : 'number'}"
|
|
470
639
|
.value="${live(val)}"
|
|
471
|
-
.min="${String(this.min)}"
|
|
472
|
-
.max="${String(this.max)}"
|
|
473
|
-
.step="${this.step > 0 ? String(this.step) : '1'}"
|
|
640
|
+
.min="${this.#stepsMode ? '' : String(this.min)}"
|
|
641
|
+
.max="${this.#stepsMode ? '' : String(this.max)}"
|
|
642
|
+
.step="${this.#stepsMode ? '' : this.step > 0 ? String(this.step) : '1'}"
|
|
474
643
|
?disabled="${this.disabled}"
|
|
475
644
|
?readonly="${this.readOnly}"
|
|
476
645
|
@input="${onInput}"
|
|
@@ -483,6 +652,29 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
483
652
|
}
|
|
484
653
|
|
|
485
654
|
#renderStepDots() {
|
|
655
|
+
if (this.#stepsMode) {
|
|
656
|
+
const normalized = this.#normalizedSteps;
|
|
657
|
+
const total = normalized.length - 1;
|
|
658
|
+
if (total <= 0 || normalized.length > 100) {
|
|
659
|
+
return nothing;
|
|
660
|
+
}
|
|
661
|
+
const stepDots = normalized.map((step, i) => {
|
|
662
|
+
const left =
|
|
663
|
+
i === 0
|
|
664
|
+
? 'var(--zui-slider-thumb-size)'
|
|
665
|
+
: i === total
|
|
666
|
+
? 'calc(100% - var(--zui-slider-thumb-size))'
|
|
667
|
+
: ZuiSlider.#thumbPositionCSS((i / total) * 100);
|
|
668
|
+
return html`<span
|
|
669
|
+
class=${classMap({ 'step-dot': true, 'step-dot--last': i === total && this.showStepLabels })}
|
|
670
|
+
style="left: ${left}"
|
|
671
|
+
></span>
|
|
672
|
+
${this.showStepLabels
|
|
673
|
+
? html`<span class="step-dot-label" style="left: ${left}">${step.label}</span>`
|
|
674
|
+
: nothing}`;
|
|
675
|
+
});
|
|
676
|
+
return html`<div class="step-dots">${stepDots}</div>`;
|
|
677
|
+
}
|
|
486
678
|
if (this.step <= 0 || this.#range === 0) {
|
|
487
679
|
return nothing;
|
|
488
680
|
}
|
|
@@ -490,7 +682,7 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
490
682
|
if (count > 100) {
|
|
491
683
|
return nothing;
|
|
492
684
|
}
|
|
493
|
-
const dots
|
|
685
|
+
const dots = [];
|
|
494
686
|
for (let i = 0; i <= count; i++) {
|
|
495
687
|
let left: string;
|
|
496
688
|
if (i === 0) {
|
|
@@ -507,22 +699,34 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
507
699
|
}
|
|
508
700
|
|
|
509
701
|
#renderMinMaxLabels() {
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
702
|
+
const normalized = this.#normalizedSteps;
|
|
703
|
+
const minLabel = this.#stepsMode ? normalized[0]?.label ?? '' : String(this.min);
|
|
704
|
+
const maxLabel = this.#stepsMode ? normalized[normalized.length - 1]?.label ?? '' : String(this.max);
|
|
705
|
+
const hidden = this.showStepLabels && this.#stepsMode;
|
|
513
706
|
return html`
|
|
514
|
-
<div
|
|
515
|
-
|
|
516
|
-
|
|
707
|
+
<div
|
|
708
|
+
class="min-max-labels"
|
|
709
|
+
aria-hidden="${ifDefined(hidden ? 'true' : undefined)}"
|
|
710
|
+
style=${hidden ? styleMap({ visibility: 'hidden' }) : nothing}
|
|
711
|
+
>
|
|
712
|
+
<span class="min-max-label">${minLabel}</span>
|
|
713
|
+
<span class="min-max-label">${maxLabel}</span>
|
|
517
714
|
</div>
|
|
518
715
|
`;
|
|
519
716
|
}
|
|
520
717
|
|
|
718
|
+
// ─── Event handlers ──────────────────────────────────────────────────────────
|
|
719
|
+
|
|
521
720
|
#onInput(e: Event) {
|
|
522
721
|
if (this.readOnly) {
|
|
523
722
|
return;
|
|
524
723
|
}
|
|
525
|
-
|
|
724
|
+
const input = e.target as HTMLInputElement;
|
|
725
|
+
if (this.#stepsMode) {
|
|
726
|
+
this.value = this.#stepAt(parseInt(input.value, 10));
|
|
727
|
+
} else {
|
|
728
|
+
this.value = input.value;
|
|
729
|
+
}
|
|
526
730
|
}
|
|
527
731
|
|
|
528
732
|
#onChange() {
|
|
@@ -536,20 +740,43 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
536
740
|
return stepDecimals > 0 ? clamped.toFixed(stepDecimals) : String(Math.round(clamped));
|
|
537
741
|
}
|
|
538
742
|
|
|
743
|
+
#currentValueForFlag(flag: ThumbFlag): string {
|
|
744
|
+
if (flag === 'startThumb') {
|
|
745
|
+
return this.#valueStart;
|
|
746
|
+
}
|
|
747
|
+
if (flag === 'endThumb') {
|
|
748
|
+
return this.#valueEnd;
|
|
749
|
+
}
|
|
750
|
+
return this.#value;
|
|
751
|
+
}
|
|
752
|
+
|
|
539
753
|
#makeFloatingChange(flag: ThumbFlag, setter: (val: string) => void, dispatch: () => void) {
|
|
540
754
|
return (e: Event) => {
|
|
541
755
|
if (this.readOnly) {
|
|
542
756
|
return;
|
|
543
757
|
}
|
|
544
758
|
const input = e.target as HTMLInputElement;
|
|
545
|
-
if (input.value === '') {
|
|
546
|
-
return;
|
|
547
|
-
}
|
|
548
759
|
const entry = this.#thumbInputState.get(flag)!;
|
|
549
760
|
clearTimeout(entry.debounceTimer);
|
|
550
761
|
entry.debounceTimer = undefined;
|
|
551
|
-
|
|
552
|
-
|
|
762
|
+
if (input.value === '') {
|
|
763
|
+
input.value = this.#currentValueForFlag(flag);
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
if (this.#stepsMode) {
|
|
767
|
+
const resolved = this.#resolveFloatingInput(input.value);
|
|
768
|
+
if (resolved !== null) {
|
|
769
|
+
setter(resolved);
|
|
770
|
+
this.requestUpdate();
|
|
771
|
+
dispatch();
|
|
772
|
+
} else {
|
|
773
|
+
input.value = this.#currentValueForFlag(flag);
|
|
774
|
+
}
|
|
775
|
+
} else {
|
|
776
|
+
setter(this.#processFloatingValue(parseFloat(input.value)));
|
|
777
|
+
this.requestUpdate();
|
|
778
|
+
dispatch();
|
|
779
|
+
}
|
|
553
780
|
};
|
|
554
781
|
}
|
|
555
782
|
|
|
@@ -559,13 +786,29 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
559
786
|
return;
|
|
560
787
|
}
|
|
561
788
|
const input = e.target as HTMLInputElement;
|
|
789
|
+
const entry = this.#thumbInputState.get(flag)!;
|
|
790
|
+
clearTimeout(entry.debounceTimer);
|
|
791
|
+
entry.debounceTimer = undefined;
|
|
562
792
|
if (input.value === '') {
|
|
563
793
|
return;
|
|
564
794
|
}
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
795
|
+
if (this.#stepsMode) {
|
|
796
|
+
// Capture raw string at event time so the debounce closure reads the right value
|
|
797
|
+
const raw = input.value;
|
|
798
|
+
entry.debounceTimer = setTimeout(() => {
|
|
799
|
+
const resolved = this.#resolveFloatingInput(raw);
|
|
800
|
+
if (resolved !== null) {
|
|
801
|
+
setter(resolved);
|
|
802
|
+
this.requestUpdate();
|
|
803
|
+
}
|
|
804
|
+
}, 500);
|
|
805
|
+
} else {
|
|
806
|
+
const raw = parseFloat(input.value);
|
|
807
|
+
entry.debounceTimer = setTimeout(() => {
|
|
808
|
+
setter(this.#processFloatingValue(raw));
|
|
809
|
+
this.requestUpdate();
|
|
810
|
+
}, 500);
|
|
811
|
+
}
|
|
569
812
|
};
|
|
570
813
|
}
|
|
571
814
|
|
|
@@ -575,18 +818,37 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
575
818
|
return;
|
|
576
819
|
}
|
|
577
820
|
const input = e.target as HTMLInputElement;
|
|
578
|
-
if (
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
821
|
+
if (this.#stepsMode) {
|
|
822
|
+
const idx = parseInt(input.value, 10);
|
|
823
|
+
const startIdx = Math.max(0, this.#stepsIndexOf(this.#valueStart));
|
|
824
|
+
const endIdx = Math.max(0, this.#stepsIndexOf(this.#valueEnd));
|
|
825
|
+
if (which === 'start') {
|
|
826
|
+
if (idx >= endIdx) {
|
|
827
|
+
input.value = String(startIdx);
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
this.valueStart = this.#stepAt(idx);
|
|
831
|
+
} else {
|
|
832
|
+
if (idx <= startIdx) {
|
|
833
|
+
input.value = String(endIdx);
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
this.valueEnd = this.#stepAt(idx);
|
|
582
837
|
}
|
|
583
|
-
this.valueStart = input.value;
|
|
584
838
|
} else {
|
|
585
|
-
if (
|
|
586
|
-
input.value
|
|
587
|
-
|
|
839
|
+
if (which === 'start') {
|
|
840
|
+
if (parseFloat(input.value) >= parseFloat(this.#valueEnd)) {
|
|
841
|
+
input.value = this.#valueStart;
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
this.valueStart = input.value;
|
|
845
|
+
} else {
|
|
846
|
+
if (parseFloat(input.value) <= parseFloat(this.#valueStart)) {
|
|
847
|
+
input.value = this.#valueEnd;
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
this.valueEnd = input.value;
|
|
588
851
|
}
|
|
589
|
-
this.valueEnd = input.value;
|
|
590
852
|
}
|
|
591
853
|
};
|
|
592
854
|
}
|
|
@@ -612,10 +874,41 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
612
874
|
const trackLeft = rect.left + thumbRadius;
|
|
613
875
|
const effectiveWidth = rect.width - 2 * thumbRadius;
|
|
614
876
|
const fraction = Math.max(0, Math.min(1, (e.clientX - trackLeft) / effectiveWidth));
|
|
877
|
+
|
|
878
|
+
if (this.#stepsMode) {
|
|
879
|
+
const total = this.#normalizedSteps.length - 1;
|
|
880
|
+
if (total <= 0) {
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
const clickedIdx = Math.round(fraction * total);
|
|
884
|
+
const startIdx = Math.max(0, this.#stepsIndexOf(this.#valueStart));
|
|
885
|
+
const endIdx = Math.max(0, this.#stepsIndexOf(this.#valueEnd));
|
|
886
|
+
|
|
887
|
+
// Ignore clicks within a thumb's hit area
|
|
888
|
+
const startX = trackLeft + (startIdx / total) * effectiveWidth;
|
|
889
|
+
const endX = trackLeft + (endIdx / total) * effectiveWidth;
|
|
890
|
+
if (Math.abs(e.clientX - startX) <= thumbRadius || Math.abs(e.clientX - endX) <= thumbRadius) {
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// Move whichever thumb is closer by index distance; prefer start on a tie
|
|
895
|
+
if (Math.abs(clickedIdx - startIdx) <= Math.abs(clickedIdx - endIdx)) {
|
|
896
|
+
if (clickedIdx < endIdx) {
|
|
897
|
+
this.valueStart = this.#stepAt(clickedIdx);
|
|
898
|
+
this.#onRangeChange();
|
|
899
|
+
}
|
|
900
|
+
} else {
|
|
901
|
+
if (clickedIdx > startIdx) {
|
|
902
|
+
this.valueEnd = this.#stepAt(clickedIdx);
|
|
903
|
+
this.#onRangeChange();
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
|
|
615
909
|
const rawValue = this.min + fraction * (this.max - this.min);
|
|
616
910
|
|
|
617
|
-
// Ignore clicks
|
|
618
|
-
// interactions and should not be treated as track clicks.
|
|
911
|
+
// Ignore clicks within a thumb's hit area; those are native input interactions, not track clicks.
|
|
619
912
|
const startX = trackLeft + (this.progressStart / 100) * effectiveWidth;
|
|
620
913
|
const endX = trackLeft + (this.progressEnd / 100) * effectiveWidth;
|
|
621
914
|
if (Math.abs(e.clientX - startX) <= thumbRadius || Math.abs(e.clientX - endX) <= thumbRadius) {
|
|
@@ -677,9 +970,7 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
677
970
|
this.#scheduleHideThumbInput(flag);
|
|
678
971
|
}
|
|
679
972
|
|
|
680
|
-
// Maps
|
|
681
|
-
// `progress% + thumbSize * offset` lands on the thumb center within the inset wrapper.
|
|
682
|
-
// At 0%: offset=1.5 → thumbSize*1.5 (thumb center at min). At 100%: offset=-1.5 → -thumbSize*1.5 (thumb center at max).
|
|
973
|
+
// 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.
|
|
683
974
|
static #thumbCenterOffset(progress: number): number {
|
|
684
975
|
return 1.5 - (3 * progress) / 100;
|
|
685
976
|
}
|