@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/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,13 +61,15 @@ 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
|
{
|
|
61
70
|
visible: boolean;
|
|
62
71
|
focused: boolean;
|
|
63
72
|
timer?: ReturnType<typeof setTimeout>;
|
|
64
|
-
debounceTimer?: ReturnType<typeof setTimeout>;
|
|
65
73
|
}
|
|
66
74
|
>([
|
|
67
75
|
['thumb', { visible: false, focused: false }],
|
|
@@ -69,12 +77,7 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
69
77
|
['endThumb', { visible: false, focused: false }],
|
|
70
78
|
]);
|
|
71
79
|
|
|
72
|
-
//
|
|
73
|
-
#onThumbFloatingInput = this.#onFloatingInput('thumb', (v) => (this.value = v));
|
|
74
|
-
#onStartThumbFloatingInput = this.#onFloatingInput('startThumb', (v) => (this.valueStart = v));
|
|
75
|
-
#onEndThumbFloatingInput = this.#onFloatingInput('endThumb', (v) => (this.valueEnd = v));
|
|
76
|
-
|
|
77
|
-
// Cached floating input change handlers — flush debounce and dispatch immediately on commit (Enter/blur)
|
|
80
|
+
// Cached floating input change handlers; commit value on Enter or blur
|
|
78
81
|
#onThumbFloatingChange = this.#makeFloatingChange(
|
|
79
82
|
'thumb',
|
|
80
83
|
(v) => (this.value = v),
|
|
@@ -91,11 +94,21 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
91
94
|
() => this.#onRangeChange()
|
|
92
95
|
);
|
|
93
96
|
|
|
97
|
+
// Cached keydown handler: Enter commits the floating input value in both number and text modes.
|
|
98
|
+
#onFloatingInputKeydown = (e: KeyboardEvent) => {
|
|
99
|
+
if (e.key === 'Enter') {
|
|
100
|
+
e.preventDefault();
|
|
101
|
+
const input = e.target as HTMLInputElement;
|
|
102
|
+
input.dispatchEvent(new Event('change'));
|
|
103
|
+
input.blur();
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
94
107
|
// Cached range drag input handlers
|
|
95
108
|
#onRangeStartInput = this.#onRangeInput('start');
|
|
96
109
|
#onRangeEndInput = this.#onRangeInput('end');
|
|
97
110
|
|
|
98
|
-
// Cached pointer/focus handlers per thumb
|
|
111
|
+
// Cached pointer/focus handlers per thumb; prevents new closures on every render
|
|
99
112
|
#h: Record<ThumbFlag, { show: () => void; hide: () => void; focus: () => void; blur: () => void }> = {
|
|
100
113
|
thumb: {
|
|
101
114
|
show: () => this.#showThumbInput('thumb'),
|
|
@@ -121,9 +134,30 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
121
134
|
return [super.styles, style];
|
|
122
135
|
}
|
|
123
136
|
|
|
137
|
+
@property({
|
|
138
|
+
converter: {
|
|
139
|
+
fromAttribute: (value: string | null) => (value ? value.split(',').map((s) => s.trim()) : []),
|
|
140
|
+
toAttribute: (value: StepInput[]) =>
|
|
141
|
+
value.map((s) => (typeof s === 'object' ? s.label ?? String(s.value) : String(s))).join(','),
|
|
142
|
+
},
|
|
143
|
+
})
|
|
144
|
+
steps: StepInput[] = [];
|
|
145
|
+
|
|
146
|
+
@property({ attribute: false }) stepParser: ((input: string) => number | string | null) | null = null;
|
|
147
|
+
|
|
124
148
|
connectedCallback() {
|
|
125
149
|
super.connectedCallback();
|
|
126
150
|
this.updateComplete.then(() => {
|
|
151
|
+
if (this.#stepsMode) {
|
|
152
|
+
this.#syncValuesToSteps();
|
|
153
|
+
if (this.range) {
|
|
154
|
+
this.#defaultValueStart = this.#valueStart;
|
|
155
|
+
this.#defaultValueEnd = this.#valueEnd;
|
|
156
|
+
} else {
|
|
157
|
+
this.#defaultValue = this.#value;
|
|
158
|
+
}
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
127
161
|
if (this.range) {
|
|
128
162
|
const [startInput, endInput] = this.#rangeInputs();
|
|
129
163
|
this.#valueStart = startInput.value;
|
|
@@ -143,8 +177,7 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
143
177
|
disconnectedCallback() {
|
|
144
178
|
super.disconnectedCallback();
|
|
145
179
|
this.#clearAllThumbInputState();
|
|
146
|
-
//
|
|
147
|
-
// Lit defers this render until the element reconnects if it is currently disconnected.
|
|
180
|
+
// Re-render to reflect cleared visibility state; Lit defers this until reconnect.
|
|
148
181
|
this.requestUpdate();
|
|
149
182
|
}
|
|
150
183
|
|
|
@@ -152,10 +185,15 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
152
185
|
if (changed.has('disabled') && this.disabled) {
|
|
153
186
|
this.#clearAllThumbInputState();
|
|
154
187
|
}
|
|
155
|
-
if (changed.has('
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
188
|
+
if (changed.has('steps')) {
|
|
189
|
+
this.#cachedNormalizedSteps = null;
|
|
190
|
+
this.#cachedNumericValues = null;
|
|
191
|
+
if (this.#stepsMode) {
|
|
192
|
+
this.#syncValuesToSteps();
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (!this.#stepsMode && (changed.has('min') || changed.has('max'))) {
|
|
196
|
+
// Native inputs clamp/snap to new bounds; read back and sync state. Re-render if value changed.
|
|
159
197
|
if (this.range) {
|
|
160
198
|
const [startInput, endInput] = this.#rangeInputs();
|
|
161
199
|
const newStart = startInput.value;
|
|
@@ -196,8 +234,7 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
196
234
|
|
|
197
235
|
protected formResetCallback() {
|
|
198
236
|
this.#clearAllThumbInputState();
|
|
199
|
-
//
|
|
200
|
-
// reset value equals the current value (the named setter requestUpdate would be a no-op).
|
|
237
|
+
// Force re-render: if the reset value equals the current value, the setter's requestUpdate is a no-op.
|
|
201
238
|
this.requestUpdate();
|
|
202
239
|
if (this.range) {
|
|
203
240
|
this.valueStart = this.#defaultValueStart;
|
|
@@ -210,23 +247,59 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
210
247
|
#clearAllThumbInputState() {
|
|
211
248
|
for (const entry of this.#thumbInputState.values()) {
|
|
212
249
|
clearTimeout(entry.timer);
|
|
213
|
-
clearTimeout(entry.debounceTimer);
|
|
214
250
|
entry.timer = undefined;
|
|
215
|
-
entry.debounceTimer = undefined;
|
|
216
251
|
entry.visible = false;
|
|
217
252
|
entry.focused = false;
|
|
218
253
|
}
|
|
219
254
|
}
|
|
220
255
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
256
|
+
#syncValuesToSteps() {
|
|
257
|
+
const lastIdx = this.#normalizedSteps.length - 1;
|
|
258
|
+
if (this.range) {
|
|
259
|
+
if (this.#stepsIndexOf(this.#valueStart) < 0) {
|
|
260
|
+
this.#valueStart = this.#stepAt(0);
|
|
261
|
+
this.requestUpdate();
|
|
262
|
+
}
|
|
263
|
+
if (this.#stepsIndexOf(this.#valueEnd) < 0) {
|
|
264
|
+
this.#valueEnd = this.#stepAt(lastIdx);
|
|
265
|
+
this.requestUpdate();
|
|
266
|
+
}
|
|
267
|
+
this._setFormValue(`${this.#valueStart},${this.#valueEnd}`);
|
|
268
|
+
} else {
|
|
269
|
+
if (this.#stepsIndexOf(this.#value) < 0) {
|
|
270
|
+
this.#value = this.#stepAt(0);
|
|
271
|
+
this.requestUpdate();
|
|
272
|
+
}
|
|
273
|
+
this._setFormValue(this.#value);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Steps mode: snaps to nearest step. Normal mode: clamps to [min, max]. Empty string passes through. */
|
|
226
278
|
#clampToRange(rawVal: string): string {
|
|
227
279
|
if (rawVal === '') {
|
|
228
280
|
return rawVal;
|
|
229
281
|
}
|
|
282
|
+
if (this.#stepsMode) {
|
|
283
|
+
if (this.#stepsIndexOf(rawVal) >= 0) {
|
|
284
|
+
return rawVal;
|
|
285
|
+
}
|
|
286
|
+
let n: number | null = null;
|
|
287
|
+
if (this.stepParser) {
|
|
288
|
+
const result = this.stepParser(rawVal);
|
|
289
|
+
if (typeof result === 'number') {
|
|
290
|
+
n = result;
|
|
291
|
+
} else if (typeof result === 'string' && this.#stepsIndexOf(result) >= 0) {
|
|
292
|
+
return result;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (n === null) {
|
|
296
|
+
n = parseFloat(rawVal);
|
|
297
|
+
}
|
|
298
|
+
if (!isNaN(n)) {
|
|
299
|
+
return this.#snapToSteps(n);
|
|
300
|
+
}
|
|
301
|
+
return this.#stepAt(0);
|
|
302
|
+
}
|
|
230
303
|
const num = parseFloat(rawVal);
|
|
231
304
|
if (isNaN(num)) {
|
|
232
305
|
return rawVal;
|
|
@@ -251,9 +324,6 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
251
324
|
return parseFloat(this.#value);
|
|
252
325
|
}
|
|
253
326
|
|
|
254
|
-
/**
|
|
255
|
-
* Enables range mode with two thumbs for selecting a value range
|
|
256
|
-
*/
|
|
257
327
|
@property({ type: Boolean }) range = false;
|
|
258
328
|
|
|
259
329
|
@property({ attribute: 'value-start' })
|
|
@@ -282,25 +352,14 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
282
352
|
this.requestUpdate('valueEnd', oldVal);
|
|
283
353
|
}
|
|
284
354
|
|
|
285
|
-
/**
|
|
286
|
-
* Represents the minimum permitted value
|
|
287
|
-
*/
|
|
288
355
|
@property({ type: Number }) min = 0;
|
|
289
356
|
|
|
290
|
-
/**
|
|
291
|
-
* Represents the maximum permitted value
|
|
292
|
-
*/
|
|
293
357
|
@property({ type: Number }) max = 100;
|
|
294
358
|
|
|
295
|
-
/**
|
|
296
|
-
* Represents the stepping interval, used both for user interface and validation purposes
|
|
297
|
-
*/
|
|
298
359
|
@property({ type: Number }) step = 0;
|
|
299
360
|
|
|
300
|
-
/**
|
|
301
|
-
|
|
302
|
-
*/
|
|
303
|
-
@property({ type: Boolean, attribute: 'show-min-max' }) showMinMax = false;
|
|
361
|
+
/** Displays each step's label beneath its dot on the track. Requires `steps` to be set. */
|
|
362
|
+
@property({ type: Boolean, attribute: 'show-step-labels' }) showStepLabels = false;
|
|
304
363
|
|
|
305
364
|
get #range() {
|
|
306
365
|
return this.max - this.min;
|
|
@@ -314,6 +373,109 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
314
373
|
return this.#thumbInputState.get(flag)!.visible && !this.disabled;
|
|
315
374
|
}
|
|
316
375
|
|
|
376
|
+
// ─── Steps helpers ───────────────────────────────────────────────────────────
|
|
377
|
+
|
|
378
|
+
get #stepsMode(): boolean {
|
|
379
|
+
return this.steps.length > 0;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
get #nativeRangeAttrs(): { nativeMin: string; nativeMax: string; nativeStep: string } {
|
|
383
|
+
return {
|
|
384
|
+
nativeMin: this.#stepsMode ? '0' : String(this.min),
|
|
385
|
+
nativeMax: this.#stepsMode ? String(this.#normalizedSteps.length - 1) : String(this.max),
|
|
386
|
+
nativeStep: this.#stepsMode ? '1' : this.step > 0 ? String(this.step) : '1',
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Normalizes each StepInput to `{ value, label }`. Cached after first access. */
|
|
391
|
+
get #normalizedSteps(): { value: number | string; label: string }[] {
|
|
392
|
+
if (!this.#cachedNormalizedSteps) {
|
|
393
|
+
this.#cachedNormalizedSteps = this.steps.map((s) => {
|
|
394
|
+
if (typeof s === 'number') {
|
|
395
|
+
return { value: s, label: String(s) };
|
|
396
|
+
}
|
|
397
|
+
if (typeof s === 'string') {
|
|
398
|
+
return { value: s, label: s };
|
|
399
|
+
}
|
|
400
|
+
return { value: s.value, label: s.label ?? String(s.value) };
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
return this.#cachedNormalizedSteps;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
#stepsIndexOf(val: string): number {
|
|
407
|
+
return this.#normalizedSteps.findIndex((s) => s.label === val);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
#stepAt(index: number): string {
|
|
411
|
+
const normalized = this.#normalizedSteps;
|
|
412
|
+
return normalized[Math.max(0, Math.min(normalized.length - 1, index))].label;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Numeric value per step for #snapToSteps. Strings parsed via parseFloat; unparseable strings map to Infinity. */
|
|
416
|
+
get #stepsNumericValues(): number[] {
|
|
417
|
+
if (!this.#cachedNumericValues) {
|
|
418
|
+
this.#cachedNumericValues = this.#normalizedSteps.map((step) => {
|
|
419
|
+
if (typeof step.value === 'number') {
|
|
420
|
+
return step.value;
|
|
421
|
+
}
|
|
422
|
+
const sv = step.value as string;
|
|
423
|
+
const n = parseFloat(sv);
|
|
424
|
+
return isNaN(n) ? Infinity : n;
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
return this.#cachedNumericValues;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Snaps n to the nearest step by numeric value; overflow steps win only past the last finite value. */
|
|
431
|
+
#snapToSteps(n: number): string {
|
|
432
|
+
const numericValues = this.#stepsNumericValues;
|
|
433
|
+
let lastFiniteValue = -Infinity;
|
|
434
|
+
for (const v of numericValues) {
|
|
435
|
+
if (isFinite(v) && v > lastFiniteValue) {
|
|
436
|
+
lastFiniteValue = v;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
let bestIdx = 0;
|
|
440
|
+
let bestDist = Infinity;
|
|
441
|
+
for (let i = 0; i < numericValues.length; i++) {
|
|
442
|
+
const v = numericValues[i];
|
|
443
|
+
const dist = v === Infinity ? (n > lastFiniteValue ? 0 : Math.abs(n - lastFiniteValue) + 1) : Math.abs(n - v);
|
|
444
|
+
if (dist < bestDist) {
|
|
445
|
+
bestDist = dist;
|
|
446
|
+
bestIdx = i;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return this.#stepAt(bestIdx);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Resolves a user-typed string to a step label; returns null if unresolvable. Steps mode only. */
|
|
453
|
+
#resolveFloatingInput(raw: string): string | null {
|
|
454
|
+
// Exact label match wins; a valid label is never rejected by a stepParser that doesn't recognize it.
|
|
455
|
+
const exactIdx = this.#stepsIndexOf(raw);
|
|
456
|
+
if (exactIdx >= 0) {
|
|
457
|
+
return this.#stepAt(exactIdx);
|
|
458
|
+
}
|
|
459
|
+
if (this.stepParser) {
|
|
460
|
+
const result = this.stepParser(raw);
|
|
461
|
+
if (result === null) {
|
|
462
|
+
return null;
|
|
463
|
+
}
|
|
464
|
+
if (typeof result === 'number') {
|
|
465
|
+
return this.#snapToSteps(result);
|
|
466
|
+
}
|
|
467
|
+
// String result must be a valid step label
|
|
468
|
+
return this.#stepsIndexOf(result) >= 0 ? result : null;
|
|
469
|
+
}
|
|
470
|
+
const n = parseFloat(raw);
|
|
471
|
+
if (isNaN(n)) {
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
return this.#snapToSteps(n);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// ─── Progress ────────────────────────────────────────────────────────────────
|
|
478
|
+
|
|
317
479
|
get progress() {
|
|
318
480
|
return this.#computeProgress(this.#value);
|
|
319
481
|
}
|
|
@@ -327,6 +489,14 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
327
489
|
}
|
|
328
490
|
|
|
329
491
|
#computeProgress(rawValue: string): number {
|
|
492
|
+
if (this.#stepsMode) {
|
|
493
|
+
const total = this.#normalizedSteps.length - 1;
|
|
494
|
+
if (total <= 0) {
|
|
495
|
+
return 0;
|
|
496
|
+
}
|
|
497
|
+
const idx = this.#stepsIndexOf(rawValue);
|
|
498
|
+
return idx < 0 ? 0 : parseFloat(((idx / total) * 100).toFixed(4));
|
|
499
|
+
}
|
|
330
500
|
if (this.#range === 0) {
|
|
331
501
|
return 0;
|
|
332
502
|
}
|
|
@@ -347,22 +517,27 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
347
517
|
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
518
|
}
|
|
349
519
|
|
|
520
|
+
// ─── Render ──────────────────────────────────────────────────────────────────
|
|
521
|
+
|
|
350
522
|
render() {
|
|
351
523
|
return html`${this.range ? this.#renderRange() : this.#renderSingle()}${this.#renderMinMaxLabels()}`;
|
|
352
524
|
}
|
|
353
525
|
|
|
354
526
|
#renderSingle() {
|
|
355
527
|
const progress = this.progress;
|
|
528
|
+
const { nativeMin, nativeMax, nativeStep } = this.#nativeRangeAttrs;
|
|
529
|
+
// live() required: direct DOM writes during drag don't trigger a state change, so Lit won't re-sync without it.
|
|
530
|
+
const nativeValue = this.#stepsMode ? String(Math.max(0, this.#stepsIndexOf(this.#value))) : this.#value;
|
|
356
531
|
return html`
|
|
357
532
|
<div class="single-wrapper">
|
|
358
533
|
<input
|
|
359
534
|
aria-label="Slider value"
|
|
360
535
|
style=${styleMap({ '--zui-slider-track-bg': this.#singleTrackBackground(progress) })}
|
|
361
536
|
type="range"
|
|
362
|
-
.min="${
|
|
363
|
-
.max="${
|
|
364
|
-
.step="${
|
|
365
|
-
.value="${
|
|
537
|
+
.min="${nativeMin}"
|
|
538
|
+
.max="${nativeMax}"
|
|
539
|
+
.step="${nativeStep}"
|
|
540
|
+
.value="${live(nativeValue)}"
|
|
366
541
|
?disabled="${this.disabled || this.readOnly}"
|
|
367
542
|
@input="${this.#onInput}"
|
|
368
543
|
@change="${this.#onChange}"
|
|
@@ -373,7 +548,6 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
373
548
|
/>
|
|
374
549
|
${this.#renderFloatingInput(
|
|
375
550
|
this.#value,
|
|
376
|
-
this.#onThumbFloatingInput,
|
|
377
551
|
this.#onThumbFloatingChange,
|
|
378
552
|
'thumb',
|
|
379
553
|
this.#isVisible('thumb'),
|
|
@@ -392,7 +566,6 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
392
566
|
${this.#renderRangeInput('start', this.#rangeTrackBackground(progressStart, progressEnd))}
|
|
393
567
|
${this.#renderFloatingInput(
|
|
394
568
|
this.#valueStart,
|
|
395
|
-
this.#onStartThumbFloatingInput,
|
|
396
569
|
this.#onStartThumbFloatingChange,
|
|
397
570
|
'startThumb',
|
|
398
571
|
this.#isVisible('startThumb'),
|
|
@@ -401,7 +574,6 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
401
574
|
${this.#renderRangeInput('end')}
|
|
402
575
|
${this.#renderFloatingInput(
|
|
403
576
|
this.#valueEnd,
|
|
404
|
-
this.#onEndThumbFloatingInput,
|
|
405
577
|
this.#onEndThumbFloatingChange,
|
|
406
578
|
'endThumb',
|
|
407
579
|
this.#isVisible('endThumb'),
|
|
@@ -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}"
|
|
@@ -443,16 +615,16 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
443
615
|
|
|
444
616
|
#renderFloatingInput(
|
|
445
617
|
val: string,
|
|
446
|
-
onInput: (e: Event) => void,
|
|
447
618
|
onFloatingChange: (e: Event) => void,
|
|
448
619
|
flag: ThumbFlag,
|
|
449
620
|
visible: boolean,
|
|
450
621
|
progress: number
|
|
451
622
|
) {
|
|
452
623
|
const h = this.#h[flag];
|
|
453
|
-
//
|
|
454
|
-
//
|
|
455
|
-
|
|
624
|
+
// type="text" in steps mode to allow label and stepParser input.
|
|
625
|
+
// live() required: commits that snap/clamp to the current value skip reactive updates, so Lit won't re-sync without it.
|
|
626
|
+
const ariaLabel =
|
|
627
|
+
flag === 'startThumb' ? 'Range start value' : flag === 'endThumb' ? 'Range end value' : 'Slider value';
|
|
456
628
|
return html`
|
|
457
629
|
<div
|
|
458
630
|
class=${classMap({ 'thumb-input': true, 'thumb-input--visible': visible })}
|
|
@@ -461,19 +633,15 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
461
633
|
@pointerleave="${h.hide}"
|
|
462
634
|
>
|
|
463
635
|
<input
|
|
464
|
-
aria-label="${
|
|
465
|
-
|
|
466
|
-
: flag === 'endThumb'
|
|
467
|
-
? 'Range end value'
|
|
468
|
-
: 'Slider value'}"
|
|
469
|
-
type="number"
|
|
636
|
+
aria-label="${ariaLabel}"
|
|
637
|
+
type="${this.#stepsMode ? 'text' : 'number'}"
|
|
470
638
|
.value="${live(val)}"
|
|
471
|
-
.min="${String(this.min)}"
|
|
472
|
-
.max="${String(this.max)}"
|
|
473
|
-
.step="${this.step > 0 ? String(this.step) : '1'}"
|
|
639
|
+
.min="${this.#stepsMode ? '' : String(this.min)}"
|
|
640
|
+
.max="${this.#stepsMode ? '' : String(this.max)}"
|
|
641
|
+
.step="${this.#stepsMode ? '' : this.step > 0 ? String(this.step) : '1'}"
|
|
474
642
|
?disabled="${this.disabled}"
|
|
475
643
|
?readonly="${this.readOnly}"
|
|
476
|
-
@
|
|
644
|
+
@keydown="${this.#onFloatingInputKeydown}"
|
|
477
645
|
@change="${onFloatingChange}"
|
|
478
646
|
@focus="${h.focus}"
|
|
479
647
|
@blur="${h.blur}"
|
|
@@ -483,6 +651,29 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
483
651
|
}
|
|
484
652
|
|
|
485
653
|
#renderStepDots() {
|
|
654
|
+
if (this.#stepsMode) {
|
|
655
|
+
const normalized = this.#normalizedSteps;
|
|
656
|
+
const total = normalized.length - 1;
|
|
657
|
+
if (total <= 0 || normalized.length > 100) {
|
|
658
|
+
return nothing;
|
|
659
|
+
}
|
|
660
|
+
const stepDots = normalized.map((step, i) => {
|
|
661
|
+
const left =
|
|
662
|
+
i === 0
|
|
663
|
+
? 'var(--zui-slider-thumb-size)'
|
|
664
|
+
: i === total
|
|
665
|
+
? 'calc(100% - var(--zui-slider-thumb-size))'
|
|
666
|
+
: ZuiSlider.#thumbPositionCSS((i / total) * 100);
|
|
667
|
+
return html`<span
|
|
668
|
+
class=${classMap({ 'step-dot': true, 'step-dot--last': i === total && this.showStepLabels })}
|
|
669
|
+
style="left: ${left}"
|
|
670
|
+
></span>
|
|
671
|
+
${this.showStepLabels
|
|
672
|
+
? html`<span class="step-dot-label" style="left: ${left}">${step.label}</span>`
|
|
673
|
+
: nothing}`;
|
|
674
|
+
});
|
|
675
|
+
return html`<div class="step-dots">${stepDots}</div>`;
|
|
676
|
+
}
|
|
486
677
|
if (this.step <= 0 || this.#range === 0) {
|
|
487
678
|
return nothing;
|
|
488
679
|
}
|
|
@@ -490,7 +681,7 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
490
681
|
if (count > 100) {
|
|
491
682
|
return nothing;
|
|
492
683
|
}
|
|
493
|
-
const dots
|
|
684
|
+
const dots = [];
|
|
494
685
|
for (let i = 0; i <= count; i++) {
|
|
495
686
|
let left: string;
|
|
496
687
|
if (i === 0) {
|
|
@@ -507,22 +698,34 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
507
698
|
}
|
|
508
699
|
|
|
509
700
|
#renderMinMaxLabels() {
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
701
|
+
const normalized = this.#normalizedSteps;
|
|
702
|
+
const minLabel = this.#stepsMode ? normalized[0]?.label ?? '' : String(this.min);
|
|
703
|
+
const maxLabel = this.#stepsMode ? normalized[normalized.length - 1]?.label ?? '' : String(this.max);
|
|
704
|
+
const hidden = this.showStepLabels && this.#stepsMode;
|
|
513
705
|
return html`
|
|
514
|
-
<div
|
|
515
|
-
|
|
516
|
-
|
|
706
|
+
<div
|
|
707
|
+
class="min-max-labels"
|
|
708
|
+
aria-hidden="${ifDefined(hidden ? 'true' : undefined)}"
|
|
709
|
+
style=${hidden ? styleMap({ visibility: 'hidden' }) : nothing}
|
|
710
|
+
>
|
|
711
|
+
<span class="min-max-label">${minLabel}</span>
|
|
712
|
+
<span class="min-max-label">${maxLabel}</span>
|
|
517
713
|
</div>
|
|
518
714
|
`;
|
|
519
715
|
}
|
|
520
716
|
|
|
717
|
+
// ─── Event handlers ──────────────────────────────────────────────────────────
|
|
718
|
+
|
|
521
719
|
#onInput(e: Event) {
|
|
522
720
|
if (this.readOnly) {
|
|
523
721
|
return;
|
|
524
722
|
}
|
|
525
|
-
|
|
723
|
+
const input = e.target as HTMLInputElement;
|
|
724
|
+
if (this.#stepsMode) {
|
|
725
|
+
this.value = this.#stepAt(parseInt(input.value, 10));
|
|
726
|
+
} else {
|
|
727
|
+
this.value = input.value;
|
|
728
|
+
}
|
|
526
729
|
}
|
|
527
730
|
|
|
528
731
|
#onChange() {
|
|
@@ -536,36 +739,45 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
536
739
|
return stepDecimals > 0 ? clamped.toFixed(stepDecimals) : String(Math.round(clamped));
|
|
537
740
|
}
|
|
538
741
|
|
|
539
|
-
#
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
}
|
|
548
|
-
const entry = this.#thumbInputState.get(flag)!;
|
|
549
|
-
clearTimeout(entry.debounceTimer);
|
|
550
|
-
entry.debounceTimer = undefined;
|
|
551
|
-
setter(this.#processFloatingValue(parseFloat(input.value)));
|
|
552
|
-
dispatch();
|
|
553
|
-
};
|
|
742
|
+
#currentValueForFlag(flag: ThumbFlag): string {
|
|
743
|
+
if (flag === 'startThumb') {
|
|
744
|
+
return this.#valueStart;
|
|
745
|
+
}
|
|
746
|
+
if (flag === 'endThumb') {
|
|
747
|
+
return this.#valueEnd;
|
|
748
|
+
}
|
|
749
|
+
return this.#value;
|
|
554
750
|
}
|
|
555
751
|
|
|
556
|
-
#
|
|
752
|
+
#makeFloatingChange(flag: ThumbFlag, setter: (val: string) => void, dispatch: () => void) {
|
|
557
753
|
return (e: Event) => {
|
|
558
754
|
if (this.readOnly) {
|
|
559
755
|
return;
|
|
560
756
|
}
|
|
561
757
|
const input = e.target as HTMLInputElement;
|
|
562
758
|
if (input.value === '') {
|
|
759
|
+
input.value = this.#currentValueForFlag(flag);
|
|
563
760
|
return;
|
|
564
761
|
}
|
|
565
|
-
const
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
762
|
+
const before = this.#currentValueForFlag(flag);
|
|
763
|
+
if (this.#stepsMode) {
|
|
764
|
+
const resolved = this.#resolveFloatingInput(input.value);
|
|
765
|
+
if (resolved !== null) {
|
|
766
|
+
setter(resolved);
|
|
767
|
+
this.requestUpdate();
|
|
768
|
+
if (this.#currentValueForFlag(flag) !== before) {
|
|
769
|
+
dispatch();
|
|
770
|
+
}
|
|
771
|
+
} else {
|
|
772
|
+
input.value = this.#currentValueForFlag(flag);
|
|
773
|
+
}
|
|
774
|
+
} else {
|
|
775
|
+
setter(this.#processFloatingValue(parseFloat(input.value)));
|
|
776
|
+
this.requestUpdate();
|
|
777
|
+
if (this.#currentValueForFlag(flag) !== before) {
|
|
778
|
+
dispatch();
|
|
779
|
+
}
|
|
780
|
+
}
|
|
569
781
|
};
|
|
570
782
|
}
|
|
571
783
|
|
|
@@ -575,18 +787,37 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
575
787
|
return;
|
|
576
788
|
}
|
|
577
789
|
const input = e.target as HTMLInputElement;
|
|
578
|
-
if (
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
790
|
+
if (this.#stepsMode) {
|
|
791
|
+
const idx = parseInt(input.value, 10);
|
|
792
|
+
const startIdx = Math.max(0, this.#stepsIndexOf(this.#valueStart));
|
|
793
|
+
const endIdx = Math.max(0, this.#stepsIndexOf(this.#valueEnd));
|
|
794
|
+
if (which === 'start') {
|
|
795
|
+
if (idx >= endIdx) {
|
|
796
|
+
input.value = String(startIdx);
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
this.valueStart = this.#stepAt(idx);
|
|
800
|
+
} else {
|
|
801
|
+
if (idx <= startIdx) {
|
|
802
|
+
input.value = String(endIdx);
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
this.valueEnd = this.#stepAt(idx);
|
|
582
806
|
}
|
|
583
|
-
this.valueStart = input.value;
|
|
584
807
|
} else {
|
|
585
|
-
if (
|
|
586
|
-
input.value
|
|
587
|
-
|
|
808
|
+
if (which === 'start') {
|
|
809
|
+
if (parseFloat(input.value) >= parseFloat(this.#valueEnd)) {
|
|
810
|
+
input.value = this.#valueStart;
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
this.valueStart = input.value;
|
|
814
|
+
} else {
|
|
815
|
+
if (parseFloat(input.value) <= parseFloat(this.#valueStart)) {
|
|
816
|
+
input.value = this.#valueEnd;
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
this.valueEnd = input.value;
|
|
588
820
|
}
|
|
589
|
-
this.valueEnd = input.value;
|
|
590
821
|
}
|
|
591
822
|
};
|
|
592
823
|
}
|
|
@@ -612,10 +843,41 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
612
843
|
const trackLeft = rect.left + thumbRadius;
|
|
613
844
|
const effectiveWidth = rect.width - 2 * thumbRadius;
|
|
614
845
|
const fraction = Math.max(0, Math.min(1, (e.clientX - trackLeft) / effectiveWidth));
|
|
846
|
+
|
|
847
|
+
if (this.#stepsMode) {
|
|
848
|
+
const total = this.#normalizedSteps.length - 1;
|
|
849
|
+
if (total <= 0) {
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
const clickedIdx = Math.round(fraction * total);
|
|
853
|
+
const startIdx = Math.max(0, this.#stepsIndexOf(this.#valueStart));
|
|
854
|
+
const endIdx = Math.max(0, this.#stepsIndexOf(this.#valueEnd));
|
|
855
|
+
|
|
856
|
+
// Ignore clicks within a thumb's hit area
|
|
857
|
+
const startX = trackLeft + (startIdx / total) * effectiveWidth;
|
|
858
|
+
const endX = trackLeft + (endIdx / total) * effectiveWidth;
|
|
859
|
+
if (Math.abs(e.clientX - startX) <= thumbRadius || Math.abs(e.clientX - endX) <= thumbRadius) {
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// Move whichever thumb is closer by index distance; prefer start on a tie
|
|
864
|
+
if (Math.abs(clickedIdx - startIdx) <= Math.abs(clickedIdx - endIdx)) {
|
|
865
|
+
if (clickedIdx < endIdx) {
|
|
866
|
+
this.valueStart = this.#stepAt(clickedIdx);
|
|
867
|
+
this.#onRangeChange();
|
|
868
|
+
}
|
|
869
|
+
} else {
|
|
870
|
+
if (clickedIdx > startIdx) {
|
|
871
|
+
this.valueEnd = this.#stepAt(clickedIdx);
|
|
872
|
+
this.#onRangeChange();
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
|
|
615
878
|
const rawValue = this.min + fraction * (this.max - this.min);
|
|
616
879
|
|
|
617
|
-
// Ignore clicks
|
|
618
|
-
// interactions and should not be treated as track clicks.
|
|
880
|
+
// Ignore clicks within a thumb's hit area; those are native input interactions, not track clicks.
|
|
619
881
|
const startX = trackLeft + (this.progressStart / 100) * effectiveWidth;
|
|
620
882
|
const endX = trackLeft + (this.progressEnd / 100) * effectiveWidth;
|
|
621
883
|
if (Math.abs(e.clientX - startX) <= thumbRadius || Math.abs(e.clientX - endX) <= thumbRadius) {
|
|
@@ -677,9 +939,7 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
|
|
|
677
939
|
this.#scheduleHideThumbInput(flag);
|
|
678
940
|
}
|
|
679
941
|
|
|
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).
|
|
942
|
+
// 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
943
|
static #thumbCenterOffset(progress: number): number {
|
|
684
944
|
return 1.5 - (3 * progress) / 100;
|
|
685
945
|
}
|