@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/src/zui-slider.ts CHANGED
@@ -1,13 +1,17 @@
1
1
  import { ZuiFormAssociatedElement } from '@zywave/zui-base';
2
- import { html, nothing, TemplateResult } from 'lit';
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 {boolean} [show-min-max=false] - Shows the min and max values beneath the slider
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 {boolean} [showMinMax=false] - Shows the min and max values beneath the slider
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 flush debounce and dispatch immediately on commit (Enter/blur)
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 prevents new closures on every render
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
- // Schedule a re-render so the cleared visibility state is reflected in the DOM.
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('min') || changed.has('max')) {
156
- // The native inputs have already clamped (and step-snapped) their values to the new bounds.
157
- // Read them back and sync internal state + form value. If the value changed, request a
158
- // re-render so the floating input (which binds to #value/#valueStart/#valueEnd) stays in sync.
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
- // Always request a re-render so the cleared thumb visibility is reflected even when the
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
- * Clamps a raw string value to the current [min, max] range.
223
- * Empty string is passed through unchanged (represents an in-progress floating input edit).
224
- * Non-numeric strings are passed through unchanged.
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
- * Shows the min and max values beneath the slider
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="${String(this.min)}"
363
- .max="${String(this.max)}"
364
- .step="${this.step > 0 ? String(this.step) : '1'}"
365
- .value="${this.#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
- // live() is required: when #onRangeInput snaps the DOM value back via direct input.value
421
- // assignment (drag past the other thumb), there is no state change → no re-render. live()
422
- // forces Lit to re-read the current DOM value and re-sync it on every render.
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="${String(this.min)}"
430
- .max="${String(this.max)}"
431
- .step="${this.step > 0 ? String(this.step) : '1'}"
432
- .value="${live(val)}"
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
- // live() is required on the number input: when the debounced setter clamps a value to the
454
- // same string it already holds (e.g. typing 150 when max=100 clamps back to '100'), no
455
- // reactive property change occurs and Lit skips the DOM update. live() forces a re-read.
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="${flag === 'startThumb'
465
- ? 'Range start value'
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: TemplateResult[] = [];
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
- if (!this.showMinMax) {
511
- return nothing;
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 class="min-max-labels">
515
- <span class="min-max-label">${this.min}</span>
516
- <span class="min-max-label">${this.max}</span>
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
- this.value = (e.target as HTMLInputElement).value;
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
- setter(this.#processFloatingValue(parseFloat(input.value)));
552
- dispatch();
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
- const raw = parseFloat(input.value);
566
- const entry = this.#thumbInputState.get(flag)!;
567
- clearTimeout(entry.debounceTimer);
568
- entry.debounceTimer = setTimeout(() => setter(this.#processFloatingValue(raw)), 300);
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 (which === 'start') {
579
- if (parseFloat(input.value) >= parseFloat(this.#valueEnd)) {
580
- input.value = this.#valueStart;
581
- return;
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 (parseFloat(input.value) <= parseFloat(this.#valueStart)) {
586
- input.value = this.#valueEnd;
587
- return;
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 that land within a thumb's hit area those bubble up from native input
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 a 0100 progress value to a CSS multiplier for --zui-slider-thumb-size so that
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
  }