@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.
@@ -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,23 +62,30 @@ 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 }],
65
70
  ['endThumb', { visible: false, focused: false }],
66
71
  ]);
67
- // Pre-bound floating input handlers cached to avoid new function references on every render
68
- this.#onThumbFloatingInput = this.#onFloatingInput('thumb', (v) => (this.value = v));
69
- this.#onStartThumbFloatingInput = this.#onFloatingInput('startThumb', (v) => (this.valueStart = v));
70
- this.#onEndThumbFloatingInput = this.#onFloatingInput('endThumb', (v) => (this.valueEnd = v));
71
- // Cached floating input change handlers — flush debounce and dispatch immediately on commit (Enter/blur)
72
+ // Cached floating input change handlers; commit value on Enter or blur
72
73
  this.#onThumbFloatingChange = this.#makeFloatingChange('thumb', (v) => (this.value = v), () => this.#onChange());
73
74
  this.#onStartThumbFloatingChange = this.#makeFloatingChange('startThumb', (v) => (this.valueStart = v), () => this.#onRangeChange());
74
75
  this.#onEndThumbFloatingChange = this.#makeFloatingChange('endThumb', (v) => (this.valueEnd = v), () => this.#onRangeChange());
76
+ // Cached keydown handler: Enter commits the floating input value in both number and text modes.
77
+ this.#onFloatingInputKeydown = (e) => {
78
+ if (e.key === 'Enter') {
79
+ e.preventDefault();
80
+ const input = e.target;
81
+ input.dispatchEvent(new Event('change'));
82
+ input.blur();
83
+ }
84
+ };
75
85
  // Cached range drag input handlers
76
86
  this.#onRangeStartInput = this.#onRangeInput('start');
77
87
  this.#onRangeEndInput = this.#onRangeInput('end');
78
- // Cached pointer/focus handlers per thumb prevents new closures on every render
88
+ // Cached pointer/focus handlers per thumb; prevents new closures on every render
79
89
  this.#h = {
80
90
  thumb: {
81
91
  show: () => this.#showThumbInput('thumb'),
@@ -96,26 +106,14 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
96
106
  blur: () => this.#blurFloatingInput('endThumb'),
97
107
  },
98
108
  };
99
- /**
100
- * Enables range mode with two thumbs for selecting a value range
101
- */
109
+ this.steps = [];
110
+ this.stepParser = null;
102
111
  this.range = false;
103
- /**
104
- * Represents the minimum permitted value
105
- */
106
112
  this.min = 0;
107
- /**
108
- * Represents the maximum permitted value
109
- */
110
113
  this.max = 100;
111
- /**
112
- * Represents the stepping interval, used both for user interface and validation purposes
113
- */
114
114
  this.step = 0;
115
- /**
116
- * Shows the min and max values beneath the slider
117
- */
118
- this.showMinMax = false;
115
+ /** Displays each step's label beneath its dot on the track. Requires `steps` to be set. */
116
+ this.showStepLabels = false;
119
117
  }
120
118
  #defaultValue;
121
119
  #defaultValueStart;
@@ -123,19 +121,19 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
123
121
  #value;
124
122
  #valueStart;
125
123
  #valueEnd;
124
+ #cachedNormalizedSteps;
125
+ #cachedNumericValues;
126
126
  #thumbInputState;
127
- // Pre-bound floating input handlers cached to avoid new function references on every render
128
- #onThumbFloatingInput;
129
- #onStartThumbFloatingInput;
130
- #onEndThumbFloatingInput;
131
- // Cached floating input change handlers — flush debounce and dispatch immediately on commit (Enter/blur)
127
+ // Cached floating input change handlers; commit value on Enter or blur
132
128
  #onThumbFloatingChange;
133
129
  #onStartThumbFloatingChange;
134
130
  #onEndThumbFloatingChange;
131
+ // Cached keydown handler: Enter commits the floating input value in both number and text modes.
132
+ #onFloatingInputKeydown;
135
133
  // Cached range drag input handlers
136
134
  #onRangeStartInput;
137
135
  #onRangeEndInput;
138
- // Cached pointer/focus handlers per thumb prevents new closures on every render
136
+ // Cached pointer/focus handlers per thumb; prevents new closures on every render
139
137
  #h;
140
138
  static get styles() {
141
139
  return [super.styles, style];
@@ -143,6 +141,17 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
143
141
  connectedCallback() {
144
142
  super.connectedCallback();
145
143
  this.updateComplete.then(() => {
144
+ if (this.#stepsMode) {
145
+ this.#syncValuesToSteps();
146
+ if (this.range) {
147
+ this.#defaultValueStart = this.#valueStart;
148
+ this.#defaultValueEnd = this.#valueEnd;
149
+ }
150
+ else {
151
+ this.#defaultValue = this.#value;
152
+ }
153
+ return;
154
+ }
146
155
  if (this.range) {
147
156
  const [startInput, endInput] = this.#rangeInputs();
148
157
  this.#valueStart = startInput.value;
@@ -162,18 +171,22 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
162
171
  disconnectedCallback() {
163
172
  super.disconnectedCallback();
164
173
  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.
174
+ // Re-render to reflect cleared visibility state; Lit defers this until reconnect.
167
175
  this.requestUpdate();
168
176
  }
169
177
  updated(changed) {
170
178
  if (changed.has('disabled') && this.disabled) {
171
179
  this.#clearAllThumbInputState();
172
180
  }
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.
181
+ if (changed.has('steps')) {
182
+ this.#cachedNormalizedSteps = null;
183
+ this.#cachedNumericValues = null;
184
+ if (this.#stepsMode) {
185
+ this.#syncValuesToSteps();
186
+ }
187
+ }
188
+ if (!this.#stepsMode && (changed.has('min') || changed.has('max'))) {
189
+ // Native inputs clamp/snap to new bounds; read back and sync state. Re-render if value changed.
177
190
  if (this.range) {
178
191
  const [startInput, endInput] = this.#rangeInputs();
179
192
  const newStart = startInput.value;
@@ -210,8 +223,7 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
210
223
  }
211
224
  formResetCallback() {
212
225
  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).
226
+ // Force re-render: if the reset value equals the current value, the setter's requestUpdate is a no-op.
215
227
  this.requestUpdate();
216
228
  if (this.range) {
217
229
  this.valueStart = this.#defaultValueStart;
@@ -224,22 +236,59 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
224
236
  #clearAllThumbInputState() {
225
237
  for (const entry of this.#thumbInputState.values()) {
226
238
  clearTimeout(entry.timer);
227
- clearTimeout(entry.debounceTimer);
228
239
  entry.timer = undefined;
229
- entry.debounceTimer = undefined;
230
240
  entry.visible = false;
231
241
  entry.focused = false;
232
242
  }
233
243
  }
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
- */
244
+ #syncValuesToSteps() {
245
+ const lastIdx = this.#normalizedSteps.length - 1;
246
+ if (this.range) {
247
+ if (this.#stepsIndexOf(this.#valueStart) < 0) {
248
+ this.#valueStart = this.#stepAt(0);
249
+ this.requestUpdate();
250
+ }
251
+ if (this.#stepsIndexOf(this.#valueEnd) < 0) {
252
+ this.#valueEnd = this.#stepAt(lastIdx);
253
+ this.requestUpdate();
254
+ }
255
+ this._setFormValue(`${this.#valueStart},${this.#valueEnd}`);
256
+ }
257
+ else {
258
+ if (this.#stepsIndexOf(this.#value) < 0) {
259
+ this.#value = this.#stepAt(0);
260
+ this.requestUpdate();
261
+ }
262
+ this._setFormValue(this.#value);
263
+ }
264
+ }
265
+ /** Steps mode: snaps to nearest step. Normal mode: clamps to [min, max]. Empty string passes through. */
239
266
  #clampToRange(rawVal) {
240
267
  if (rawVal === '') {
241
268
  return rawVal;
242
269
  }
270
+ if (this.#stepsMode) {
271
+ if (this.#stepsIndexOf(rawVal) >= 0) {
272
+ return rawVal;
273
+ }
274
+ let n = null;
275
+ if (this.stepParser) {
276
+ const result = this.stepParser(rawVal);
277
+ if (typeof result === 'number') {
278
+ n = result;
279
+ }
280
+ else if (typeof result === 'string' && this.#stepsIndexOf(result) >= 0) {
281
+ return result;
282
+ }
283
+ }
284
+ if (n === null) {
285
+ n = parseFloat(rawVal);
286
+ }
287
+ if (!isNaN(n)) {
288
+ return this.#snapToSteps(n);
289
+ }
290
+ return this.#stepAt(0);
291
+ }
243
292
  const num = parseFloat(rawVal);
244
293
  if (isNaN(num)) {
245
294
  return rawVal;
@@ -288,6 +337,99 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
288
337
  #isVisible(flag) {
289
338
  return this.#thumbInputState.get(flag).visible && !this.disabled;
290
339
  }
340
+ // ─── Steps helpers ───────────────────────────────────────────────────────────
341
+ get #stepsMode() {
342
+ return this.steps.length > 0;
343
+ }
344
+ get #nativeRangeAttrs() {
345
+ return {
346
+ nativeMin: this.#stepsMode ? '0' : String(this.min),
347
+ nativeMax: this.#stepsMode ? String(this.#normalizedSteps.length - 1) : String(this.max),
348
+ nativeStep: this.#stepsMode ? '1' : this.step > 0 ? String(this.step) : '1',
349
+ };
350
+ }
351
+ /** Normalizes each StepInput to `{ value, label }`. Cached after first access. */
352
+ get #normalizedSteps() {
353
+ if (!this.#cachedNormalizedSteps) {
354
+ this.#cachedNormalizedSteps = this.steps.map((s) => {
355
+ if (typeof s === 'number') {
356
+ return { value: s, label: String(s) };
357
+ }
358
+ if (typeof s === 'string') {
359
+ return { value: s, label: s };
360
+ }
361
+ return { value: s.value, label: s.label ?? String(s.value) };
362
+ });
363
+ }
364
+ return this.#cachedNormalizedSteps;
365
+ }
366
+ #stepsIndexOf(val) {
367
+ return this.#normalizedSteps.findIndex((s) => s.label === val);
368
+ }
369
+ #stepAt(index) {
370
+ const normalized = this.#normalizedSteps;
371
+ return normalized[Math.max(0, Math.min(normalized.length - 1, index))].label;
372
+ }
373
+ /** Numeric value per step for #snapToSteps. Strings parsed via parseFloat; unparseable strings map to Infinity. */
374
+ get #stepsNumericValues() {
375
+ if (!this.#cachedNumericValues) {
376
+ this.#cachedNumericValues = this.#normalizedSteps.map((step) => {
377
+ if (typeof step.value === 'number') {
378
+ return step.value;
379
+ }
380
+ const sv = step.value;
381
+ const n = parseFloat(sv);
382
+ return isNaN(n) ? Infinity : n;
383
+ });
384
+ }
385
+ return this.#cachedNumericValues;
386
+ }
387
+ /** Snaps n to the nearest step by numeric value; overflow steps win only past the last finite value. */
388
+ #snapToSteps(n) {
389
+ const numericValues = this.#stepsNumericValues;
390
+ let lastFiniteValue = -Infinity;
391
+ for (const v of numericValues) {
392
+ if (isFinite(v) && v > lastFiniteValue) {
393
+ lastFiniteValue = v;
394
+ }
395
+ }
396
+ let bestIdx = 0;
397
+ let bestDist = Infinity;
398
+ for (let i = 0; i < numericValues.length; i++) {
399
+ const v = numericValues[i];
400
+ const dist = v === Infinity ? (n > lastFiniteValue ? 0 : Math.abs(n - lastFiniteValue) + 1) : Math.abs(n - v);
401
+ if (dist < bestDist) {
402
+ bestDist = dist;
403
+ bestIdx = i;
404
+ }
405
+ }
406
+ return this.#stepAt(bestIdx);
407
+ }
408
+ /** Resolves a user-typed string to a step label; returns null if unresolvable. Steps mode only. */
409
+ #resolveFloatingInput(raw) {
410
+ // Exact label match wins; a valid label is never rejected by a stepParser that doesn't recognize it.
411
+ const exactIdx = this.#stepsIndexOf(raw);
412
+ if (exactIdx >= 0) {
413
+ return this.#stepAt(exactIdx);
414
+ }
415
+ if (this.stepParser) {
416
+ const result = this.stepParser(raw);
417
+ if (result === null) {
418
+ return null;
419
+ }
420
+ if (typeof result === 'number') {
421
+ return this.#snapToSteps(result);
422
+ }
423
+ // String result must be a valid step label
424
+ return this.#stepsIndexOf(result) >= 0 ? result : null;
425
+ }
426
+ const n = parseFloat(raw);
427
+ if (isNaN(n)) {
428
+ return null;
429
+ }
430
+ return this.#snapToSteps(n);
431
+ }
432
+ // ─── Progress ────────────────────────────────────────────────────────────────
291
433
  get progress() {
292
434
  return this.#computeProgress(this.#value);
293
435
  }
@@ -298,6 +440,14 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
298
440
  return this.#computeProgress(this.#valueEnd);
299
441
  }
300
442
  #computeProgress(rawValue) {
443
+ if (this.#stepsMode) {
444
+ const total = this.#normalizedSteps.length - 1;
445
+ if (total <= 0) {
446
+ return 0;
447
+ }
448
+ const idx = this.#stepsIndexOf(rawValue);
449
+ return idx < 0 ? 0 : parseFloat(((idx / total) * 100).toFixed(4));
450
+ }
301
451
  if (this.#range === 0) {
302
452
  return 0;
303
453
  }
@@ -315,21 +465,25 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
315
465
  const endStop = _a.#thumbPositionCSS(progressEnd);
316
466
  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
467
  }
468
+ // ─── Render ──────────────────────────────────────────────────────────────────
318
469
  render() {
319
470
  return html `${this.range ? this.#renderRange() : this.#renderSingle()}${this.#renderMinMaxLabels()}`;
320
471
  }
321
472
  #renderSingle() {
322
473
  const progress = this.progress;
474
+ const { nativeMin, nativeMax, nativeStep } = this.#nativeRangeAttrs;
475
+ // live() required: direct DOM writes during drag don't trigger a state change, so Lit won't re-sync without it.
476
+ const nativeValue = this.#stepsMode ? String(Math.max(0, this.#stepsIndexOf(this.#value))) : this.#value;
323
477
  return html `
324
478
  <div class="single-wrapper">
325
479
  <input
326
480
  aria-label="Slider value"
327
481
  style=${styleMap({ '--zui-slider-track-bg': this.#singleTrackBackground(progress) })}
328
482
  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}"
483
+ .min="${nativeMin}"
484
+ .max="${nativeMax}"
485
+ .step="${nativeStep}"
486
+ .value="${live(nativeValue)}"
333
487
  ?disabled="${this.disabled || this.readOnly}"
334
488
  @input="${this.#onInput}"
335
489
  @change="${this.#onChange}"
@@ -338,7 +492,7 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
338
492
  @focus="${this.#h.thumb.show}"
339
493
  @blur="${this.#h.thumb.hide}"
340
494
  />
341
- ${this.#renderFloatingInput(this.#value, this.#onThumbFloatingInput, this.#onThumbFloatingChange, 'thumb', this.#isVisible('thumb'), progress)}
495
+ ${this.#renderFloatingInput(this.#value, this.#onThumbFloatingChange, 'thumb', this.#isVisible('thumb'), progress)}
342
496
  ${this.#renderStepDots()}
343
497
  </div>
344
498
  `;
@@ -349,9 +503,9 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
349
503
  return html `
350
504
  <div class="range-wrapper" @click="${this.#onTrackClick}">
351
505
  ${this.#renderRangeInput('start', this.#rangeTrackBackground(progressStart, progressEnd))}
352
- ${this.#renderFloatingInput(this.#valueStart, this.#onStartThumbFloatingInput, this.#onStartThumbFloatingChange, 'startThumb', this.#isVisible('startThumb'), progressStart)}
506
+ ${this.#renderFloatingInput(this.#valueStart, this.#onStartThumbFloatingChange, 'startThumb', this.#isVisible('startThumb'), progressStart)}
353
507
  ${this.#renderRangeInput('end')}
354
- ${this.#renderFloatingInput(this.#valueEnd, this.#onEndThumbFloatingInput, this.#onEndThumbFloatingChange, 'endThumb', this.#isVisible('endThumb'), progressEnd)}
508
+ ${this.#renderFloatingInput(this.#valueEnd, this.#onEndThumbFloatingChange, 'endThumb', this.#isVisible('endThumb'), progressEnd)}
355
509
  ${this.#renderStepDots()}
356
510
  </div>
357
511
  `;
@@ -361,19 +515,19 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
361
515
  const val = which === 'start' ? this.#valueStart : this.#valueEnd;
362
516
  const onInput = which === 'start' ? this.#onRangeStartInput : this.#onRangeEndInput;
363
517
  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.
518
+ const { nativeMin, nativeMax, nativeStep } = this.#nativeRangeAttrs;
519
+ const nativeValue = this.#stepsMode ? String(Math.max(0, this.#stepsIndexOf(val))) : val;
520
+ // live() required: direct DOM writes during drag don't trigger a state change, so Lit won't re-sync without it.
367
521
  return html `
368
522
  <input
369
523
  aria-label="${which === 'start' ? 'Range start' : 'Range end'}"
370
524
  class="range-${which}"
371
525
  type="range"
372
526
  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)}"
527
+ .min="${nativeMin}"
528
+ .max="${nativeMax}"
529
+ .step="${nativeStep}"
530
+ .value="${live(nativeValue)}"
377
531
  ?disabled="${this.disabled || this.readOnly}"
378
532
  @input="${onInput}"
379
533
  @change="${this.#onRangeChange}"
@@ -384,11 +538,11 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
384
538
  />
385
539
  `;
386
540
  }
387
- #renderFloatingInput(val, onInput, onFloatingChange, flag, visible, progress) {
541
+ #renderFloatingInput(val, onFloatingChange, flag, visible, progress) {
388
542
  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.
543
+ // type="text" in steps mode to allow label and stepParser input.
544
+ // live() required: commits that snap/clamp to the current value skip reactive updates, so Lit won't re-sync without it.
545
+ const ariaLabel = flag === 'startThumb' ? 'Range start value' : flag === 'endThumb' ? 'Range end value' : 'Slider value';
392
546
  return html `
393
547
  <div
394
548
  class=${classMap({ 'thumb-input': true, 'thumb-input--visible': visible })}
@@ -397,19 +551,15 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
397
551
  @pointerleave="${h.hide}"
398
552
  >
399
553
  <input
400
- aria-label="${flag === 'startThumb'
401
- ? 'Range start value'
402
- : flag === 'endThumb'
403
- ? 'Range end value'
404
- : 'Slider value'}"
405
- type="number"
554
+ aria-label="${ariaLabel}"
555
+ type="${this.#stepsMode ? 'text' : 'number'}"
406
556
  .value="${live(val)}"
407
- .min="${String(this.min)}"
408
- .max="${String(this.max)}"
409
- .step="${this.step > 0 ? String(this.step) : '1'}"
557
+ .min="${this.#stepsMode ? '' : String(this.min)}"
558
+ .max="${this.#stepsMode ? '' : String(this.max)}"
559
+ .step="${this.#stepsMode ? '' : this.step > 0 ? String(this.step) : '1'}"
410
560
  ?disabled="${this.disabled}"
411
561
  ?readonly="${this.readOnly}"
412
- @input="${onInput}"
562
+ @keydown="${this.#onFloatingInputKeydown}"
413
563
  @change="${onFloatingChange}"
414
564
  @focus="${h.focus}"
415
565
  @blur="${h.blur}"
@@ -418,6 +568,28 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
418
568
  `;
419
569
  }
420
570
  #renderStepDots() {
571
+ if (this.#stepsMode) {
572
+ const normalized = this.#normalizedSteps;
573
+ const total = normalized.length - 1;
574
+ if (total <= 0 || normalized.length > 100) {
575
+ return nothing;
576
+ }
577
+ const stepDots = normalized.map((step, i) => {
578
+ const left = i === 0
579
+ ? 'var(--zui-slider-thumb-size)'
580
+ : i === total
581
+ ? 'calc(100% - var(--zui-slider-thumb-size))'
582
+ : _a.#thumbPositionCSS((i / total) * 100);
583
+ return html `<span
584
+ class=${classMap({ 'step-dot': true, 'step-dot--last': i === total && this.showStepLabels })}
585
+ style="left: ${left}"
586
+ ></span>
587
+ ${this.showStepLabels
588
+ ? html `<span class="step-dot-label" style="left: ${left}">${step.label}</span>`
589
+ : nothing}`;
590
+ });
591
+ return html `<div class="step-dots">${stepDots}</div>`;
592
+ }
421
593
  if (this.step <= 0 || this.#range === 0) {
422
594
  return nothing;
423
595
  }
@@ -443,21 +615,33 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
443
615
  return html `<div class="step-dots">${dots}</div>`;
444
616
  }
445
617
  #renderMinMaxLabels() {
446
- if (!this.showMinMax) {
447
- return nothing;
448
- }
618
+ const normalized = this.#normalizedSteps;
619
+ const minLabel = this.#stepsMode ? normalized[0]?.label ?? '' : String(this.min);
620
+ const maxLabel = this.#stepsMode ? normalized[normalized.length - 1]?.label ?? '' : String(this.max);
621
+ const hidden = this.showStepLabels && this.#stepsMode;
449
622
  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>
623
+ <div
624
+ class="min-max-labels"
625
+ aria-hidden="${ifDefined(hidden ? 'true' : undefined)}"
626
+ style=${hidden ? styleMap({ visibility: 'hidden' }) : nothing}
627
+ >
628
+ <span class="min-max-label">${minLabel}</span>
629
+ <span class="min-max-label">${maxLabel}</span>
453
630
  </div>
454
631
  `;
455
632
  }
633
+ // ─── Event handlers ──────────────────────────────────────────────────────────
456
634
  #onInput(e) {
457
635
  if (this.readOnly) {
458
636
  return;
459
637
  }
460
- this.value = e.target.value;
638
+ const input = e.target;
639
+ if (this.#stepsMode) {
640
+ this.value = this.#stepAt(parseInt(input.value, 10));
641
+ }
642
+ else {
643
+ this.value = input.value;
644
+ }
461
645
  }
462
646
  #onChange() {
463
647
  this.dispatchEvent(new CustomEvent('change', { detail: this.#value, bubbles: true }));
@@ -468,6 +652,15 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
468
652
  const clamped = Math.min(this.max, Math.max(this.min, stepped));
469
653
  return stepDecimals > 0 ? clamped.toFixed(stepDecimals) : String(Math.round(clamped));
470
654
  }
655
+ #currentValueForFlag(flag) {
656
+ if (flag === 'startThumb') {
657
+ return this.#valueStart;
658
+ }
659
+ if (flag === 'endThumb') {
660
+ return this.#valueEnd;
661
+ }
662
+ return this.#value;
663
+ }
471
664
  #makeFloatingChange(flag, setter, dispatch) {
472
665
  return (e) => {
473
666
  if (this.readOnly) {
@@ -475,28 +668,30 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
475
668
  }
476
669
  const input = e.target;
477
670
  if (input.value === '') {
671
+ input.value = this.#currentValueForFlag(flag);
478
672
  return;
479
673
  }
480
- const entry = this.#thumbInputState.get(flag);
481
- clearTimeout(entry.debounceTimer);
482
- entry.debounceTimer = undefined;
483
- setter(this.#processFloatingValue(parseFloat(input.value)));
484
- dispatch();
485
- };
486
- }
487
- #onFloatingInput(flag, setter) {
488
- return (e) => {
489
- if (this.readOnly) {
490
- return;
674
+ const before = this.#currentValueForFlag(flag);
675
+ if (this.#stepsMode) {
676
+ const resolved = this.#resolveFloatingInput(input.value);
677
+ if (resolved !== null) {
678
+ setter(resolved);
679
+ this.requestUpdate();
680
+ if (this.#currentValueForFlag(flag) !== before) {
681
+ dispatch();
682
+ }
683
+ }
684
+ else {
685
+ input.value = this.#currentValueForFlag(flag);
686
+ }
491
687
  }
492
- const input = e.target;
493
- if (input.value === '') {
494
- return;
688
+ else {
689
+ setter(this.#processFloatingValue(parseFloat(input.value)));
690
+ this.requestUpdate();
691
+ if (this.#currentValueForFlag(flag) !== before) {
692
+ dispatch();
693
+ }
495
694
  }
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);
500
695
  };
501
696
  }
502
697
  #onRangeInput(which) {
@@ -505,19 +700,40 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
505
700
  return;
506
701
  }
507
702
  const input = e.target;
508
- if (which === 'start') {
509
- if (parseFloat(input.value) >= parseFloat(this.#valueEnd)) {
510
- input.value = this.#valueStart;
511
- return;
703
+ if (this.#stepsMode) {
704
+ const idx = parseInt(input.value, 10);
705
+ const startIdx = Math.max(0, this.#stepsIndexOf(this.#valueStart));
706
+ const endIdx = Math.max(0, this.#stepsIndexOf(this.#valueEnd));
707
+ if (which === 'start') {
708
+ if (idx >= endIdx) {
709
+ input.value = String(startIdx);
710
+ return;
711
+ }
712
+ this.valueStart = this.#stepAt(idx);
713
+ }
714
+ else {
715
+ if (idx <= startIdx) {
716
+ input.value = String(endIdx);
717
+ return;
718
+ }
719
+ this.valueEnd = this.#stepAt(idx);
512
720
  }
513
- this.valueStart = input.value;
514
721
  }
515
722
  else {
516
- if (parseFloat(input.value) <= parseFloat(this.#valueStart)) {
517
- input.value = this.#valueEnd;
518
- return;
723
+ if (which === 'start') {
724
+ if (parseFloat(input.value) >= parseFloat(this.#valueEnd)) {
725
+ input.value = this.#valueStart;
726
+ return;
727
+ }
728
+ this.valueStart = input.value;
729
+ }
730
+ else {
731
+ if (parseFloat(input.value) <= parseFloat(this.#valueStart)) {
732
+ input.value = this.#valueEnd;
733
+ return;
734
+ }
735
+ this.valueEnd = input.value;
519
736
  }
520
- this.valueEnd = input.value;
521
737
  }
522
738
  };
523
739
  }
@@ -538,9 +754,37 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
538
754
  const trackLeft = rect.left + thumbRadius;
539
755
  const effectiveWidth = rect.width - 2 * thumbRadius;
540
756
  const fraction = Math.max(0, Math.min(1, (e.clientX - trackLeft) / effectiveWidth));
757
+ if (this.#stepsMode) {
758
+ const total = this.#normalizedSteps.length - 1;
759
+ if (total <= 0) {
760
+ return;
761
+ }
762
+ const clickedIdx = Math.round(fraction * total);
763
+ const startIdx = Math.max(0, this.#stepsIndexOf(this.#valueStart));
764
+ const endIdx = Math.max(0, this.#stepsIndexOf(this.#valueEnd));
765
+ // Ignore clicks within a thumb's hit area
766
+ const startX = trackLeft + (startIdx / total) * effectiveWidth;
767
+ const endX = trackLeft + (endIdx / total) * effectiveWidth;
768
+ if (Math.abs(e.clientX - startX) <= thumbRadius || Math.abs(e.clientX - endX) <= thumbRadius) {
769
+ return;
770
+ }
771
+ // Move whichever thumb is closer by index distance; prefer start on a tie
772
+ if (Math.abs(clickedIdx - startIdx) <= Math.abs(clickedIdx - endIdx)) {
773
+ if (clickedIdx < endIdx) {
774
+ this.valueStart = this.#stepAt(clickedIdx);
775
+ this.#onRangeChange();
776
+ }
777
+ }
778
+ else {
779
+ if (clickedIdx > startIdx) {
780
+ this.valueEnd = this.#stepAt(clickedIdx);
781
+ this.#onRangeChange();
782
+ }
783
+ }
784
+ return;
785
+ }
541
786
  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.
787
+ // Ignore clicks within a thumb's hit area; those are native input interactions, not track clicks.
544
788
  const startX = trackLeft + (this.progressStart / 100) * effectiveWidth;
545
789
  const endX = trackLeft + (this.progressEnd / 100) * effectiveWidth;
546
790
  if (Math.abs(e.clientX - startX) <= thumbRadius || Math.abs(e.clientX - endX) <= thumbRadius) {
@@ -596,9 +840,7 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
596
840
  entry.focused = false;
597
841
  this.#scheduleHideThumbInput(flag);
598
842
  }
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).
843
+ // 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
844
  static #thumbCenterOffset(progress) {
603
845
  return 1.5 - (3 * progress) / 100;
604
846
  }
@@ -607,6 +849,17 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
607
849
  }
608
850
  }
609
851
  _a = ZuiSlider;
852
+ __decorate([
853
+ property({
854
+ converter: {
855
+ fromAttribute: (value) => (value ? value.split(',').map((s) => s.trim()) : []),
856
+ toAttribute: (value) => value.map((s) => (typeof s === 'object' ? s.label ?? String(s.value) : String(s))).join(','),
857
+ },
858
+ })
859
+ ], ZuiSlider.prototype, "steps", void 0);
860
+ __decorate([
861
+ property({ attribute: false })
862
+ ], ZuiSlider.prototype, "stepParser", void 0);
610
863
  __decorate([
611
864
  property()
612
865
  ], ZuiSlider.prototype, "value", null);
@@ -629,7 +882,7 @@ __decorate([
629
882
  property({ type: Number })
630
883
  ], ZuiSlider.prototype, "step", void 0);
631
884
  __decorate([
632
- property({ type: Boolean, attribute: 'show-min-max' })
633
- ], ZuiSlider.prototype, "showMinMax", void 0);
885
+ property({ type: Boolean, attribute: 'show-step-labels' })
886
+ ], ZuiSlider.prototype, "showStepLabels", void 0);
634
887
  window.customElements.define('zui-slider', ZuiSlider);
635
888
  //# sourceMappingURL=zui-slider.js.map