@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.
@@ -9,6 +9,7 @@ import { ZuiFormAssociatedElement } from '@zywave/zui-base';
9
9
  import { html, nothing } from 'lit';
10
10
  import { property } from 'lit/decorators.js';
11
11
  import { classMap } from 'lit/directives/class-map.js';
12
+ import { ifDefined } from 'lit/directives/if-defined.js';
12
13
  import { live } from 'lit/directives/live.js';
13
14
  import { styleMap } from 'lit/directives/style-map.js';
14
15
  import { style } from './zui-slider-css.js';
@@ -27,8 +28,8 @@ import { style } from './zui-slider-css.js';
27
28
  * @attr {number} [min=0] - Represents the minimum permitted value
28
29
  * @attr {number} [max=100] - Represents the maximum permitted value
29
30
  * @attr {number} [step=0] - Represents the stepping interval; 0 means any value is allowed
30
- * @attr {boolean} [show-min-max=false] - Shows the min and max values beneath the slider
31
- *
31
+ * @attr {string} [steps=''] - Comma-separated step labels; overrides min/max/step. Labels containing commas must be set via the property instead.
32
+ * @attr {boolean} [show-step-labels=false] - When set, displays each step's label beneath its dot on the track
32
33
  * @prop {string | null} [name=null] - The name of this element that is associated with form submission
33
34
  * @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
34
35
  * @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
@@ -44,7 +45,9 @@ import { style } from './zui-slider-css.js';
44
45
  * @prop {number} [min=0] - Represents the minimum permitted value
45
46
  * @prop {number} [max=100] - Represents the maximum permitted value
46
47
  * @prop {number} [step=0] - Represents the stepping interval; 0 means any value is allowed
47
- * @prop {boolean} [showMinMax=false] - Shows the min and max values beneath the slider
48
+ * @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.
49
+ * @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.
50
+ * @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
48
51
  *
49
52
  * @cssprop [--zui-slider-input-width=7ch] - Width of the floating value input above each slider thumb
50
53
  *
@@ -59,6 +62,8 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
59
62
  this.#value = '50';
60
63
  this.#valueStart = '0';
61
64
  this.#valueEnd = '100';
65
+ this.#cachedNormalizedSteps = null;
66
+ this.#cachedNumericValues = null;
62
67
  this.#thumbInputState = new Map([
63
68
  ['thumb', { visible: false, focused: false }],
64
69
  ['startThumb', { visible: false, focused: false }],
@@ -68,14 +73,14 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
68
73
  this.#onThumbFloatingInput = this.#onFloatingInput('thumb', (v) => (this.value = v));
69
74
  this.#onStartThumbFloatingInput = this.#onFloatingInput('startThumb', (v) => (this.valueStart = v));
70
75
  this.#onEndThumbFloatingInput = this.#onFloatingInput('endThumb', (v) => (this.valueEnd = v));
71
- // Cached floating input change handlers flush debounce and dispatch immediately on commit (Enter/blur)
76
+ // Cached floating input change handlers; flush debounce and dispatch immediately on commit (Enter/blur)
72
77
  this.#onThumbFloatingChange = this.#makeFloatingChange('thumb', (v) => (this.value = v), () => this.#onChange());
73
78
  this.#onStartThumbFloatingChange = this.#makeFloatingChange('startThumb', (v) => (this.valueStart = v), () => this.#onRangeChange());
74
79
  this.#onEndThumbFloatingChange = this.#makeFloatingChange('endThumb', (v) => (this.valueEnd = v), () => this.#onRangeChange());
75
80
  // Cached range drag input handlers
76
81
  this.#onRangeStartInput = this.#onRangeInput('start');
77
82
  this.#onRangeEndInput = this.#onRangeInput('end');
78
- // Cached pointer/focus handlers per thumb prevents new closures on every render
83
+ // Cached pointer/focus handlers per thumb; prevents new closures on every render
79
84
  this.#h = {
80
85
  thumb: {
81
86
  show: () => this.#showThumbInput('thumb'),
@@ -96,26 +101,14 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
96
101
  blur: () => this.#blurFloatingInput('endThumb'),
97
102
  },
98
103
  };
99
- /**
100
- * Enables range mode with two thumbs for selecting a value range
101
- */
104
+ this.steps = [];
105
+ this.stepParser = null;
102
106
  this.range = false;
103
- /**
104
- * Represents the minimum permitted value
105
- */
106
107
  this.min = 0;
107
- /**
108
- * Represents the maximum permitted value
109
- */
110
108
  this.max = 100;
111
- /**
112
- * Represents the stepping interval, used both for user interface and validation purposes
113
- */
114
109
  this.step = 0;
115
- /**
116
- * Shows the min and max values beneath the slider
117
- */
118
- this.showMinMax = false;
110
+ /** Displays each step's label beneath its dot on the track. Requires `steps` to be set. */
111
+ this.showStepLabels = false;
119
112
  }
120
113
  #defaultValue;
121
114
  #defaultValueStart;
@@ -123,19 +116,21 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
123
116
  #value;
124
117
  #valueStart;
125
118
  #valueEnd;
119
+ #cachedNormalizedSteps;
120
+ #cachedNumericValues;
126
121
  #thumbInputState;
127
122
  // Pre-bound floating input handlers cached to avoid new function references on every render
128
123
  #onThumbFloatingInput;
129
124
  #onStartThumbFloatingInput;
130
125
  #onEndThumbFloatingInput;
131
- // Cached floating input change handlers flush debounce and dispatch immediately on commit (Enter/blur)
126
+ // Cached floating input change handlers; flush debounce and dispatch immediately on commit (Enter/blur)
132
127
  #onThumbFloatingChange;
133
128
  #onStartThumbFloatingChange;
134
129
  #onEndThumbFloatingChange;
135
130
  // Cached range drag input handlers
136
131
  #onRangeStartInput;
137
132
  #onRangeEndInput;
138
- // Cached pointer/focus handlers per thumb prevents new closures on every render
133
+ // Cached pointer/focus handlers per thumb; prevents new closures on every render
139
134
  #h;
140
135
  static get styles() {
141
136
  return [super.styles, style];
@@ -143,6 +138,17 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
143
138
  connectedCallback() {
144
139
  super.connectedCallback();
145
140
  this.updateComplete.then(() => {
141
+ if (this.#stepsMode) {
142
+ this.#syncValuesToSteps();
143
+ if (this.range) {
144
+ this.#defaultValueStart = this.#valueStart;
145
+ this.#defaultValueEnd = this.#valueEnd;
146
+ }
147
+ else {
148
+ this.#defaultValue = this.#value;
149
+ }
150
+ return;
151
+ }
146
152
  if (this.range) {
147
153
  const [startInput, endInput] = this.#rangeInputs();
148
154
  this.#valueStart = startInput.value;
@@ -162,18 +168,22 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
162
168
  disconnectedCallback() {
163
169
  super.disconnectedCallback();
164
170
  this.#clearAllThumbInputState();
165
- // Schedule a re-render so the cleared visibility state is reflected in the DOM.
166
- // Lit defers this render until the element reconnects if it is currently disconnected.
171
+ // Re-render to reflect cleared visibility state; Lit defers this until reconnect.
167
172
  this.requestUpdate();
168
173
  }
169
174
  updated(changed) {
170
175
  if (changed.has('disabled') && this.disabled) {
171
176
  this.#clearAllThumbInputState();
172
177
  }
173
- if (changed.has('min') || changed.has('max')) {
174
- // The native inputs have already clamped (and step-snapped) their values to the new bounds.
175
- // Read them back and sync internal state + form value. If the value changed, request a
176
- // re-render so the floating input (which binds to #value/#valueStart/#valueEnd) stays in sync.
178
+ if (changed.has('steps')) {
179
+ this.#cachedNormalizedSteps = null;
180
+ this.#cachedNumericValues = null;
181
+ if (this.#stepsMode) {
182
+ this.#syncValuesToSteps();
183
+ }
184
+ }
185
+ if (!this.#stepsMode && (changed.has('min') || changed.has('max'))) {
186
+ // Native inputs clamp/snap to new bounds; read back and sync state. Re-render if value changed.
177
187
  if (this.range) {
178
188
  const [startInput, endInput] = this.#rangeInputs();
179
189
  const newStart = startInput.value;
@@ -210,8 +220,7 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
210
220
  }
211
221
  formResetCallback() {
212
222
  this.#clearAllThumbInputState();
213
- // Always request a re-render so the cleared thumb visibility is reflected even when the
214
- // reset value equals the current value (the named setter requestUpdate would be a no-op).
223
+ // Force re-render: if the reset value equals the current value, the setter's requestUpdate is a no-op.
215
224
  this.requestUpdate();
216
225
  if (this.range) {
217
226
  this.valueStart = this.#defaultValueStart;
@@ -231,15 +240,54 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
231
240
  entry.focused = false;
232
241
  }
233
242
  }
234
- /**
235
- * Clamps a raw string value to the current [min, max] range.
236
- * Empty string is passed through unchanged (represents an in-progress floating input edit).
237
- * Non-numeric strings are passed through unchanged.
238
- */
243
+ #syncValuesToSteps() {
244
+ const lastIdx = this.#normalizedSteps.length - 1;
245
+ if (this.range) {
246
+ if (this.#stepsIndexOf(this.#valueStart) < 0) {
247
+ this.#valueStart = this.#stepAt(0);
248
+ this.requestUpdate();
249
+ }
250
+ if (this.#stepsIndexOf(this.#valueEnd) < 0) {
251
+ this.#valueEnd = this.#stepAt(lastIdx);
252
+ this.requestUpdate();
253
+ }
254
+ this._setFormValue(`${this.#valueStart},${this.#valueEnd}`);
255
+ }
256
+ else {
257
+ if (this.#stepsIndexOf(this.#value) < 0) {
258
+ this.#value = this.#stepAt(0);
259
+ this.requestUpdate();
260
+ }
261
+ this._setFormValue(this.#value);
262
+ }
263
+ }
264
+ /** Steps mode: snaps to nearest step. Normal mode: clamps to [min, max]. Empty string passes through. */
239
265
  #clampToRange(rawVal) {
240
266
  if (rawVal === '') {
241
267
  return rawVal;
242
268
  }
269
+ if (this.#stepsMode) {
270
+ if (this.#stepsIndexOf(rawVal) >= 0) {
271
+ return rawVal;
272
+ }
273
+ let n = null;
274
+ if (this.stepParser) {
275
+ const result = this.stepParser(rawVal);
276
+ if (typeof result === 'number') {
277
+ n = result;
278
+ }
279
+ else if (typeof result === 'string' && this.#stepsIndexOf(result) >= 0) {
280
+ return result;
281
+ }
282
+ }
283
+ if (n === null) {
284
+ n = parseFloat(rawVal);
285
+ }
286
+ if (!isNaN(n)) {
287
+ return this.#snapToSteps(n);
288
+ }
289
+ return this.#stepAt(0);
290
+ }
243
291
  const num = parseFloat(rawVal);
244
292
  if (isNaN(num)) {
245
293
  return rawVal;
@@ -288,6 +336,99 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
288
336
  #isVisible(flag) {
289
337
  return this.#thumbInputState.get(flag).visible && !this.disabled;
290
338
  }
339
+ // ─── Steps helpers ───────────────────────────────────────────────────────────
340
+ get #stepsMode() {
341
+ return this.steps.length > 0;
342
+ }
343
+ get #nativeRangeAttrs() {
344
+ return {
345
+ nativeMin: this.#stepsMode ? '0' : String(this.min),
346
+ nativeMax: this.#stepsMode ? String(this.#normalizedSteps.length - 1) : String(this.max),
347
+ nativeStep: this.#stepsMode ? '1' : this.step > 0 ? String(this.step) : '1',
348
+ };
349
+ }
350
+ /** Normalizes each StepInput to `{ value, label }`. Cached after first access. */
351
+ get #normalizedSteps() {
352
+ if (!this.#cachedNormalizedSteps) {
353
+ this.#cachedNormalizedSteps = this.steps.map((s) => {
354
+ if (typeof s === 'number') {
355
+ return { value: s, label: String(s) };
356
+ }
357
+ if (typeof s === 'string') {
358
+ return { value: s, label: s };
359
+ }
360
+ return { value: s.value, label: s.label ?? String(s.value) };
361
+ });
362
+ }
363
+ return this.#cachedNormalizedSteps;
364
+ }
365
+ #stepsIndexOf(val) {
366
+ return this.#normalizedSteps.findIndex((s) => s.label === val);
367
+ }
368
+ #stepAt(index) {
369
+ const normalized = this.#normalizedSteps;
370
+ return normalized[Math.max(0, Math.min(normalized.length - 1, index))].label;
371
+ }
372
+ /** Numeric value per step for #snapToSteps. Strings parsed via parseFloat; unparseable strings map to Infinity. */
373
+ get #stepsNumericValues() {
374
+ if (!this.#cachedNumericValues) {
375
+ this.#cachedNumericValues = this.#normalizedSteps.map((step) => {
376
+ if (typeof step.value === 'number') {
377
+ return step.value;
378
+ }
379
+ const sv = step.value;
380
+ const n = parseFloat(sv);
381
+ return isNaN(n) ? Infinity : n;
382
+ });
383
+ }
384
+ return this.#cachedNumericValues;
385
+ }
386
+ /** Snaps n to the nearest step by numeric value; overflow steps win only past the last finite value. */
387
+ #snapToSteps(n) {
388
+ const numericValues = this.#stepsNumericValues;
389
+ let lastFiniteValue = -Infinity;
390
+ for (const v of numericValues) {
391
+ if (isFinite(v) && v > lastFiniteValue) {
392
+ lastFiniteValue = v;
393
+ }
394
+ }
395
+ let bestIdx = 0;
396
+ let bestDist = Infinity;
397
+ for (let i = 0; i < numericValues.length; i++) {
398
+ const v = numericValues[i];
399
+ const dist = v === Infinity ? (n > lastFiniteValue ? 0 : Math.abs(n - lastFiniteValue) + 1) : Math.abs(n - v);
400
+ if (dist < bestDist) {
401
+ bestDist = dist;
402
+ bestIdx = i;
403
+ }
404
+ }
405
+ return this.#stepAt(bestIdx);
406
+ }
407
+ /** Resolves a user-typed string to a step label; returns null if unresolvable. Steps mode only. */
408
+ #resolveFloatingInput(raw) {
409
+ // Exact label match wins; a valid label is never rejected by a stepParser that doesn't recognize it.
410
+ const exactIdx = this.#stepsIndexOf(raw);
411
+ if (exactIdx >= 0) {
412
+ return this.#stepAt(exactIdx);
413
+ }
414
+ if (this.stepParser) {
415
+ const result = this.stepParser(raw);
416
+ if (result === null) {
417
+ return null;
418
+ }
419
+ if (typeof result === 'number') {
420
+ return this.#snapToSteps(result);
421
+ }
422
+ // String result must be a valid step label
423
+ return this.#stepsIndexOf(result) >= 0 ? result : null;
424
+ }
425
+ const n = parseFloat(raw);
426
+ if (isNaN(n)) {
427
+ return null;
428
+ }
429
+ return this.#snapToSteps(n);
430
+ }
431
+ // ─── Progress ────────────────────────────────────────────────────────────────
291
432
  get progress() {
292
433
  return this.#computeProgress(this.#value);
293
434
  }
@@ -298,6 +439,14 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
298
439
  return this.#computeProgress(this.#valueEnd);
299
440
  }
300
441
  #computeProgress(rawValue) {
442
+ if (this.#stepsMode) {
443
+ const total = this.#normalizedSteps.length - 1;
444
+ if (total <= 0) {
445
+ return 0;
446
+ }
447
+ const idx = this.#stepsIndexOf(rawValue);
448
+ return idx < 0 ? 0 : parseFloat(((idx / total) * 100).toFixed(4));
449
+ }
301
450
  if (this.#range === 0) {
302
451
  return 0;
303
452
  }
@@ -315,21 +464,24 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
315
464
  const endStop = _a.#thumbPositionCSS(progressEnd);
316
465
  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)))`;
317
466
  }
467
+ // ─── Render ──────────────────────────────────────────────────────────────────
318
468
  render() {
319
469
  return html `${this.range ? this.#renderRange() : this.#renderSingle()}${this.#renderMinMaxLabels()}`;
320
470
  }
321
471
  #renderSingle() {
322
472
  const progress = this.progress;
473
+ const { nativeMin, nativeMax, nativeStep } = this.#nativeRangeAttrs;
474
+ const nativeValue = this.#stepsMode ? String(Math.max(0, this.#stepsIndexOf(this.#value))) : this.#value;
323
475
  return html `
324
476
  <div class="single-wrapper">
325
477
  <input
326
478
  aria-label="Slider value"
327
479
  style=${styleMap({ '--zui-slider-track-bg': this.#singleTrackBackground(progress) })}
328
480
  type="range"
329
- .min="${String(this.min)}"
330
- .max="${String(this.max)}"
331
- .step="${this.step > 0 ? String(this.step) : '1'}"
332
- .value="${this.#value}"
481
+ .min="${nativeMin}"
482
+ .max="${nativeMax}"
483
+ .step="${nativeStep}"
484
+ .value="${nativeValue}"
333
485
  ?disabled="${this.disabled || this.readOnly}"
334
486
  @input="${this.#onInput}"
335
487
  @change="${this.#onChange}"
@@ -361,19 +513,19 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
361
513
  const val = which === 'start' ? this.#valueStart : this.#valueEnd;
362
514
  const onInput = which === 'start' ? this.#onRangeStartInput : this.#onRangeEndInput;
363
515
  const h = this.#h[flag];
364
- // live() is required: when #onRangeInput snaps the DOM value back via direct input.value
365
- // assignment (drag past the other thumb), there is no state change → no re-render. live()
366
- // forces Lit to re-read the current DOM value and re-sync it on every render.
516
+ const { nativeMin, nativeMax, nativeStep } = this.#nativeRangeAttrs;
517
+ const nativeValue = this.#stepsMode ? String(Math.max(0, this.#stepsIndexOf(val))) : val;
518
+ // live() required: direct DOM writes during drag don't trigger a state change, so Lit won't re-sync without it.
367
519
  return html `
368
520
  <input
369
521
  aria-label="${which === 'start' ? 'Range start' : 'Range end'}"
370
522
  class="range-${which}"
371
523
  type="range"
372
524
  style=${trackBg ? styleMap({ '--zui-slider-track-bg': trackBg }) : nothing}
373
- .min="${String(this.min)}"
374
- .max="${String(this.max)}"
375
- .step="${this.step > 0 ? String(this.step) : '1'}"
376
- .value="${live(val)}"
525
+ .min="${nativeMin}"
526
+ .max="${nativeMax}"
527
+ .step="${nativeStep}"
528
+ .value="${live(nativeValue)}"
377
529
  ?disabled="${this.disabled || this.readOnly}"
378
530
  @input="${onInput}"
379
531
  @change="${this.#onRangeChange}"
@@ -386,9 +538,9 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
386
538
  }
387
539
  #renderFloatingInput(val, onInput, onFloatingChange, flag, visible, progress) {
388
540
  const h = this.#h[flag];
389
- // live() is required on the number input: when the debounced setter clamps a value to the
390
- // same string it already holds (e.g. typing 150 when max=100 clamps back to '100'), no
391
- // reactive property change occurs and Lit skips the DOM update. live() forces a re-read.
541
+ // type="text" in steps mode to allow label and stepParser input.
542
+ // live() required: same-value debounce resolutions skip reactive updates, so Lit won't re-sync without it.
543
+ const ariaLabel = flag === 'startThumb' ? 'Range start value' : flag === 'endThumb' ? 'Range end value' : 'Slider value';
392
544
  return html `
393
545
  <div
394
546
  class=${classMap({ 'thumb-input': true, 'thumb-input--visible': visible })}
@@ -397,16 +549,12 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
397
549
  @pointerleave="${h.hide}"
398
550
  >
399
551
  <input
400
- aria-label="${flag === 'startThumb'
401
- ? 'Range start value'
402
- : flag === 'endThumb'
403
- ? 'Range end value'
404
- : 'Slider value'}"
405
- type="number"
552
+ aria-label="${ariaLabel}"
553
+ type="${this.#stepsMode ? 'text' : 'number'}"
406
554
  .value="${live(val)}"
407
- .min="${String(this.min)}"
408
- .max="${String(this.max)}"
409
- .step="${this.step > 0 ? String(this.step) : '1'}"
555
+ .min="${this.#stepsMode ? '' : String(this.min)}"
556
+ .max="${this.#stepsMode ? '' : String(this.max)}"
557
+ .step="${this.#stepsMode ? '' : this.step > 0 ? String(this.step) : '1'}"
410
558
  ?disabled="${this.disabled}"
411
559
  ?readonly="${this.readOnly}"
412
560
  @input="${onInput}"
@@ -418,6 +566,28 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
418
566
  `;
419
567
  }
420
568
  #renderStepDots() {
569
+ if (this.#stepsMode) {
570
+ const normalized = this.#normalizedSteps;
571
+ const total = normalized.length - 1;
572
+ if (total <= 0 || normalized.length > 100) {
573
+ return nothing;
574
+ }
575
+ const stepDots = normalized.map((step, i) => {
576
+ const left = i === 0
577
+ ? 'var(--zui-slider-thumb-size)'
578
+ : i === total
579
+ ? 'calc(100% - var(--zui-slider-thumb-size))'
580
+ : _a.#thumbPositionCSS((i / total) * 100);
581
+ return html `<span
582
+ class=${classMap({ 'step-dot': true, 'step-dot--last': i === total && this.showStepLabels })}
583
+ style="left: ${left}"
584
+ ></span>
585
+ ${this.showStepLabels
586
+ ? html `<span class="step-dot-label" style="left: ${left}">${step.label}</span>`
587
+ : nothing}`;
588
+ });
589
+ return html `<div class="step-dots">${stepDots}</div>`;
590
+ }
421
591
  if (this.step <= 0 || this.#range === 0) {
422
592
  return nothing;
423
593
  }
@@ -443,21 +613,33 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
443
613
  return html `<div class="step-dots">${dots}</div>`;
444
614
  }
445
615
  #renderMinMaxLabels() {
446
- if (!this.showMinMax) {
447
- return nothing;
448
- }
616
+ const normalized = this.#normalizedSteps;
617
+ const minLabel = this.#stepsMode ? normalized[0]?.label ?? '' : String(this.min);
618
+ const maxLabel = this.#stepsMode ? normalized[normalized.length - 1]?.label ?? '' : String(this.max);
619
+ const hidden = this.showStepLabels && this.#stepsMode;
449
620
  return html `
450
- <div class="min-max-labels">
451
- <span class="min-max-label">${this.min}</span>
452
- <span class="min-max-label">${this.max}</span>
621
+ <div
622
+ class="min-max-labels"
623
+ aria-hidden="${ifDefined(hidden ? 'true' : undefined)}"
624
+ style=${hidden ? styleMap({ visibility: 'hidden' }) : nothing}
625
+ >
626
+ <span class="min-max-label">${minLabel}</span>
627
+ <span class="min-max-label">${maxLabel}</span>
453
628
  </div>
454
629
  `;
455
630
  }
631
+ // ─── Event handlers ──────────────────────────────────────────────────────────
456
632
  #onInput(e) {
457
633
  if (this.readOnly) {
458
634
  return;
459
635
  }
460
- this.value = e.target.value;
636
+ const input = e.target;
637
+ if (this.#stepsMode) {
638
+ this.value = this.#stepAt(parseInt(input.value, 10));
639
+ }
640
+ else {
641
+ this.value = input.value;
642
+ }
461
643
  }
462
644
  #onChange() {
463
645
  this.dispatchEvent(new CustomEvent('change', { detail: this.#value, bubbles: true }));
@@ -468,20 +650,44 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
468
650
  const clamped = Math.min(this.max, Math.max(this.min, stepped));
469
651
  return stepDecimals > 0 ? clamped.toFixed(stepDecimals) : String(Math.round(clamped));
470
652
  }
653
+ #currentValueForFlag(flag) {
654
+ if (flag === 'startThumb') {
655
+ return this.#valueStart;
656
+ }
657
+ if (flag === 'endThumb') {
658
+ return this.#valueEnd;
659
+ }
660
+ return this.#value;
661
+ }
471
662
  #makeFloatingChange(flag, setter, dispatch) {
472
663
  return (e) => {
473
664
  if (this.readOnly) {
474
665
  return;
475
666
  }
476
667
  const input = e.target;
477
- if (input.value === '') {
478
- return;
479
- }
480
668
  const entry = this.#thumbInputState.get(flag);
481
669
  clearTimeout(entry.debounceTimer);
482
670
  entry.debounceTimer = undefined;
483
- setter(this.#processFloatingValue(parseFloat(input.value)));
484
- dispatch();
671
+ if (input.value === '') {
672
+ input.value = this.#currentValueForFlag(flag);
673
+ return;
674
+ }
675
+ if (this.#stepsMode) {
676
+ const resolved = this.#resolveFloatingInput(input.value);
677
+ if (resolved !== null) {
678
+ setter(resolved);
679
+ this.requestUpdate();
680
+ dispatch();
681
+ }
682
+ else {
683
+ input.value = this.#currentValueForFlag(flag);
684
+ }
685
+ }
686
+ else {
687
+ setter(this.#processFloatingValue(parseFloat(input.value)));
688
+ this.requestUpdate();
689
+ dispatch();
690
+ }
485
691
  };
486
692
  }
487
693
  #onFloatingInput(flag, setter) {
@@ -490,13 +696,30 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
490
696
  return;
491
697
  }
492
698
  const input = e.target;
699
+ const entry = this.#thumbInputState.get(flag);
700
+ clearTimeout(entry.debounceTimer);
701
+ entry.debounceTimer = undefined;
493
702
  if (input.value === '') {
494
703
  return;
495
704
  }
496
- const raw = parseFloat(input.value);
497
- const entry = this.#thumbInputState.get(flag);
498
- clearTimeout(entry.debounceTimer);
499
- entry.debounceTimer = setTimeout(() => setter(this.#processFloatingValue(raw)), 300);
705
+ if (this.#stepsMode) {
706
+ // Capture raw string at event time so the debounce closure reads the right value
707
+ const raw = input.value;
708
+ entry.debounceTimer = setTimeout(() => {
709
+ const resolved = this.#resolveFloatingInput(raw);
710
+ if (resolved !== null) {
711
+ setter(resolved);
712
+ this.requestUpdate();
713
+ }
714
+ }, 500);
715
+ }
716
+ else {
717
+ const raw = parseFloat(input.value);
718
+ entry.debounceTimer = setTimeout(() => {
719
+ setter(this.#processFloatingValue(raw));
720
+ this.requestUpdate();
721
+ }, 500);
722
+ }
500
723
  };
501
724
  }
502
725
  #onRangeInput(which) {
@@ -505,19 +728,40 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
505
728
  return;
506
729
  }
507
730
  const input = e.target;
508
- if (which === 'start') {
509
- if (parseFloat(input.value) >= parseFloat(this.#valueEnd)) {
510
- input.value = this.#valueStart;
511
- return;
731
+ if (this.#stepsMode) {
732
+ const idx = parseInt(input.value, 10);
733
+ const startIdx = Math.max(0, this.#stepsIndexOf(this.#valueStart));
734
+ const endIdx = Math.max(0, this.#stepsIndexOf(this.#valueEnd));
735
+ if (which === 'start') {
736
+ if (idx >= endIdx) {
737
+ input.value = String(startIdx);
738
+ return;
739
+ }
740
+ this.valueStart = this.#stepAt(idx);
741
+ }
742
+ else {
743
+ if (idx <= startIdx) {
744
+ input.value = String(endIdx);
745
+ return;
746
+ }
747
+ this.valueEnd = this.#stepAt(idx);
512
748
  }
513
- this.valueStart = input.value;
514
749
  }
515
750
  else {
516
- if (parseFloat(input.value) <= parseFloat(this.#valueStart)) {
517
- input.value = this.#valueEnd;
518
- return;
751
+ if (which === 'start') {
752
+ if (parseFloat(input.value) >= parseFloat(this.#valueEnd)) {
753
+ input.value = this.#valueStart;
754
+ return;
755
+ }
756
+ this.valueStart = input.value;
757
+ }
758
+ else {
759
+ if (parseFloat(input.value) <= parseFloat(this.#valueStart)) {
760
+ input.value = this.#valueEnd;
761
+ return;
762
+ }
763
+ this.valueEnd = input.value;
519
764
  }
520
- this.valueEnd = input.value;
521
765
  }
522
766
  };
523
767
  }
@@ -538,9 +782,37 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
538
782
  const trackLeft = rect.left + thumbRadius;
539
783
  const effectiveWidth = rect.width - 2 * thumbRadius;
540
784
  const fraction = Math.max(0, Math.min(1, (e.clientX - trackLeft) / effectiveWidth));
785
+ if (this.#stepsMode) {
786
+ const total = this.#normalizedSteps.length - 1;
787
+ if (total <= 0) {
788
+ return;
789
+ }
790
+ const clickedIdx = Math.round(fraction * total);
791
+ const startIdx = Math.max(0, this.#stepsIndexOf(this.#valueStart));
792
+ const endIdx = Math.max(0, this.#stepsIndexOf(this.#valueEnd));
793
+ // Ignore clicks within a thumb's hit area
794
+ const startX = trackLeft + (startIdx / total) * effectiveWidth;
795
+ const endX = trackLeft + (endIdx / total) * effectiveWidth;
796
+ if (Math.abs(e.clientX - startX) <= thumbRadius || Math.abs(e.clientX - endX) <= thumbRadius) {
797
+ return;
798
+ }
799
+ // Move whichever thumb is closer by index distance; prefer start on a tie
800
+ if (Math.abs(clickedIdx - startIdx) <= Math.abs(clickedIdx - endIdx)) {
801
+ if (clickedIdx < endIdx) {
802
+ this.valueStart = this.#stepAt(clickedIdx);
803
+ this.#onRangeChange();
804
+ }
805
+ }
806
+ else {
807
+ if (clickedIdx > startIdx) {
808
+ this.valueEnd = this.#stepAt(clickedIdx);
809
+ this.#onRangeChange();
810
+ }
811
+ }
812
+ return;
813
+ }
541
814
  const rawValue = this.min + fraction * (this.max - this.min);
542
- // Ignore clicks that land within a thumb's hit area those bubble up from native input
543
- // interactions and should not be treated as track clicks.
815
+ // Ignore clicks within a thumb's hit area; those are native input interactions, not track clicks.
544
816
  const startX = trackLeft + (this.progressStart / 100) * effectiveWidth;
545
817
  const endX = trackLeft + (this.progressEnd / 100) * effectiveWidth;
546
818
  if (Math.abs(e.clientX - startX) <= thumbRadius || Math.abs(e.clientX - endX) <= thumbRadius) {
@@ -596,9 +868,7 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
596
868
  entry.focused = false;
597
869
  this.#scheduleHideThumbInput(flag);
598
870
  }
599
- // Maps a 0100 progress value to a CSS multiplier for --zui-slider-thumb-size so that
600
- // `progress% + thumbSize * offset` lands on the thumb center within the inset wrapper.
601
- // At 0%: offset=1.5 → thumbSize*1.5 (thumb center at min). At 100%: offset=-1.5 → -thumbSize*1.5 (thumb center at max).
871
+ // 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.
602
872
  static #thumbCenterOffset(progress) {
603
873
  return 1.5 - (3 * progress) / 100;
604
874
  }
@@ -607,6 +877,17 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
607
877
  }
608
878
  }
609
879
  _a = ZuiSlider;
880
+ __decorate([
881
+ property({
882
+ converter: {
883
+ fromAttribute: (value) => (value ? value.split(',').map((s) => s.trim()) : []),
884
+ toAttribute: (value) => value.map((s) => (typeof s === 'object' ? s.label ?? String(s.value) : String(s))).join(','),
885
+ },
886
+ })
887
+ ], ZuiSlider.prototype, "steps", void 0);
888
+ __decorate([
889
+ property({ attribute: false })
890
+ ], ZuiSlider.prototype, "stepParser", void 0);
610
891
  __decorate([
611
892
  property()
612
893
  ], ZuiSlider.prototype, "value", null);
@@ -629,7 +910,7 @@ __decorate([
629
910
  property({ type: Number })
630
911
  ], ZuiSlider.prototype, "step", void 0);
631
912
  __decorate([
632
- property({ type: Boolean, attribute: 'show-min-max' })
633
- ], ZuiSlider.prototype, "showMinMax", void 0);
913
+ property({ type: Boolean, attribute: 'show-step-labels' })
914
+ ], ZuiSlider.prototype, "showStepLabels", void 0);
634
915
  window.customElements.define('zui-slider', ZuiSlider);
635
916
  //# sourceMappingURL=zui-slider.js.map