@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/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,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
- // Pre-bound floating input handlers cached to avoid new function references on every render
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 prevents new closures on every render
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
- // 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.
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('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.
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
- // 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).
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
- * 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
- */
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
- * Shows the min and max values beneath the slider
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="${String(this.min)}"
363
- .max="${String(this.max)}"
364
- .step="${this.step > 0 ? String(this.step) : '1'}"
365
- .value="${this.#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
- // 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}"
@@ -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
- // 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.
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="${flag === 'startThumb'
465
- ? 'Range start value'
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
- @input="${onInput}"
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: TemplateResult[] = [];
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
- if (!this.showMinMax) {
511
- return nothing;
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 class="min-max-labels">
515
- <span class="min-max-label">${this.min}</span>
516
- <span class="min-max-label">${this.max}</span>
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
- this.value = (e.target as HTMLInputElement).value;
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
- #makeFloatingChange(flag: ThumbFlag, setter: (val: string) => void, dispatch: () => void) {
540
- return (e: Event) => {
541
- if (this.readOnly) {
542
- return;
543
- }
544
- const input = e.target as HTMLInputElement;
545
- if (input.value === '') {
546
- return;
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
- #onFloatingInput(flag: ThumbFlag, setter: (val: string) => void) {
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 raw = parseFloat(input.value);
566
- const entry = this.#thumbInputState.get(flag)!;
567
- clearTimeout(entry.debounceTimer);
568
- entry.debounceTimer = setTimeout(() => setter(this.#processFloatingValue(raw)), 300);
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 (which === 'start') {
579
- if (parseFloat(input.value) >= parseFloat(this.#valueEnd)) {
580
- input.value = this.#valueStart;
581
- return;
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 (parseFloat(input.value) <= parseFloat(this.#valueStart)) {
586
- input.value = this.#valueEnd;
587
- return;
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 that land within a thumb's hit area those bubble up from native input
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 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).
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
  }