@zywave/zui-slider 4.3.2 → 4.4.0-pre.0

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/src/zui-slider.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import { ZuiFormAssociatedElement } from '@zywave/zui-base';
2
- import { html } from 'lit';
2
+ import { html, nothing, TemplateResult } from 'lit';
3
3
  import { property } from 'lit/decorators.js';
4
+ import { classMap } from 'lit/directives/class-map.js';
5
+ import { live } from 'lit/directives/live.js';
4
6
  import { styleMap } from 'lit/directives/style-map.js';
5
7
  import { style } from './zui-slider-css.js';
6
8
 
7
- const DEFAULT_MAX = 100;
9
+ type ThumbFlag = 'thumb' | 'startThumb' | 'endThumb';
8
10
 
9
11
  /**
10
12
  * A range form control for choosing values along a slider.
@@ -14,47 +16,235 @@ const DEFAULT_MAX = 100;
14
16
  * @attr {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
15
17
  * @attr {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
16
18
  * @attr {boolean} [autofocus=false] - If true, this element will be focused when connected to the document
17
- * @attr {number} [value=50] - Represents the value of the input. Can be set to a default value, and will reflect the value provided by the user when interactive with the control
19
+ * @attr {number} [value=50] - Represents the value of the input (single mode). Can be set to a default value, and will reflect the value provided by the user when interactive with the control
20
+ * @attr {boolean} [range=false] - Enables range mode with two thumbs for selecting a range
21
+ * @attr {number} [value-start=0] - Represents the start (lower) value in range mode
22
+ * @attr {number} [value-end=100] - Represents the end (upper) value in range mode
23
+ * @attr {number} [min=0] - Represents the minimum permitted value
24
+ * @attr {number} [max=100] - Represents the maximum permitted value
25
+ * @attr {number} [step=0] - Represents the stepping interval; 0 means any value is allowed
26
+ * @attr {boolean} [show-min-max=false] - Shows the min and max values beneath the slider
18
27
  *
19
28
  * @prop {string | null} [name=null] - The name of this element that is associated with form submission
20
29
  * @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
21
30
  * @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
22
31
  * @prop {boolean} [autofocus=false] - If true, this element will be focused when connected to the document
23
- * @prop {number} [valueAsNumber=50] - Returns the value of the element, interpreted as one of the following, in order: A number, NaN if conversion is impossible
24
- * @prop {string} [value='50'] - Represents the value of the input. Can be set to a default value, and will reflect the value provided by the user when interactive with the control
25
- * @prop {number} [progress=50] - Determines visual placement of the slider thumb along the line
32
+ * @prop {number} [valueAsNumber=50] - Returns the value as a number. Invalid or non-numeric values are clamped to min; empty string (representing an in-progress edit) returns NaN
33
+ * @prop {string} [value='50'] - Represents the value of the input (single mode). Can be set to a default value, and will reflect the value provided by the user when interactive with the control
34
+ * @prop {number} [progress=50] - Determines visual placement of the slider thumb along the line (single mode)
35
+ * @prop {boolean} [range=false] - Enables range mode with two thumbs for selecting a range
36
+ * @prop {string} [valueStart='0'] - Represents the start (lower) value in range mode
37
+ * @prop {string} [valueEnd='100'] - Represents the end (upper) value in range mode
38
+ * @prop {number} [progressStart=0] - Determines visual placement of the start thumb in range mode
39
+ * @prop {number} [progressEnd=100] - Determines visual placement of the end thumb in range mode
40
+ * @prop {number} [min=0] - Represents the minimum permitted value
41
+ * @prop {number} [max=100] - Represents the maximum permitted value
42
+ * @prop {number} [step=0] - Represents the stepping interval; 0 means any value is allowed
43
+ * @prop {boolean} [showMinMax=false] - Shows the min and max values beneath the slider
26
44
  *
27
- * @cssprop [--zui-slider-thumb-size=1.875rem (30px)] - Point of contact to grab and slide to change value
45
+ * @cssprop [--zui-slider-thumb-size=0.875rem (14px)] - Controls the visual dot size and thumb hit-area (hit area is 3× this value)
46
+ * @cssprop [--zui-slider-input-width=7ch] - Width of the floating value input above each thumb
28
47
  *
29
- * @event {CustomEvent} change - Fires when value changes, details contain `value`
48
+ * @event {CustomEvent} change - Fires when value changes; in single mode detail is the value string; in range mode detail is { valueStart, valueEnd }
30
49
  */
31
50
  export class ZuiSlider extends ZuiFormAssociatedElement {
51
+ #defaultValue: string = '50';
52
+ #defaultValueStart: string = '0';
53
+ #defaultValueEnd: string = '100';
54
+
55
+ #value: string = '50';
56
+ #valueStart: string = '0';
57
+ #valueEnd: string = '100';
58
+
59
+ #thumbInputState = new Map<
60
+ ThumbFlag,
61
+ {
62
+ visible: boolean;
63
+ focused: boolean;
64
+ timer?: ReturnType<typeof setTimeout>;
65
+ debounceTimer?: ReturnType<typeof setTimeout>;
66
+ }
67
+ >([
68
+ ['thumb', { visible: false, focused: false }],
69
+ ['startThumb', { visible: false, focused: false }],
70
+ ['endThumb', { visible: false, focused: false }],
71
+ ]);
72
+
73
+ // Pre-bound floating input handlers cached to avoid new function references on every render
74
+ #onThumbFloatingInput = this.#onFloatingInput('thumb', (v) => (this.value = v));
75
+ #onStartThumbFloatingInput = this.#onFloatingInput('startThumb', (v) => (this.valueStart = v));
76
+ #onEndThumbFloatingInput = this.#onFloatingInput('endThumb', (v) => (this.valueEnd = v));
77
+
78
+ // Cached floating input change handlers — flush debounce and dispatch immediately on commit (Enter/blur)
79
+ #onThumbFloatingChange = this.#makeFloatingChange(
80
+ 'thumb',
81
+ (v) => (this.value = v),
82
+ () => this.#onChange()
83
+ );
84
+ #onStartThumbFloatingChange = this.#makeFloatingChange(
85
+ 'startThumb',
86
+ (v) => (this.valueStart = v),
87
+ () => this.#onRangeChange()
88
+ );
89
+ #onEndThumbFloatingChange = this.#makeFloatingChange(
90
+ 'endThumb',
91
+ (v) => (this.valueEnd = v),
92
+ () => this.#onRangeChange()
93
+ );
94
+
95
+ // Cached range drag input handlers
96
+ #onRangeStartInput = this.#onRangeInput('start');
97
+ #onRangeEndInput = this.#onRangeInput('end');
98
+
99
+ // Cached pointer/focus handlers per thumb — prevents new closures on every render
100
+ #h: Record<ThumbFlag, { show: () => void; hide: () => void; focus: () => void; blur: () => void }> = {
101
+ thumb: {
102
+ show: () => this.#showThumbInput('thumb'),
103
+ hide: () => this.#scheduleHideThumbInput('thumb'),
104
+ focus: () => this.#focusFloatingInput('thumb'),
105
+ blur: () => this.#blurFloatingInput('thumb'),
106
+ },
107
+ startThumb: {
108
+ show: () => this.#showThumbInput('startThumb'),
109
+ hide: () => this.#scheduleHideThumbInput('startThumb'),
110
+ focus: () => this.#focusFloatingInput('startThumb'),
111
+ blur: () => this.#blurFloatingInput('startThumb'),
112
+ },
113
+ endThumb: {
114
+ show: () => this.#showThumbInput('endThumb'),
115
+ hide: () => this.#scheduleHideThumbInput('endThumb'),
116
+ focus: () => this.#focusFloatingInput('endThumb'),
117
+ blur: () => this.#blurFloatingInput('endThumb'),
118
+ },
119
+ };
120
+
121
+ static get styles() {
122
+ return [super.styles, style];
123
+ }
124
+
125
+ connectedCallback() {
126
+ super.connectedCallback();
127
+ this.updateComplete.then(() => {
128
+ if (this.range) {
129
+ const [startInput, endInput] = this.#rangeInputs();
130
+ this.#valueStart = startInput.value;
131
+ this.#valueEnd = endInput.value;
132
+ this.#defaultValueStart = startInput.value;
133
+ this.#defaultValueEnd = endInput.value;
134
+ this._setFormValue(`${startInput.value},${endInput.value}`);
135
+ } else {
136
+ const input = this.#singleInput();
137
+ this.#value = input.value;
138
+ this.#defaultValue = input.value;
139
+ this._setFormValue(input.value);
140
+ }
141
+ });
142
+ }
143
+
144
+ disconnectedCallback() {
145
+ super.disconnectedCallback();
146
+ this.#clearAllThumbInputState();
147
+ // Schedule a re-render so the cleared visibility state is reflected in the DOM.
148
+ // Lit defers this render until the element reconnects if it is currently disconnected.
149
+ this.requestUpdate();
150
+ }
151
+
152
+ protected updated(changed: Map<PropertyKey, unknown>) {
153
+ if (changed.has('disabled') && this.disabled) {
154
+ this.#clearAllThumbInputState();
155
+ }
156
+ if (changed.has('min') || changed.has('max')) {
157
+ // The native inputs have already clamped (and step-snapped) their values to the new bounds.
158
+ // Read them back and sync internal state + form value. If the value changed, request a
159
+ // re-render so the floating input (which binds to #value/#valueStart/#valueEnd) stays in sync.
160
+ if (this.range) {
161
+ const [startInput, endInput] = this.#rangeInputs();
162
+ const newStart = startInput.value;
163
+ const newEnd = endInput.value;
164
+ if (newStart !== this.#valueStart || newEnd !== this.#valueEnd) {
165
+ this.#valueStart = newStart;
166
+ this.#valueEnd = newEnd;
167
+ this.requestUpdate();
168
+ }
169
+ this._setFormValue(`${this.#valueStart},${this.#valueEnd}`);
170
+ } else {
171
+ const newValue = this.#singleInput().value;
172
+ if (newValue !== this.#value) {
173
+ this.#value = newValue;
174
+ this.requestUpdate();
175
+ }
176
+ this._setFormValue(this.#value);
177
+ }
178
+ }
179
+ }
180
+
181
+ #singleInput(): HTMLInputElement {
182
+ return this.shadowRoot!.querySelector<HTMLInputElement>('input[type="range"]')!;
183
+ }
184
+
185
+ #rangeInputs(): [HTMLInputElement, HTMLInputElement] {
186
+ const inputs = this.shadowRoot!.querySelectorAll<HTMLInputElement>('input[type="range"]');
187
+ return [inputs[0], inputs[1]];
188
+ }
189
+
32
190
  protected get _focusControlSelector() {
33
191
  return 'input';
34
192
  }
35
193
 
36
194
  protected get _formValue() {
37
- return this.value;
195
+ return this.range ? `${this.#valueStart},${this.#valueEnd}` : this.#value;
38
196
  }
39
197
 
40
198
  protected formResetCallback() {
41
- this.value = this.#defaultValue;
199
+ this.#clearAllThumbInputState();
200
+ // Always request a re-render so the cleared thumb visibility is reflected even when the
201
+ // reset value equals the current value (the named setter requestUpdate would be a no-op).
202
+ this.requestUpdate();
203
+ if (this.range) {
204
+ this.valueStart = this.#defaultValueStart;
205
+ this.valueEnd = this.#defaultValueEnd;
206
+ } else {
207
+ this.value = this.#defaultValue;
208
+ }
42
209
  }
43
210
 
44
- #defaultValue = `${DEFAULT_MAX / 2}`;
211
+ #clearAllThumbInputState() {
212
+ for (const entry of this.#thumbInputState.values()) {
213
+ clearTimeout(entry.timer);
214
+ clearTimeout(entry.debounceTimer);
215
+ entry.timer = undefined;
216
+ entry.debounceTimer = undefined;
217
+ entry.visible = false;
218
+ entry.focused = false;
219
+ }
220
+ }
45
221
 
46
- #value = `${DEFAULT_MAX / 2}`;
222
+ /**
223
+ * Clamps a raw string value to the current [min, max] range.
224
+ * Empty string is passed through unchanged (represents an in-progress floating input edit).
225
+ * Non-numeric strings are passed through unchanged.
226
+ */
227
+ #clampToRange(rawVal: string): string {
228
+ if (rawVal === '') {
229
+ return rawVal;
230
+ }
231
+ const num = parseFloat(rawVal);
232
+ if (isNaN(num)) {
233
+ return rawVal;
234
+ }
235
+ return String(Math.min(this.max, Math.max(this.min, num)));
236
+ }
47
237
 
48
238
  @property()
49
239
  get value() {
50
240
  return this.#value;
51
241
  }
52
242
 
53
- set value(val) {
243
+ set value(rawVal: string) {
244
+ rawVal = this.#clampToRange(rawVal);
54
245
  const oldVal = this.#value;
55
- val = this._ensureValidValue(val);
56
- this.#value = val;
57
- this._setFormValue(this.#value);
246
+ this.#value = rawVal;
247
+ this._setFormValue(rawVal);
58
248
  this.requestUpdate('value', oldVal);
59
249
  }
60
250
 
@@ -63,14 +253,45 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
63
253
  }
64
254
 
65
255
  /**
66
- * Represents the maximum permitted value
256
+ * Enables range mode with two thumbs for selecting a value range
257
+ */
258
+ @property({ type: Boolean }) range = false;
259
+
260
+ @property({ attribute: 'value-start' })
261
+ get valueStart() {
262
+ return this.#valueStart;
263
+ }
264
+
265
+ set valueStart(rawVal: string) {
266
+ rawVal = this.#clampToRange(rawVal);
267
+ const oldVal = this.#valueStart;
268
+ this.#valueStart = rawVal;
269
+ this._setFormValue(`${rawVal},${this.#valueEnd}`);
270
+ this.requestUpdate('valueStart', oldVal);
271
+ }
272
+
273
+ @property({ attribute: 'value-end' })
274
+ get valueEnd() {
275
+ return this.#valueEnd;
276
+ }
277
+
278
+ set valueEnd(rawVal: string) {
279
+ rawVal = this.#clampToRange(rawVal);
280
+ const oldVal = this.#valueEnd;
281
+ this.#valueEnd = rawVal;
282
+ this._setFormValue(`${this.#valueStart},${rawVal}`);
283
+ this.requestUpdate('valueEnd', oldVal);
284
+ }
285
+
286
+ /**
287
+ * Represents the minimum permitted value
67
288
  */
68
289
  @property({ type: Number }) min = 0;
69
290
 
70
291
  /**
71
292
  * Represents the maximum permitted value
72
293
  */
73
- @property({ type: Number, attribute: 'max' }) max = 100;
294
+ @property({ type: Number }) max = 100;
74
295
 
75
296
  /**
76
297
  * Represents the stepping interval, used both for user interface and validation purposes
@@ -78,93 +299,354 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
78
299
  @property({ type: Number }) step = 0;
79
300
 
80
301
  /**
81
- * Represents that this control must be filled in for form submission
302
+ * Shows the min and max values beneath the slider
82
303
  */
83
- @property({ type: Boolean, attribute: 'no-text' }) noText = false;
304
+ @property({ type: Boolean, attribute: 'show-min-max' }) showMinMax = false;
305
+
306
+ get #range() {
307
+ return this.max - this.min;
308
+ }
309
+
310
+ get #progressColor() {
311
+ return this.disabled ? 'var(--zui-gray)' : 'var(--zui-blue)';
312
+ }
313
+
314
+ #isVisible(flag: ThumbFlag): boolean {
315
+ return this.#thumbInputState.get(flag)!.visible && !this.disabled;
316
+ }
84
317
 
85
318
  get progress() {
86
- return ((this.valueAsNumber - this.min) / (this.max - this.min)) * 100;
319
+ return this.#computeProgress(this.#value);
87
320
  }
88
321
 
89
- static get styles() {
90
- return [super.styles, style];
322
+ get progressStart() {
323
+ return this.#computeProgress(this.#valueStart);
91
324
  }
92
325
 
93
- connectedCallback() {
94
- super.connectedCallback();
326
+ get progressEnd() {
327
+ return this.#computeProgress(this.#valueEnd);
328
+ }
95
329
 
96
- // we want to go a little faster than LitElement and behave more like native HTML Form Associated Elements
97
- let value = this.getAttribute('value') ?? this.value;
98
- const max = this.getAttribute('max') ?? this.max;
99
- const min = this.getAttribute('min') ?? this.min;
330
+ #computeProgress(rawValue: string): number {
331
+ if (this.#range === 0) {
332
+ return 0;
333
+ }
334
+ const num = parseFloat(rawValue);
335
+ return isNaN(num) ? 0 : parseFloat((((num - this.min) / this.#range) * 100).toFixed(4));
336
+ }
100
337
 
101
- value = this._ensureValidValue(value, min, max);
338
+ #singleTrackBackground(progress: number): string {
339
+ const c = this.#progressColor;
340
+ const stop = ZuiSlider.#thumbPositionCSS(progress);
341
+ return `linear-gradient(to right, transparent var(--zui-slider-thumb-size), ${c} var(--zui-slider-thumb-size), ${c} ${stop}, var(--zui-gray-200) ${stop}, var(--zui-gray-200) calc(100% - var(--zui-slider-thumb-size)), transparent calc(100% - var(--zui-slider-thumb-size)))`;
342
+ }
102
343
 
103
- this.#defaultValue = value;
104
- this._setFormValue(value.toString());
344
+ #rangeTrackBackground(progressStart: number, progressEnd: number): string {
345
+ const c = this.#progressColor;
346
+ const startStop = ZuiSlider.#thumbPositionCSS(progressStart);
347
+ const endStop = ZuiSlider.#thumbPositionCSS(progressEnd);
348
+ 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)))`;
105
349
  }
106
350
 
107
351
  render() {
108
- const progressColor = this.disabled ? 'var(--zui-gray)' : 'var(--zui-blue)';
109
- const styles = {
110
- background: `linear-gradient(to right, ${progressColor} 0%, ${progressColor} ${this.progress}%, var(--zui-gray-200) ${this.progress}%, var(--zui-gray-200) 100%)`,
111
- };
112
- return html`<input
113
- style=${styleMap(styles)}
352
+ return html`${this.range ? this.#renderRange() : this.#renderSingle()}${this.#renderMinMaxLabels()}`;
353
+ }
354
+
355
+ #renderSingle() {
356
+ const progress = this.progress;
357
+ return html`
358
+ <div class="single-wrapper">
359
+ <input
360
+ aria-label="Slider value"
361
+ style=${styleMap({ background: this.#singleTrackBackground(progress) })}
362
+ type="range"
363
+ .min="${String(this.min)}"
364
+ .max="${String(this.max)}"
365
+ .step="${this.step > 0 ? String(this.step) : '1'}"
366
+ .value="${this.#value}"
367
+ ?disabled="${this.disabled}"
368
+ @input="${this.#onInput}"
369
+ @change="${this.#onChange}"
370
+ @pointerenter="${this.#h.thumb.show}"
371
+ @pointerleave="${this.#h.thumb.hide}"
372
+ @focus="${this.#h.thumb.show}"
373
+ @blur="${this.#h.thumb.hide}"
374
+ />
375
+ ${this.#renderStepDots()}
376
+ ${this.#renderFloatingInput(
377
+ this.#value,
378
+ this.#onThumbFloatingInput,
379
+ this.#onThumbFloatingChange,
380
+ 'thumb',
381
+ this.#isVisible('thumb'),
382
+ progress
383
+ )}
384
+ </div>
385
+ `;
386
+ }
387
+
388
+ #renderRange() {
389
+ const progressStart = this.progressStart;
390
+ const progressEnd = this.progressEnd;
391
+ return html`
392
+ <div
393
+ class="range-wrapper"
394
+ style=${styleMap({ background: this.#rangeTrackBackground(progressStart, progressEnd) })}
395
+ >
396
+ ${this.#renderStepDots()}${this.#renderRangeInput('start')}
397
+ ${this.#renderFloatingInput(
398
+ this.#valueStart,
399
+ this.#onStartThumbFloatingInput,
400
+ this.#onStartThumbFloatingChange,
401
+ 'startThumb',
402
+ this.#isVisible('startThumb'),
403
+ progressStart
404
+ )}
405
+ ${this.#renderRangeInput('end')}
406
+ ${this.#renderFloatingInput(
407
+ this.#valueEnd,
408
+ this.#onEndThumbFloatingInput,
409
+ this.#onEndThumbFloatingChange,
410
+ 'endThumb',
411
+ this.#isVisible('endThumb'),
412
+ progressEnd
413
+ )}
414
+ </div>
415
+ `;
416
+ }
417
+
418
+ #renderRangeInput(which: 'start' | 'end') {
419
+ const flag: ThumbFlag = which === 'start' ? 'startThumb' : 'endThumb';
420
+ const val = which === 'start' ? this.#valueStart : this.#valueEnd;
421
+ const onInput = which === 'start' ? this.#onRangeStartInput : this.#onRangeEndInput;
422
+ const h = this.#h[flag];
423
+ // live() is required: when #onRangeInput snaps the DOM value back via direct input.value
424
+ // assignment (drag past the other thumb), there is no state change → no re-render. live()
425
+ // forces Lit to re-read the current DOM value and re-sync it on every render.
426
+ return html`
427
+ <input
428
+ aria-label="${which === 'start' ? 'Range start' : 'Range end'}"
429
+ class="range-${which}"
114
430
  type="range"
115
- .value="${this.value}"
116
- .min="${this.min}"
117
- .max="${this.max}"
118
- .step="${this.step}"
431
+ .min="${String(this.min)}"
432
+ .max="${String(this.max)}"
433
+ .step="${this.step > 0 ? String(this.step) : '1'}"
434
+ .value="${live(val)}"
119
435
  ?disabled="${this.disabled}"
120
- @input="${this._onInput}"
121
- @change="${this._onChange}"
122
- />${this._renderText()}`;
436
+ @input="${onInput}"
437
+ @change="${this.#onRangeChange}"
438
+ @pointerenter="${h.show}"
439
+ @pointerleave="${h.hide}"
440
+ @focus="${h.show}"
441
+ @blur="${h.hide}"
442
+ />
443
+ `;
444
+ }
445
+
446
+ #renderFloatingInput(
447
+ val: string,
448
+ onInput: (e: Event) => void,
449
+ onFloatingChange: (e: Event) => void,
450
+ flag: ThumbFlag,
451
+ visible: boolean,
452
+ progress: number
453
+ ) {
454
+ const h = this.#h[flag];
455
+ // live() is required on the number input: when the debounced setter clamps a value to the
456
+ // same string it already holds (e.g. typing 150 when max=100 clamps back to '100'), no
457
+ // reactive property change occurs and Lit skips the DOM update. live() forces a re-read.
458
+ return html`
459
+ <div
460
+ class=${classMap({ 'thumb-input': true, 'thumb-input--visible': visible })}
461
+ style=${styleMap({ left: ZuiSlider.#thumbPositionCSS(progress) })}
462
+ @pointerenter="${h.show}"
463
+ @pointerleave="${h.hide}"
464
+ >
465
+ <input
466
+ aria-label="${flag === 'startThumb'
467
+ ? 'Range start value'
468
+ : flag === 'endThumb'
469
+ ? 'Range end value'
470
+ : 'Slider value'}"
471
+ type="number"
472
+ .value="${live(val)}"
473
+ .min="${String(this.min)}"
474
+ .max="${String(this.max)}"
475
+ .step="${this.step > 0 ? String(this.step) : '1'}"
476
+ ?disabled="${this.disabled}"
477
+ ?readonly="${this.readOnly}"
478
+ @input="${onInput}"
479
+ @change="${onFloatingChange}"
480
+ @focus="${h.focus}"
481
+ @blur="${h.blur}"
482
+ />
483
+ </div>
484
+ `;
123
485
  }
124
486
 
125
- private _renderText() {
126
- if (this.noText) {
127
- return html``;
487
+ #renderStepDots() {
488
+ if (this.step <= 0 || this.#range === 0) {
489
+ return nothing;
128
490
  }
129
- const thumbOffset = `var(--zui-slider-thumb-size) * ${this.progress / 100}`;
130
- const styles = {
131
- left: `calc(${this.progress}% - ${thumbOffset} + 0.125rem)`,
132
- };
133
- return html`<span style=${styleMap(styles)}>${this.value}</span>`;
491
+ const count = Math.round(this.#range / this.step);
492
+ if (count > 100) {
493
+ return nothing;
494
+ }
495
+ const dots: TemplateResult[] = [];
496
+ for (let i = 0; i <= count; i++) {
497
+ let left: string;
498
+ if (i === 0) {
499
+ left = 'var(--zui-slider-thumb-size)';
500
+ } else if (i === count) {
501
+ left = 'calc(100% - var(--zui-slider-thumb-size))';
502
+ } else {
503
+ const pos = parseFloat((((i * this.step) / this.#range) * 100).toFixed(4));
504
+ left = ZuiSlider.#thumbPositionCSS(pos);
505
+ }
506
+ dots.push(html`<span class="step-dot" style="left: ${left}"></span>`);
507
+ }
508
+ return html`<div class="step-dots">${dots}</div>`;
509
+ }
510
+
511
+ #renderMinMaxLabels() {
512
+ if (!this.showMinMax) {
513
+ return nothing;
514
+ }
515
+ return html`
516
+ <div class="min-max-labels">
517
+ <span class="min-max-label">${this.min}</span>
518
+ <span class="min-max-label">${this.max}</span>
519
+ </div>
520
+ `;
134
521
  }
135
522
 
136
- private _onInput(e: Event) {
523
+ #onInput(e: Event) {
524
+ if (this.readOnly) {
525
+ return;
526
+ }
137
527
  this.value = (e.target as HTMLInputElement).value;
138
528
  }
139
529
 
140
- private _onChange() {
141
- this.dispatchEvent(new CustomEvent('change', { detail: this.value, bubbles: true }));
530
+ #onChange() {
531
+ this.dispatchEvent(new CustomEvent('change', { detail: this.#value, bubbles: true }));
142
532
  }
143
533
 
144
- private _ensureValidValue(value: string | number, min?: number | string | null, max?: number | string | null) {
145
- if (value === '') {
146
- return value;
147
- }
148
- min = min ?? this.min;
149
- max = max ?? this.max;
534
+ #processFloatingValue(raw: number): string {
535
+ const stepDecimals = this.step > 0 ? (String(this.step).split('.')[1] ?? '').length : 0;
536
+ const stepped = this.step > 0 ? Math.round((raw - this.min) / this.step) * this.step + this.min : raw;
537
+ const clamped = Math.min(this.max, Math.max(this.min, stepped));
538
+ return stepDecimals > 0 ? clamped.toFixed(stepDecimals) : String(Math.round(clamped));
539
+ }
150
540
 
151
- if (typeof value === 'string') {
152
- value = parseFloat(value);
153
- }
154
- if (typeof min === 'string') {
155
- min = parseFloat(min);
156
- }
157
- if (typeof max === 'string') {
158
- max = parseFloat(max);
541
+ #makeFloatingChange(flag: ThumbFlag, setter: (val: string) => void, dispatch: () => void) {
542
+ return (e: Event) => {
543
+ if (this.readOnly) {
544
+ return;
545
+ }
546
+ const input = e.target as HTMLInputElement;
547
+ if (input.value === '') {
548
+ return;
549
+ }
550
+ const entry = this.#thumbInputState.get(flag)!;
551
+ clearTimeout(entry.debounceTimer);
552
+ entry.debounceTimer = undefined;
553
+ setter(this.#processFloatingValue(parseFloat(input.value)));
554
+ dispatch();
555
+ };
556
+ }
557
+
558
+ #onFloatingInput(flag: ThumbFlag, setter: (val: string) => void) {
559
+ return (e: Event) => {
560
+ if (this.readOnly) {
561
+ return;
562
+ }
563
+ const input = e.target as HTMLInputElement;
564
+ if (input.value === '') {
565
+ return;
566
+ }
567
+ const raw = parseFloat(input.value);
568
+ const entry = this.#thumbInputState.get(flag)!;
569
+ clearTimeout(entry.debounceTimer);
570
+ entry.debounceTimer = setTimeout(() => setter(this.#processFloatingValue(raw)), 300);
571
+ };
572
+ }
573
+
574
+ #onRangeInput(which: 'start' | 'end') {
575
+ return (e: Event) => {
576
+ if (this.readOnly) {
577
+ return;
578
+ }
579
+ const input = e.target as HTMLInputElement;
580
+ if (which === 'start') {
581
+ if (parseFloat(input.value) >= parseFloat(this.#valueEnd)) {
582
+ input.value = this.#valueStart;
583
+ return;
584
+ }
585
+ this.valueStart = input.value;
586
+ } else {
587
+ if (parseFloat(input.value) <= parseFloat(this.#valueStart)) {
588
+ input.value = this.#valueEnd;
589
+ return;
590
+ }
591
+ this.valueEnd = input.value;
592
+ }
593
+ };
594
+ }
595
+
596
+ #onRangeChange() {
597
+ this.dispatchEvent(
598
+ new CustomEvent('change', {
599
+ detail: { valueStart: this.#valueStart, valueEnd: this.#valueEnd },
600
+ bubbles: true,
601
+ })
602
+ );
603
+ }
604
+
605
+ #showThumbInput(flag: ThumbFlag) {
606
+ if (this.disabled) {
607
+ return;
159
608
  }
609
+ const entry = this.#thumbInputState.get(flag)!;
610
+ clearTimeout(entry.timer);
611
+ entry.timer = undefined;
612
+ entry.visible = true;
613
+ this.requestUpdate();
614
+ }
615
+
616
+ #scheduleHideThumbInput(flag: ThumbFlag) {
617
+ const entry = this.#thumbInputState.get(flag)!;
618
+ clearTimeout(entry.timer);
619
+ entry.timer = setTimeout(() => {
620
+ if (!entry.focused) {
621
+ entry.visible = false;
622
+ this.requestUpdate();
623
+ }
624
+ }, 100);
625
+ }
160
626
 
161
- if (value < min) {
162
- value = min;
163
- } else if (value > max) {
164
- value = max;
627
+ #focusFloatingInput(flag: ThumbFlag) {
628
+ if (this.disabled) {
629
+ return;
165
630
  }
631
+ this.#showThumbInput(flag);
632
+ this.#thumbInputState.get(flag)!.focused = true;
633
+ }
634
+
635
+ #blurFloatingInput(flag: ThumbFlag) {
636
+ const entry = this.#thumbInputState.get(flag)!;
637
+ entry.focused = false;
638
+ this.#scheduleHideThumbInput(flag);
639
+ }
640
+
641
+ // Maps a 0–100 progress value to a CSS multiplier for --zui-slider-thumb-size so that
642
+ // `progress% + thumbSize * offset` lands on the thumb center within the inset wrapper.
643
+ // At 0%: offset=1.5 → thumbSize*1.5 (thumb center at min). At 100%: offset=-1.5 → -thumbSize*1.5 (thumb center at max).
644
+ static #thumbCenterOffset(progress: number): number {
645
+ return 1.5 - (3 * progress) / 100;
646
+ }
166
647
 
167
- return value.toString();
648
+ static #thumbPositionCSS(progress: number): string {
649
+ return `calc(${progress}% + var(--zui-slider-thumb-size) * ${ZuiSlider.#thumbCenterOffset(progress)})`;
168
650
  }
169
651
  }
170
652