@zywave/zui-slider 4.4.0-pre.4 → 4.4.0-pre.6

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.
@@ -97,17 +97,14 @@ suite('zui-slider', () => {
97
97
  assert.equal(element.value, '33.5');
98
98
  });
99
99
 
100
- test('float input position and track background update after drag', async () => {
100
+ test('float input position updates after drag', async () => {
101
101
  await element.updateComplete;
102
102
  const input = element.shadowRoot!.querySelector<HTMLInputElement>('input[type="range"]')!;
103
103
  const thumbDiv = element.shadowRoot!.querySelector<HTMLElement>('.thumb-input')!;
104
- const bgBefore = input.style.getPropertyValue('--zui-slider-track-bg');
105
104
  input.value = '75';
106
105
  input.dispatchEvent(new Event('input'));
107
106
  await element.updateComplete;
108
107
  assert.include(thumbDiv.style.left, '75%');
109
- assert.notEqual(input.style.getPropertyValue('--zui-slider-track-bg'), bgBefore);
110
- assert.include(input.style.getPropertyValue('--zui-slider-track-bg'), '75%');
111
108
  });
112
109
 
113
110
  test('floating input change fires component change event', async () => {
@@ -127,7 +124,7 @@ suite('zui-slider', () => {
127
124
  test('valueAsNumber reads the single-mode backing field regardless of range mode', () => {
128
125
  element.value = '35';
129
126
  element.range = true;
130
- // valueAsNumber reads #value (the single-mode backing field) range mode does not update it
127
+ // valueAsNumber reads #value (the single-mode backing field); range mode does not update it
131
128
  assert.equal(element.valueAsNumber, 35);
132
129
  });
133
130
 
@@ -196,26 +193,38 @@ suite('zui-slider', () => {
196
193
  assert.equal(new FormData(form).get(name), '50');
197
194
  });
198
195
 
199
- test('single mode input has inline linear-gradient background with transparent insets', async () => {
196
+ test('element.value, native range input, and floating input stay in sync after drag', async () => {
200
197
  await element.updateComplete;
201
- const input = element.shadowRoot!.querySelector<HTMLInputElement>('input[type="range"]')!;
202
- const bg = input.style.getPropertyValue('--zui-slider-track-bg');
203
- assert.include(bg, 'linear-gradient');
204
- assert.include(bg, 'transparent var(--zui-slider-thumb-size)');
205
- assert.include(bg, 'transparent calc(100% - var(--zui-slider-thumb-size))');
198
+ const rangeInput = element.shadowRoot!.querySelector<HTMLInputElement>('input[type="range"]')!;
199
+ const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input')!;
200
+
201
+ rangeInput.value = '75';
202
+ rangeInput.dispatchEvent(new Event('input'));
203
+ await element.updateComplete;
204
+
205
+ assert.equal(element.value, '75');
206
+ assert.equal(rangeInput.value, '75');
207
+ assert.equal(floatInput.value, '75');
206
208
  });
207
209
 
208
- test('single mode gradient uses gray when disabled and reverts to blue when re-enabled', async () => {
209
- element.disabled = true;
210
+ test('programmatic value reset after drag keeps native range input in sync', async () => {
211
+ // Reproduces a live() omission: user drags while a programmatic set is pending,
212
+ // causing Lit to skip the DOM write because its stored virtual still matches.
213
+ element.value = '50';
210
214
  await element.updateComplete;
211
215
  const input = element.shadowRoot!.querySelector<HTMLInputElement>('input[type="range"]')!;
212
- assert.include(input.style.getPropertyValue('--zui-slider-track-bg'), 'var(--zui-gray)');
213
- assert.notInclude(input.style.getPropertyValue('--zui-slider-track-bg'), 'var(--zui-blue)');
214
216
 
215
- element.disabled = false;
216
- await element.updateComplete;
217
- assert.include(input.style.getPropertyValue('--zui-slider-track-bg'), 'var(--zui-blue)');
218
- assert.notInclude(input.style.getPropertyValue('--zui-slider-track-bg'), 'var(--zui-gray)');
217
+ // Simulate drag: browser writes directly to DOM, then fires input event
218
+ input.value = '75';
219
+ input.dispatchEvent(new Event('input'));
220
+
221
+ // Before that render fires, programmatically revert. This is the live() failure case.
222
+ element.value = '50'; // Lit virtual is still '50' from the last render
223
+
224
+ await element.updateComplete; // without live(), Lit sees virtual='50'==new='50' → skips DOM write
225
+
226
+ assert.equal(input.value, '50');
227
+ assert.equal(element.value, '50');
219
228
  });
220
229
 
221
230
  test('form reset hides visible floating input', async () => {
@@ -268,7 +277,7 @@ suite('zui-slider', () => {
268
277
  });
269
278
 
270
279
  test('connectedCallback with range property (not attribute) uses range branch', async () => {
271
- // el.range = true without setAttribute connectedCallback must check this.range too.
280
+ // el.range = true without setAttribute; connectedCallback must check this.range too.
272
281
  // Must await so the async connectedCallback reads native inputs and calls _setFormValue.
273
282
  const el = document.createElement('zui-slider') as ZuiSlider;
274
283
  el.range = true;
@@ -303,56 +312,6 @@ suite('zui-slider', () => {
303
312
  document.body.removeChild(f);
304
313
  });
305
314
 
306
- test('floating input updates value after debounce delay but not immediately', async () => {
307
- await element.updateComplete;
308
- element.value = '50';
309
- const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input[type="number"]')!;
310
- floatInput.value = '75';
311
- floatInput.dispatchEvent(new Event('input'));
312
- // Debounce is 500ms — value must not update synchronously
313
- assert.equal(element.value, '50');
314
- await new Promise<void>((r) => setTimeout(r, 600));
315
- assert.equal(element.value, '75');
316
- });
317
-
318
- test('form reset cancels pending floating input debounce', async () => {
319
- await element.updateComplete;
320
- const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input[type="number"]')!;
321
- floatInput.value = '80';
322
- floatInput.dispatchEvent(new Event('input'));
323
- // Debounce is pending — reset before it fires
324
- form.reset();
325
- await new Promise<void>((r) => setTimeout(r, 600));
326
- // Debounce timer must have been cancelled by formResetCallback
327
- assert.equal(element.value, '50');
328
- });
329
-
330
- test('floating input empty-string input does not trigger value update', async () => {
331
- await element.updateComplete;
332
- element.value = '50';
333
- const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input[type="number"]')!;
334
- floatInput.value = '';
335
- floatInput.dispatchEvent(new Event('input'));
336
- // Empty string is an in-progress edit — value must not be updated
337
- assert.equal(element.value, '50');
338
- });
339
-
340
- test('clearing floating input cancels pending debounce so stale value is not committed', async () => {
341
- await element.updateComplete;
342
- element.value = '50';
343
- const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input[type="number"]')!;
344
- // Start a debounce for '75'
345
- floatInput.value = '75';
346
- floatInput.dispatchEvent(new Event('input'));
347
- assert.equal(element.value, '50'); // debounce not yet fired
348
- // Then clear the field — should cancel the '75' debounce
349
- floatInput.value = '';
350
- floatInput.dispatchEvent(new Event('input'));
351
- await new Promise<void>((r) => setTimeout(r, 350));
352
- // The pending debounce was cancelled; value stays at '50'
353
- assert.equal(element.value, '50');
354
- });
355
-
356
315
  test('committing empty floating input reverts display to current value', async () => {
357
316
  await element.updateComplete;
358
317
  element.value = '75';
@@ -363,51 +322,103 @@ suite('zui-slider', () => {
363
322
  assert.equal(floatInput.value, '75');
364
323
  });
365
324
 
366
- test('floating input clamps out-of-bounds values to min/max after debounce', async () => {
325
+ test('floating input clamps out-of-bounds values to min/max on commit', async () => {
367
326
  await element.updateComplete;
368
327
  const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input[type="number"]')!;
369
328
  floatInput.value = '150';
370
- floatInput.dispatchEvent(new Event('input'));
371
- await new Promise<void>((r) => setTimeout(r, 600));
329
+ floatInput.dispatchEvent(new Event('change'));
372
330
  assert.equal(element.value, '100');
373
331
 
374
332
  element.min = 20;
375
333
  floatInput.value = '5';
376
- floatInput.dispatchEvent(new Event('input'));
377
- await new Promise<void>((r) => setTimeout(r, 600));
334
+ floatInput.dispatchEvent(new Event('change'));
378
335
  assert.equal(element.value, '20');
379
336
  });
380
337
 
381
- test('floating input snaps typed value to nearest step after debounce', async () => {
338
+ test('floating input snaps typed value to nearest step on commit', async () => {
382
339
  await element.updateComplete;
383
340
  element.step = 10;
384
341
  const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input[type="number"]')!;
385
342
  floatInput.value = '47';
386
- floatInput.dispatchEvent(new Event('input'));
387
- await new Promise<void>((r) => setTimeout(r, 600));
343
+ floatInput.dispatchEvent(new Event('change'));
388
344
  assert.equal(element.value, '50');
389
345
  });
390
346
 
391
- test('floating input change flushes debounce and dispatches correct value immediately', async () => {
347
+ test('floating input change dispatches correct value and does not re-fire if value is unchanged', async () => {
392
348
  await element.updateComplete;
349
+ let changeCount = 0;
393
350
  let detail: string | undefined;
394
351
  element.addEventListener('change', (e: Event) => {
352
+ changeCount++;
395
353
  detail = (e as CustomEvent<string>).detail;
396
354
  });
397
355
 
398
356
  const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input[type="number"]')!;
399
- // Simulate typing then immediately committing before the 300ms debounce settles
400
357
  floatInput.value = '75';
401
- floatInput.dispatchEvent(new Event('input'));
402
- assert.equal(element.value, '50'); // debounce not yet fired
403
- floatInput.dispatchEvent(new Event('change')); // commits immediately
358
+ floatInput.dispatchEvent(new Event('change'));
404
359
  assert.equal(element.value, '75');
405
360
  assert.equal(detail, '75');
406
- // Debounce timer was cleared — value must not update again after 500ms
407
- await new Promise<void>((r) => setTimeout(r, 600));
361
+ assert.equal(changeCount, 1);
362
+
363
+ // Committing the same value again must not fire another change event
364
+ floatInput.value = '75';
365
+ floatInput.dispatchEvent(new Event('change'));
366
+ assert.equal(changeCount, 1);
367
+ });
368
+
369
+ test('pressing Enter in floating input commits value and retains focus', async () => {
370
+ await element.updateComplete;
371
+ element.value = '50';
372
+ const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input[type="number"]')!;
373
+ let blurred = false;
374
+ floatInput.addEventListener('blur', () => (blurred = true));
375
+ floatInput.value = '75';
376
+ floatInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
377
+ assert.equal(element.value, '75');
378
+ assert.isFalse(blurred);
379
+ });
380
+
381
+ test('blurring floating input commits typed value', async () => {
382
+ await element.updateComplete;
383
+ element.value = '50';
384
+ const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input[type="number"]')!;
385
+ floatInput.value = '75';
386
+ floatInput.dispatchEvent(new Event('input'));
387
+ floatInput.dispatchEvent(new FocusEvent('blur'));
408
388
  assert.equal(element.value, '75');
409
389
  });
410
390
 
391
+ test('blurring floating input after Enter does not dispatch second change event', async () => {
392
+ await element.updateComplete;
393
+ element.value = '50';
394
+ let changeCount = 0;
395
+ element.addEventListener('change', () => changeCount++);
396
+ const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input[type="number"]')!;
397
+ floatInput.value = '75';
398
+ floatInput.dispatchEvent(new Event('input'));
399
+ floatInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
400
+ floatInput.dispatchEvent(new FocusEvent('blur'));
401
+ assert.equal(element.value, '75');
402
+ assert.equal(changeCount, 1);
403
+ });
404
+
405
+ test('typing after Enter in floating input re-enables blur commit', async () => {
406
+ await element.updateComplete;
407
+ element.value = '50';
408
+ let changeCount = 0;
409
+ element.addEventListener('change', () => changeCount++);
410
+ const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input[type="number"]')!;
411
+ floatInput.value = '75';
412
+ floatInput.dispatchEvent(new Event('input'));
413
+ floatInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
414
+ assert.equal(changeCount, 1);
415
+ floatInput.value = '80';
416
+ floatInput.dispatchEvent(new Event('input'));
417
+ floatInput.dispatchEvent(new FocusEvent('blur'));
418
+ assert.equal(element.value, '80');
419
+ assert.equal(changeCount, 2);
420
+ });
421
+
411
422
  test('focused floating input stays visible after pointerleave', async () => {
412
423
  await element.updateComplete;
413
424
  const rangeInput = element.shadowRoot!.querySelector<HTMLInputElement>('input[type="range"]')!;
@@ -437,7 +448,7 @@ suite('zui-slider', () => {
437
448
  await element.updateComplete;
438
449
  assert.isTrue(thumbInputDiv.classList.contains('thumb-input--visible'));
439
450
 
440
- // Blur without re-focusing #blurFloatingInput clears focused and schedules hide.
451
+ // Blur without re-focusing; #blurFloatingInput clears focused and schedules hide.
441
452
  // #scheduleHideThumbInput uses a real 100ms setTimeout, so wait past it before asserting.
442
453
  floatInput.dispatchEvent(new Event('blur'));
443
454
  rangeInput.dispatchEvent(new Event('pointerleave'));
@@ -455,7 +466,7 @@ suite('zui-slider', () => {
455
466
  await element.updateComplete;
456
467
  assert.isTrue(element.shadowRoot!.querySelector('.thumb-input')!.classList.contains('thumb-input--visible'));
457
468
 
458
- // Disconnect then reconnect disconnectedCallback should clear state
469
+ // Disconnect then reconnect; disconnectedCallback should clear state
459
470
  document.body.removeChild(form);
460
471
  document.body.appendChild(form);
461
472
  await element.updateComplete;
@@ -473,8 +484,7 @@ suite('zui-slider', () => {
473
484
  input.dispatchEvent(new Event('input'));
474
485
  assert.equal(element.value, '50');
475
486
  floatInput.value = '75';
476
- floatInput.dispatchEvent(new Event('input'));
477
- await new Promise<void>((r) => setTimeout(r, 350));
487
+ floatInput.dispatchEvent(new Event('change'));
478
488
  assert.equal(element.value, '50');
479
489
  });
480
490
 
@@ -540,7 +550,6 @@ suite('zui-slider min-max labels', () => {
540
550
  });
541
551
 
542
552
  test('min-max labels are hidden when showStepLabels is true', async () => {
543
- element.steps = ['Small', 'Medium', 'Large'];
544
553
  element.showStepLabels = true;
545
554
  await element.updateComplete;
546
555
  const labels = element.shadowRoot!.querySelector<HTMLElement>('.min-max-labels');
@@ -582,7 +591,6 @@ suite('zui-slider min-max labels', () => {
582
591
  });
583
592
 
584
593
  test('show-step-labels attribute hides min-max labels', async () => {
585
- element.steps = ['Small', 'Medium', 'Large'];
586
594
  element.setAttribute('show-step-labels', '');
587
595
  await element.updateComplete;
588
596
  const labels = element.shadowRoot!.querySelector<HTMLElement>('.min-max-labels');
@@ -807,61 +815,39 @@ suite('zui-slider range', () => {
807
815
  document.body.removeChild(f);
808
816
  });
809
817
 
810
- test('input handler rejects start dragging past end', async () => {
818
+ test('input handler rejects start dragging to or past end', async () => {
811
819
  await element.updateComplete;
812
820
  element.valueStart = '20';
813
821
  element.valueEnd = '60';
814
-
815
822
  const startInput = element.shadowRoot!.querySelector<HTMLInputElement>('input.range-start')!;
816
- startInput.value = '70';
817
- startInput.dispatchEvent(new Event('input'));
818
823
 
824
+ startInput.value = '70'; // past end
825
+ startInput.dispatchEvent(new Event('input'));
819
826
  assert.equal(element.valueStart, '20');
820
827
  assert.equal(element.valueEnd, '60');
821
828
  assert.equal(startInput.value, '20');
822
- });
823
829
 
824
- test('input handler rejects start dragging to equal end', async () => {
825
- await element.updateComplete;
826
- element.valueStart = '20';
827
- element.valueEnd = '60';
828
-
829
- const startInput = element.shadowRoot!.querySelector<HTMLInputElement>('input.range-start')!;
830
- startInput.value = '60';
830
+ startInput.value = '60'; // equal to end
831
831
  startInput.dispatchEvent(new Event('input'));
832
-
833
- // start >= end is rejected; input snaps back and state is unchanged
834
832
  assert.equal(element.valueStart, '20');
835
- assert.equal(element.valueEnd, '60');
836
833
  assert.equal(startInput.value, '20');
837
834
  });
838
835
 
839
- test('input handler rejects end dragging before start', async () => {
836
+ test('input handler rejects end dragging to or before start', async () => {
840
837
  await element.updateComplete;
841
838
  element.valueStart = '30';
842
839
  element.valueEnd = '70';
843
-
844
840
  const endInput = element.shadowRoot!.querySelector<HTMLInputElement>('input.range-end')!;
845
- endInput.value = '20';
846
- endInput.dispatchEvent(new Event('input'));
847
841
 
842
+ endInput.value = '20'; // before start
843
+ endInput.dispatchEvent(new Event('input'));
848
844
  assert.equal(element.valueEnd, '70');
849
845
  assert.equal(element.valueStart, '30');
850
846
  assert.equal(endInput.value, '70');
851
- });
852
-
853
- test('input handler rejects end dragging to equal start', async () => {
854
- await element.updateComplete;
855
- element.valueStart = '30';
856
- element.valueEnd = '70';
857
847
 
858
- const endInput = element.shadowRoot!.querySelector<HTMLInputElement>('input.range-end')!;
859
- endInput.value = '30';
848
+ endInput.value = '30'; // equal to start
860
849
  endInput.dispatchEvent(new Event('input'));
861
-
862
- // end <= start is rejected; input snaps back and state is unchanged
863
850
  assert.equal(element.valueEnd, '70');
864
- assert.equal(element.valueStart, '30');
865
851
  assert.equal(endInput.value, '70');
866
852
  });
867
853
 
@@ -892,20 +878,63 @@ suite('zui-slider range', () => {
892
878
  assert.equal(element.valueStart, '33.5');
893
879
  });
894
880
 
895
- test('range start float input position and wrapper background update after drag', async () => {
881
+ test('range start float input position updates after drag', async () => {
896
882
  await element.updateComplete;
897
883
  const startInput = element.shadowRoot!.querySelector<HTMLInputElement>('input.range-start')!;
898
884
  const thumbInputDivs = element.shadowRoot!.querySelectorAll<HTMLElement>('.thumb-input');
899
- const bgBefore = startInput.style.getPropertyValue('--zui-slider-track-bg');
900
885
  startInput.value = '30';
901
886
  startInput.dispatchEvent(new Event('input'));
902
887
  await element.updateComplete;
903
888
  assert.include(thumbInputDivs[0].style.left, '30%');
904
- assert.notEqual(startInput.style.getPropertyValue('--zui-slider-track-bg'), bgBefore);
905
- assert.include(startInput.style.getPropertyValue('--zui-slider-track-bg'), '30%');
906
889
  });
907
890
 
908
- test('change event fires with valueStart and valueEnd detail on range-start change', async () => {
891
+ test('native range inputs and floating inputs stay in sync with component values after drag', async () => {
892
+ await element.updateComplete;
893
+ const startInput = element.shadowRoot!.querySelector<HTMLInputElement>('input.range-start')!;
894
+ const endInput = element.shadowRoot!.querySelector<HTMLInputElement>('input.range-end')!;
895
+ const floatInputs = element.shadowRoot!.querySelectorAll<HTMLInputElement>('.thumb-input input');
896
+
897
+ startInput.value = '30';
898
+ startInput.dispatchEvent(new Event('input'));
899
+ endInput.value = '60';
900
+ endInput.dispatchEvent(new Event('input'));
901
+ await element.updateComplete;
902
+
903
+ assert.equal(element.valueStart, '30');
904
+ assert.equal(startInput.value, '30');
905
+ assert.equal(floatInputs[0].value, '30');
906
+ assert.equal(element.valueEnd, '60');
907
+ assert.equal(endInput.value, '60');
908
+ assert.equal(floatInputs[1].value, '60');
909
+ });
910
+
911
+ test('programmatic value resets after drag keep both native range inputs in sync via live()', async () => {
912
+ element.valueStart = '20';
913
+ element.valueEnd = '80';
914
+ await element.updateComplete;
915
+ const startInput = element.shadowRoot!.querySelector<HTMLInputElement>('input.range-start')!;
916
+ const endInput = element.shadowRoot!.querySelector<HTMLInputElement>('input.range-end')!;
917
+ const floatInputs = element.shadowRoot!.querySelectorAll<HTMLInputElement>('.thumb-input input');
918
+
919
+ startInput.value = '40';
920
+ startInput.dispatchEvent(new Event('input'));
921
+ element.valueStart = '20'; // Lit virtual is still '20'; live() must force the DOM write
922
+
923
+ endInput.value = '60';
924
+ endInput.dispatchEvent(new Event('input'));
925
+ element.valueEnd = '80'; // same live() invariant for end thumb
926
+
927
+ await element.updateComplete;
928
+
929
+ assert.equal(element.valueStart, '20');
930
+ assert.equal(startInput.value, '20');
931
+ assert.equal(floatInputs[0].value, '20');
932
+ assert.equal(element.valueEnd, '80');
933
+ assert.equal(endInput.value, '80');
934
+ assert.equal(floatInputs[1].value, '80');
935
+ });
936
+
937
+ test('change event fires with valueStart and valueEnd detail from both thumbs', async () => {
909
938
  await element.updateComplete;
910
939
  let detail: { valueStart: string; valueEnd: string } | undefined;
911
940
  element.addEventListener('change', (e: Event) => {
@@ -914,11 +943,15 @@ suite('zui-slider range', () => {
914
943
 
915
944
  element.valueStart = '10';
916
945
  element.valueEnd = '40';
917
-
918
946
  const startInput = element.shadowRoot!.querySelector<HTMLInputElement>('input.range-start')!;
919
947
  startInput.dispatchEvent(new Event('change'));
920
-
921
948
  assert.deepEqual(detail, { valueStart: '10', valueEnd: '40' });
949
+
950
+ element.valueStart = '15';
951
+ element.valueEnd = '55';
952
+ const endInput = element.shadowRoot!.querySelector<HTMLInputElement>('input.range-end')!;
953
+ endInput.dispatchEvent(new Event('change'));
954
+ assert.deepEqual(detail, { valueStart: '15', valueEnd: '55' });
922
955
  });
923
956
 
924
957
  test('range change event bubbles', async () => {
@@ -932,22 +965,6 @@ suite('zui-slider range', () => {
932
965
  assert.isTrue(bubbled);
933
966
  });
934
967
 
935
- test('change event fires with valueStart and valueEnd detail on range-end change', async () => {
936
- await element.updateComplete;
937
- let detail: { valueStart: string; valueEnd: string } | undefined;
938
- element.addEventListener('change', (e: Event) => {
939
- detail = (e as CustomEvent).detail;
940
- });
941
-
942
- element.valueStart = '15';
943
- element.valueEnd = '55';
944
-
945
- const endInput = element.shadowRoot!.querySelector<HTMLInputElement>('input.range-end')!;
946
- endInput.dispatchEvent(new Event('change'));
947
-
948
- assert.deepEqual(detail, { valueStart: '15', valueEnd: '55' });
949
- });
950
-
951
968
  test('floating input change fires component range change event', async () => {
952
969
  await element.updateComplete;
953
970
  let detail: { valueStart: string; valueEnd: string } | undefined;
@@ -962,19 +979,6 @@ suite('zui-slider range', () => {
962
979
  assert.deepEqual(detail, { valueStart: '20', valueEnd: '100' });
963
980
  });
964
981
 
965
- test('range-wrapper gradient uses gray when disabled and reverts to blue when re-enabled', async () => {
966
- element.disabled = true;
967
- await element.updateComplete;
968
- const startInput = element.shadowRoot!.querySelector<HTMLInputElement>('input.range-start')!;
969
- assert.include(startInput.style.getPropertyValue('--zui-slider-track-bg'), 'var(--zui-gray)');
970
- assert.notInclude(startInput.style.getPropertyValue('--zui-slider-track-bg'), 'var(--zui-blue)');
971
-
972
- element.disabled = false;
973
- await element.updateComplete;
974
- assert.include(startInput.style.getPropertyValue('--zui-slider-track-bg'), 'var(--zui-blue)');
975
- assert.notInclude(startInput.style.getPropertyValue('--zui-slider-track-bg'), 'var(--zui-gray)');
976
- });
977
-
978
982
  test('form reset hides visible floating inputs in range mode', async () => {
979
983
  await element.updateComplete;
980
984
  const startInput = element.shadowRoot!.querySelector<HTMLInputElement>('input.range-start')!;
@@ -988,19 +992,6 @@ suite('zui-slider range', () => {
988
992
  thumbInputs.forEach((ti) => assert.notInclude(ti.className, 'thumb-input--visible'));
989
993
  });
990
994
 
991
- test('range floating input empty-string input does not trigger value update', async () => {
992
- await element.updateComplete;
993
- element.valueStart = '20';
994
- element.valueEnd = '80';
995
- const floatInputs = element.shadowRoot!.querySelectorAll<HTMLInputElement>('.thumb-input input[type="number"]');
996
- floatInputs[0].value = '';
997
- floatInputs[0].dispatchEvent(new Event('input'));
998
- floatInputs[1].value = '';
999
- floatInputs[1].dispatchEvent(new Event('input'));
1000
- assert.equal(element.valueStart, '20');
1001
- assert.equal(element.valueEnd, '80');
1002
- });
1003
-
1004
995
  test('range floating inputs hide when disabled is set while visible', async () => {
1005
996
  await element.updateComplete;
1006
997
  const startInput = element.shadowRoot!.querySelector<HTMLInputElement>('input.range-start')!;
@@ -1014,14 +1005,6 @@ suite('zui-slider range', () => {
1014
1005
  thumbInputs.forEach((ti) => assert.notInclude(ti.className, 'thumb-input--visible'));
1015
1006
  });
1016
1007
 
1017
- test('range-wrapper gradient insets are transparent outside thumb-size bounds', async () => {
1018
- await element.updateComplete;
1019
- const startInput = element.shadowRoot!.querySelector<HTMLInputElement>('input.range-start')!;
1020
- const bg = startInput.style.getPropertyValue('--zui-slider-track-bg');
1021
- assert.include(bg, 'transparent var(--zui-slider-thumb-size)');
1022
- assert.include(bg, 'transparent calc(100% - var(--zui-slider-thumb-size))');
1023
- });
1024
-
1025
1008
  test('disconnectedCallback clears timers and resets thumb input visibility in range mode', async () => {
1026
1009
  await element.updateComplete;
1027
1010
  const startInput = element.shadowRoot!.querySelector<HTMLInputElement>('input.range-start')!;
@@ -1038,45 +1021,6 @@ suite('zui-slider range', () => {
1038
1021
  thumbInputs.forEach((ti) => assert.notInclude(ti.className, 'thumb-input--visible'));
1039
1022
  });
1040
1023
 
1041
- test('range-wrapper gradient updates when valueStart and valueEnd change', async () => {
1042
- element.valueStart = '25';
1043
- element.valueEnd = '75';
1044
- await element.updateComplete;
1045
- const startInput = element.shadowRoot!.querySelector<HTMLInputElement>('input.range-start')!;
1046
- const bg25 = startInput.style.getPropertyValue('--zui-slider-track-bg');
1047
-
1048
- element.valueStart = '10';
1049
- element.valueEnd = '90';
1050
- await element.updateComplete;
1051
- const bg10 = startInput.style.getPropertyValue('--zui-slider-track-bg');
1052
-
1053
- assert.notEqual(bg25, bg10);
1054
- });
1055
-
1056
- test('range start floating input updates valueStart after debounce delay but not immediately', async () => {
1057
- await element.updateComplete;
1058
- element.valueStart = '20';
1059
- const floatInputs = element.shadowRoot!.querySelectorAll<HTMLInputElement>('.thumb-input input[type="number"]');
1060
- floatInputs[0].value = '40';
1061
- floatInputs[0].dispatchEvent(new Event('input'));
1062
- // Debounce is 500ms — valueStart must not update synchronously
1063
- assert.equal(element.valueStart, '20');
1064
- await new Promise<void>((r) => setTimeout(r, 600));
1065
- assert.equal(element.valueStart, '40');
1066
- });
1067
-
1068
- test('range end floating input updates valueEnd after debounce delay but not immediately', async () => {
1069
- await element.updateComplete;
1070
- element.valueEnd = '80';
1071
- const floatInputs = element.shadowRoot!.querySelectorAll<HTMLInputElement>('.thumb-input input[type="number"]');
1072
- floatInputs[1].value = '60';
1073
- floatInputs[1].dispatchEvent(new Event('input'));
1074
- // Debounce is 500ms — valueEnd must not update synchronously
1075
- assert.equal(element.valueEnd, '80');
1076
- await new Promise<void>((r) => setTimeout(r, 600));
1077
- assert.equal(element.valueEnd, '60');
1078
- });
1079
-
1080
1024
  test('focused range start floating input stays visible after pointerleave', async () => {
1081
1025
  await element.updateComplete;
1082
1026
  const startRangeInput = element.shadowRoot!.querySelector<HTMLInputElement>('input.range-start')!;
@@ -1094,32 +1038,33 @@ suite('zui-slider range', () => {
1094
1038
  assert.isTrue(thumbInputDivs[0].classList.contains('thumb-input--visible'));
1095
1039
  });
1096
1040
 
1097
- test('range floating inputs clamp out-of-bounds values to min/max after debounce', async () => {
1041
+ test('range floating inputs clamp out-of-bounds values to min/max on commit', async () => {
1098
1042
  await element.updateComplete;
1043
+ element.valueStart = '20';
1044
+ element.valueEnd = '80';
1099
1045
  const floatInputs = element.shadowRoot!.querySelectorAll<HTMLInputElement>('.thumb-input input[type="number"]');
1100
- floatInputs[0].value = '150';
1101
- floatInputs[0].dispatchEvent(new Event('input'));
1102
- await new Promise<void>((r) => setTimeout(r, 600));
1103
- assert.equal(element.valueStart, '100');
1104
1046
 
1105
- element.min = 20;
1106
- floatInputs[1].value = '5';
1107
- floatInputs[1].dispatchEvent(new Event('input'));
1108
- await new Promise<void>((r) => setTimeout(r, 600));
1109
- assert.equal(element.valueEnd, '20');
1047
+ // Start thumb: type below min clamped to min (0); 0 < endNum (80) → no nudge
1048
+ floatInputs[0].value = '-50';
1049
+ floatInputs[0].dispatchEvent(new Event('change'));
1050
+ assert.equal(element.valueStart, '0');
1051
+
1052
+ // End thumb: type above max → clamped to max (100); 100 > startNum (0) → no nudge
1053
+ floatInputs[1].value = '150';
1054
+ floatInputs[1].dispatchEvent(new Event('change'));
1055
+ assert.equal(element.valueEnd, '100');
1110
1056
  });
1111
1057
 
1112
- test('range floating input snaps typed valueStart to nearest step after debounce', async () => {
1058
+ test('range floating input snaps typed valueStart to nearest step on commit', async () => {
1113
1059
  await element.updateComplete;
1114
1060
  element.step = 10;
1115
1061
  const floatInputs = element.shadowRoot!.querySelectorAll<HTMLInputElement>('.thumb-input input[type="number"]');
1116
1062
  floatInputs[0].value = '23';
1117
- floatInputs[0].dispatchEvent(new Event('input'));
1118
- await new Promise<void>((r) => setTimeout(r, 600));
1063
+ floatInputs[0].dispatchEvent(new Event('change'));
1119
1064
  assert.equal(element.valueStart, '20');
1120
1065
  });
1121
1066
 
1122
- test('range start floating input change flushes debounce and dispatches correct value immediately', async () => {
1067
+ test('range start floating input change dispatches correct value', async () => {
1123
1068
  await element.updateComplete;
1124
1069
  let detail: { valueStart: string; valueEnd: string } | undefined;
1125
1070
  element.addEventListener('change', (e: Event) => {
@@ -1128,18 +1073,128 @@ suite('zui-slider range', () => {
1128
1073
 
1129
1074
  const floatInputs = element.shadowRoot!.querySelectorAll<HTMLInputElement>('.thumb-input input[type="number"]');
1130
1075
  floatInputs[0].value = '30';
1131
- floatInputs[0].dispatchEvent(new Event('input'));
1132
- assert.equal(element.valueStart, '0'); // debounce not yet fired
1133
- floatInputs[0].dispatchEvent(new Event('change')); // commits immediately
1076
+ floatInputs[0].dispatchEvent(new Event('change'));
1134
1077
  assert.equal(element.valueStart, '30');
1135
1078
  assert.deepEqual(detail, { valueStart: '30', valueEnd: '100' });
1136
- // Debounce timer was cleared — value must not update again after 500ms
1137
- await new Promise<void>((r) => setTimeout(r, 600));
1079
+ });
1080
+
1081
+ test('pressing Enter in range floating inputs commits values and retains focus', async () => {
1082
+ await element.updateComplete;
1083
+ const floatInputs = element.shadowRoot!.querySelectorAll<HTMLInputElement>('.thumb-input input[type="number"]');
1084
+ let startBlurred = false;
1085
+ floatInputs[0].addEventListener('blur', () => (startBlurred = true));
1086
+
1087
+ floatInputs[0].value = '30';
1088
+ floatInputs[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
1089
+ assert.equal(element.valueStart, '30');
1090
+ assert.isFalse(startBlurred);
1091
+
1092
+ floatInputs[1].value = '70';
1093
+ floatInputs[1].dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
1094
+ assert.equal(element.valueEnd, '70');
1095
+ });
1096
+
1097
+ test('range floating input change does not dispatch change event when value is unchanged', async () => {
1098
+ await element.updateComplete;
1099
+ element.valueStart = '30';
1100
+ element.valueEnd = '70';
1101
+ let changeCount = 0;
1102
+ element.addEventListener('change', () => changeCount++);
1103
+
1104
+ const floatInputs = element.shadowRoot!.querySelectorAll<HTMLInputElement>('.thumb-input input[type="number"]');
1105
+ floatInputs[0].value = '30';
1106
+ floatInputs[0].dispatchEvent(new Event('change'));
1107
+ floatInputs[1].value = '70';
1108
+ floatInputs[1].dispatchEvent(new Event('change'));
1109
+ assert.equal(changeCount, 0);
1110
+ });
1111
+
1112
+ test('range start floating input nudges to one step below end when typed value equals end', async () => {
1113
+ await element.updateComplete;
1114
+ element.valueStart = '20';
1115
+ element.valueEnd = '50';
1116
+ const floatInputs = element.shadowRoot!.querySelectorAll<HTMLInputElement>('.thumb-input input[type="number"]');
1117
+ floatInputs[0].value = '50';
1118
+ floatInputs[0].dispatchEvent(new Event('change'));
1119
+ // typed 50 = end 50; effectiveStep=1; nudge to 50-1=49 < 50 → committed as '49'
1120
+ assert.equal(element.valueStart, '49');
1121
+ assert.equal(element.valueEnd, '50');
1122
+ });
1123
+
1124
+ test('range end floating input nudges to one step above start when typed value equals start', async () => {
1125
+ await element.updateComplete;
1126
+ element.valueStart = '50';
1127
+ element.valueEnd = '80';
1128
+ const floatInputs = element.shadowRoot!.querySelectorAll<HTMLInputElement>('.thumb-input input[type="number"]');
1129
+ floatInputs[1].value = '50';
1130
+ floatInputs[1].dispatchEvent(new Event('change'));
1131
+ // typed 50 = start 50; effectiveStep=1; nudge to 50+1=51 > 50 → committed as '51'
1132
+ assert.equal(element.valueEnd, '51');
1133
+ assert.equal(element.valueStart, '50');
1134
+ });
1135
+
1136
+ test('range floating input nudge respects decimal step', async () => {
1137
+ element.step = 0.1;
1138
+ await element.updateComplete;
1139
+ element.valueStart = '1';
1140
+ element.valueEnd = '5';
1141
+ const floatInputs = element.shadowRoot!.querySelectorAll<HTMLInputElement>('.thumb-input input[type="number"]');
1142
+ floatInputs[1].value = '1';
1143
+ floatInputs[1].dispatchEvent(new Event('change'));
1144
+ // typed 1 = start 1; effectiveStep=0.1; nudge to 1+0.1=1.1 > 1 → committed as '1.1'
1145
+ assert.equal(element.valueEnd, '1.1');
1146
+ assert.equal(element.valueStart, '1');
1147
+ });
1148
+
1149
+ test('blurring range floating input commits typed value', async () => {
1150
+ await element.updateComplete;
1151
+ const floatInputs = element.shadowRoot!.querySelectorAll<HTMLInputElement>('.thumb-input input[type="number"]');
1152
+ floatInputs[0].value = '30';
1153
+ floatInputs[0].dispatchEvent(new Event('input'));
1154
+ floatInputs[0].dispatchEvent(new FocusEvent('blur'));
1155
+ assert.equal(element.valueStart, '30');
1156
+
1157
+ floatInputs[1].value = '70';
1158
+ floatInputs[1].dispatchEvent(new Event('input'));
1159
+ floatInputs[1].dispatchEvent(new FocusEvent('blur'));
1160
+ assert.equal(element.valueEnd, '70');
1161
+ });
1162
+
1163
+ test('blurring range floating input after Enter does not dispatch second change event', async () => {
1164
+ await element.updateComplete;
1165
+ element.valueStart = '20';
1166
+ element.valueEnd = '80';
1167
+ let changeCount = 0;
1168
+ element.addEventListener('change', () => changeCount++);
1169
+ const floatInputs = element.shadowRoot!.querySelectorAll<HTMLInputElement>('.thumb-input input[type="number"]');
1170
+ floatInputs[0].value = '30';
1171
+ floatInputs[0].dispatchEvent(new Event('input'));
1172
+ floatInputs[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
1173
+ floatInputs[0].dispatchEvent(new FocusEvent('blur'));
1138
1174
  assert.equal(element.valueStart, '30');
1175
+ assert.equal(changeCount, 1);
1176
+ });
1177
+
1178
+ test('typing after Enter in range floating input re-enables blur commit', async () => {
1179
+ await element.updateComplete;
1180
+ element.valueStart = '20';
1181
+ element.valueEnd = '80';
1182
+ let changeCount = 0;
1183
+ element.addEventListener('change', () => changeCount++);
1184
+ const floatInputs = element.shadowRoot!.querySelectorAll<HTMLInputElement>('.thumb-input input[type="number"]');
1185
+ floatInputs[0].value = '30';
1186
+ floatInputs[0].dispatchEvent(new Event('input'));
1187
+ floatInputs[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
1188
+ assert.equal(changeCount, 1);
1189
+ floatInputs[0].value = '40';
1190
+ floatInputs[0].dispatchEvent(new Event('input'));
1191
+ floatInputs[0].dispatchEvent(new FocusEvent('blur'));
1192
+ assert.equal(element.valueStart, '40');
1193
+ assert.equal(changeCount, 2);
1139
1194
  });
1140
1195
  });
1141
1196
 
1142
- // Dispatches a click at a given 01 fraction of the effective track, matching the
1197
+ // Dispatches a click at a given 0-1 fraction of the effective track, matching the
1143
1198
  // coordinate math in #onTrackClick so the component computes the same fraction back.
1144
1199
  function clickAtFraction(element: ZuiSlider, fraction: number): void {
1145
1200
  const wrapper = element.shadowRoot!.querySelector<HTMLElement>('.range-wrapper')!;
@@ -1247,6 +1302,48 @@ suite('zui-slider range track click', () => {
1247
1302
  assert.equal(element.valueStart, '40');
1248
1303
  assert.equal(element.valueEnd, '50');
1249
1304
  });
1305
+
1306
+ test('drag-end synthesized click on range-start input does not move range-end', async () => {
1307
+ // The browser synthesizes a click on the dragged input at mouseup; it must not reach #onTrackClick.
1308
+ element.valueStart = '5';
1309
+ element.valueEnd = '50';
1310
+ await element.updateComplete;
1311
+
1312
+ const wrapper = element.shadowRoot!.querySelector<HTMLElement>('.range-wrapper')!;
1313
+ const startInput = element.shadowRoot!.querySelector<HTMLInputElement>('input.range-start')!;
1314
+
1315
+ startInput.value = '80';
1316
+ startInput.dispatchEvent(new Event('input'));
1317
+ assert.equal(element.valueStart, '5');
1318
+ assert.equal(element.valueEnd, '50');
1319
+
1320
+ const rect = wrapper.getBoundingClientRect();
1321
+ const thumbRadius = 1.5 * parseFloat(getComputedStyle(element).getPropertyValue('--zui-slider-thumb-size'));
1322
+ const effectiveWidth = rect.width - 2 * thumbRadius;
1323
+ const clientX = rect.left + thumbRadius + 0.95 * effectiveWidth;
1324
+ startInput.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX }));
1325
+
1326
+ assert.equal(element.valueStart, '5');
1327
+ assert.equal(element.valueEnd, '50');
1328
+ });
1329
+
1330
+ test('clicking inside floating input does not move a thumb', async () => {
1331
+ // The floating input is inside .range-wrapper; its click bubbles to the track handler.
1332
+ element.valueStart = '20';
1333
+ element.valueEnd = '60';
1334
+ await element.updateComplete;
1335
+
1336
+ const wrapper = element.shadowRoot!.querySelector<HTMLElement>('.range-wrapper')!;
1337
+ const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input')!;
1338
+ const rect = wrapper.getBoundingClientRect();
1339
+ const thumbRadius = 1.5 * parseFloat(getComputedStyle(element).getPropertyValue('--zui-slider-thumb-size'));
1340
+ const effectiveWidth = rect.width - 2 * thumbRadius;
1341
+ const clientX = rect.left + thumbRadius + 0.9 * effectiveWidth;
1342
+ floatInput.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX }));
1343
+
1344
+ assert.equal(element.valueStart, '20');
1345
+ assert.equal(element.valueEnd, '60');
1346
+ });
1250
1347
  });
1251
1348
 
1252
1349
  suite('zui-slider step dots', () => {
@@ -1268,13 +1365,10 @@ suite('zui-slider step dots', () => {
1268
1365
  assert.equal(dots.length, 5); // 0, 25, 50, 75, 100
1269
1366
  });
1270
1367
 
1271
- test('step dots not rendered when step is 0', async () => {
1368
+ test('step dots not rendered when step is 0 or negative', async () => {
1272
1369
  element.step = 0;
1273
1370
  await element.updateComplete;
1274
1371
  assert.notExists(element.shadowRoot!.querySelector('.step-dots'));
1275
- });
1276
-
1277
- test('step dots not rendered when step is negative', async () => {
1278
1372
  element.step = -5;
1279
1373
  await element.updateComplete;
1280
1374
  assert.notExists(element.shadowRoot!.querySelector('.step-dots'));
@@ -1294,15 +1388,6 @@ suite('zui-slider step dots', () => {
1294
1388
  assert.notExists(element.shadowRoot!.querySelector('.step-dots'));
1295
1389
  });
1296
1390
 
1297
- test('step dot positions are correctly offset by thumb size', async () => {
1298
- await element.updateComplete;
1299
- const dots = element.shadowRoot!.querySelectorAll<HTMLElement>('.step-dot');
1300
- assert.equal(dots[0].style.left, 'var(--zui-slider-thumb-size)');
1301
- assert.equal(dots[dots.length - 1].style.left, 'calc(100% - var(--zui-slider-thumb-size))');
1302
- // pos=50: offset = 1.5 - (3*50)/100 = 0
1303
- assert.equal(dots[2].style.left, 'calc(50% + var(--zui-slider-thumb-size) * 0)');
1304
- });
1305
-
1306
1391
  test('step dots toggle when step is set to 0 then back', async () => {
1307
1392
  await element.updateComplete;
1308
1393
  assert.exists(element.shadowRoot!.querySelector('.step-dots'));
@@ -1351,6 +1436,7 @@ suite('zui-slider steps', () => {
1351
1436
  setup(() => {
1352
1437
  element = document.createElement('zui-slider') as ZuiSlider;
1353
1438
  form = buildForm({ enableSubmit: false, appendChildren: [element] });
1439
+ element.steps = ['Small', 'Medium', 'Large'];
1354
1440
  });
1355
1441
 
1356
1442
  teardown(() => {
@@ -1358,20 +1444,17 @@ suite('zui-slider steps', () => {
1358
1444
  });
1359
1445
 
1360
1446
  test('initializes value to first step when current value is not a step label', async () => {
1361
- element.steps = ['Small', 'Medium', 'Large'];
1362
1447
  await element.updateComplete;
1363
1448
  assert.equal(element.value, 'Small');
1364
1449
  });
1365
1450
 
1366
1451
  test('value set to a valid step label is preserved', async () => {
1367
- element.steps = ['Small', 'Medium', 'Large'];
1368
1452
  element.value = 'Medium';
1369
1453
  await element.updateComplete;
1370
1454
  assert.equal(element.value, 'Medium');
1371
1455
  });
1372
1456
 
1373
1457
  test('native range input has index-based min, max, and step attributes', async () => {
1374
- element.steps = ['Small', 'Medium', 'Large'];
1375
1458
  await element.updateComplete;
1376
1459
  const rangeInput = element.shadowRoot!.querySelector<HTMLInputElement>('input[type="range"]')!;
1377
1460
  assert.equal(rangeInput.min, '0');
@@ -1380,7 +1463,6 @@ suite('zui-slider steps', () => {
1380
1463
  });
1381
1464
 
1382
1465
  test('native range input value reflects current step index', async () => {
1383
- element.steps = ['Small', 'Medium', 'Large'];
1384
1466
  element.value = 'Medium';
1385
1467
  await element.updateComplete;
1386
1468
  const rangeInput = element.shadowRoot!.querySelector<HTMLInputElement>('input[type="range"]')!;
@@ -1388,7 +1470,6 @@ suite('zui-slider steps', () => {
1388
1470
  });
1389
1471
 
1390
1472
  test('dragging native range to index sets value to corresponding step label', async () => {
1391
- element.steps = ['Small', 'Medium', 'Large'];
1392
1473
  await element.updateComplete;
1393
1474
  const rangeInput = element.shadowRoot!.querySelector<HTMLInputElement>('input[type="range"]')!;
1394
1475
  rangeInput.value = '2';
@@ -1397,7 +1478,6 @@ suite('zui-slider steps', () => {
1397
1478
  });
1398
1479
 
1399
1480
  test('progress is computed by step index, not by numeric value', async () => {
1400
- element.steps = ['Small', 'Medium', 'Large'];
1401
1481
  element.value = 'Small';
1402
1482
  await element.updateComplete;
1403
1483
  assert.equal(element.progress, 0);
@@ -1408,14 +1488,12 @@ suite('zui-slider steps', () => {
1408
1488
  });
1409
1489
 
1410
1490
  test('floating input is type="text" in steps mode', async () => {
1411
- element.steps = ['Small', 'Medium', 'Large'];
1412
1491
  await element.updateComplete;
1413
1492
  const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input')!;
1414
1493
  assert.equal(floatInput.type, 'text');
1415
1494
  });
1416
1495
 
1417
1496
  test('floating input change to valid step label updates value immediately', async () => {
1418
- element.steps = ['Small', 'Medium', 'Large'];
1419
1497
  await element.updateComplete;
1420
1498
  const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input')!;
1421
1499
  floatInput.value = 'Large';
@@ -1423,8 +1501,20 @@ suite('zui-slider steps', () => {
1423
1501
  assert.equal(element.value, 'Large');
1424
1502
  });
1425
1503
 
1504
+ test('steps floating input change does not dispatch change event when value is unchanged', async () => {
1505
+ element.value = 'Medium';
1506
+ await element.updateComplete;
1507
+ let changeCount = 0;
1508
+ element.addEventListener('change', () => changeCount++);
1509
+
1510
+ const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input')!;
1511
+ floatInput.value = 'Medium';
1512
+ floatInput.dispatchEvent(new Event('change'));
1513
+ assert.equal(changeCount, 0);
1514
+ assert.equal(element.value, 'Medium');
1515
+ });
1516
+
1426
1517
  test('floating input change to invalid label reverts input to current value', async () => {
1427
- element.steps = ['Small', 'Medium', 'Large'];
1428
1518
  element.value = 'Medium';
1429
1519
  await element.updateComplete;
1430
1520
  const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input')!;
@@ -1434,15 +1524,13 @@ suite('zui-slider steps', () => {
1434
1524
  assert.equal(element.value, 'Medium');
1435
1525
  });
1436
1526
 
1437
- test('floating input debounce resolves typed numeric alias to nearest step', async () => {
1527
+ test('floating input resolves typed numeric alias to nearest step on commit', async () => {
1438
1528
  element.steps = [0, 25, 50, 75, 100];
1439
1529
  await element.updateComplete;
1440
- assert.equal(element.value, '50'); // '50' is a valid label not snapped on init
1530
+ assert.equal(element.value, '50'); // '50' is a valid label; not snapped on init
1441
1531
  const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input')!;
1442
1532
  floatInput.value = '30';
1443
- floatInput.dispatchEvent(new Event('input'));
1444
- assert.equal(element.value, '50'); // debounce not fired yet
1445
- await new Promise<void>((r) => setTimeout(r, 600));
1533
+ floatInput.dispatchEvent(new Event('change'));
1446
1534
  // |30-25|=5, |30-50|=20 → snaps to '25'
1447
1535
  assert.equal(element.value, '25');
1448
1536
  });
@@ -1460,13 +1548,11 @@ suite('zui-slider steps', () => {
1460
1548
  });
1461
1549
 
1462
1550
  test('step dots count equals steps.length', async () => {
1463
- element.steps = ['Small', 'Medium', 'Large'];
1464
1551
  await element.updateComplete;
1465
1552
  assert.equal(element.shadowRoot!.querySelectorAll('.step-dot').length, 3);
1466
1553
  });
1467
1554
 
1468
1555
  test('min-max labels show first and last step labels', async () => {
1469
- element.steps = ['Small', 'Medium', 'Large'];
1470
1556
  await element.updateComplete;
1471
1557
  const labels = element.shadowRoot!.querySelectorAll('.min-max-label');
1472
1558
  assert.equal(labels[0].textContent!.trim(), 'Small');
@@ -1486,7 +1572,6 @@ suite('zui-slider steps', () => {
1486
1572
  test('value is included in form submission as the step label', async () => {
1487
1573
  const name = randString();
1488
1574
  element.setAttribute('name', name);
1489
- element.steps = ['Small', 'Medium', 'Large'];
1490
1575
  element.value = 'Large';
1491
1576
  await element.updateComplete;
1492
1577
  assert.equal(new FormData(form).get(name), 'Large');
@@ -1505,7 +1590,6 @@ suite('zui-slider steps', () => {
1505
1590
  });
1506
1591
 
1507
1592
  test('showStepLabels renders a label element for each step', async () => {
1508
- element.steps = ['Small', 'Medium', 'Large'];
1509
1593
  element.showStepLabels = true;
1510
1594
  await element.updateComplete;
1511
1595
  const labels = element.shadowRoot!.querySelectorAll('.step-dot-label');
@@ -1526,30 +1610,19 @@ suite('zui-slider steps', () => {
1526
1610
  });
1527
1611
 
1528
1612
  test('showStepLabels false renders no label elements', async () => {
1529
- element.steps = ['Small', 'Medium', 'Large'];
1530
1613
  await element.updateComplete;
1531
1614
  assert.equal(element.shadowRoot!.querySelectorAll('.step-dot-label').length, 0);
1532
1615
  });
1533
1616
 
1534
- test('clearing steps floating input cancels pending debounce so stale step is not committed', async () => {
1535
- element.steps = [0, 25, 50, 75, 100];
1536
- element.value = '50';
1617
+ test('pressing Enter in steps floating input commits the step label', async () => {
1537
1618
  await element.updateComplete;
1538
1619
  const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input')!;
1539
- // Start a debounce for '80' (would snap to '75')
1540
- floatInput.value = '80';
1541
- floatInput.dispatchEvent(new Event('input'));
1542
- assert.equal(element.value, '50'); // debounce not yet fired
1543
- // Clear the field before debounce fires
1544
- floatInput.value = '';
1545
- floatInput.dispatchEvent(new Event('input'));
1546
- await new Promise<void>((r) => setTimeout(r, 600));
1547
- // The debounce was cancelled; value must stay at '50'
1548
- assert.equal(element.value, '50');
1620
+ floatInput.value = 'Large';
1621
+ floatInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
1622
+ assert.equal(element.value, 'Large');
1549
1623
  });
1550
1624
 
1551
1625
  test('committing empty steps floating input reverts display to current step label', async () => {
1552
- element.steps = ['Small', 'Medium', 'Large'];
1553
1626
  element.value = 'Medium';
1554
1627
  await element.updateComplete;
1555
1628
  const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input')!;
@@ -1699,6 +1772,30 @@ suite('zui-slider steps range', () => {
1699
1772
  assert.equal(element.valueStart, 'B');
1700
1773
  assert.equal(element.valueEnd, 'D');
1701
1774
  });
1775
+
1776
+ test('steps range end floating input nudges to next step when typed value matches start step', async () => {
1777
+ element.steps = ['A', 'B', 'C', 'D', 'E'];
1778
+ element.valueStart = 'C'; // idx=2
1779
+ element.valueEnd = 'E'; // idx=4
1780
+ await element.updateComplete;
1781
+ const floatInputs = element.shadowRoot!.querySelectorAll<HTMLInputElement>('.thumb-input input');
1782
+ floatInputs[1].value = 'C'; // typed = start ('C', idx=2); resolvedIdx(2) <= startIdx(2) → nudge to idx=3 ('D')
1783
+ floatInputs[1].dispatchEvent(new Event('change'));
1784
+ assert.equal(element.valueEnd, 'D');
1785
+ assert.equal(element.valueStart, 'C');
1786
+ });
1787
+
1788
+ test('steps range start floating input nudges to previous step when typed value matches end step', async () => {
1789
+ element.steps = ['A', 'B', 'C', 'D', 'E'];
1790
+ element.valueStart = 'A'; // idx=0
1791
+ element.valueEnd = 'C'; // idx=2
1792
+ await element.updateComplete;
1793
+ const floatInputs = element.shadowRoot!.querySelectorAll<HTMLInputElement>('.thumb-input input');
1794
+ floatInputs[0].value = 'C'; // typed = end ('C', idx=2); resolvedIdx(2) >= endIdx(2) → nudge to idx=1 ('B')
1795
+ floatInputs[0].dispatchEvent(new Event('change'));
1796
+ assert.equal(element.valueStart, 'B');
1797
+ assert.equal(element.valueEnd, 'C');
1798
+ });
1702
1799
  });
1703
1800
 
1704
1801
  suite('zui-slider stepParser', () => {
@@ -1727,7 +1824,7 @@ suite('zui-slider stepParser', () => {
1727
1824
  };
1728
1825
  await element.updateComplete;
1729
1826
  const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input')!;
1730
- floatInput.value = '2k'; // exact step label resolved before stepParser is consulted
1827
+ floatInput.value = '2k'; // exact step label; resolved before stepParser is consulted
1731
1828
  floatInput.dispatchEvent(new Event('change'));
1732
1829
  assert.equal(element.value, '2k');
1733
1830
  assert.equal(callCount, 0);
@@ -1767,23 +1864,6 @@ suite('zui-slider stepParser', () => {
1767
1864
  assert.equal(element.value, '2k');
1768
1865
  });
1769
1866
 
1770
- test('null return from debounced stepParser does not update value', async () => {
1771
- element.steps = [
1772
- { value: 1000, label: '1k' },
1773
- { value: 2000, label: '2k' },
1774
- { value: 3000, label: '3k' },
1775
- ];
1776
- element.stepParser = () => null;
1777
- await element.updateComplete;
1778
- // Default '50' snapped to '1k' on init
1779
- const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input')!;
1780
- floatInput.value = 'anything';
1781
- floatInput.dispatchEvent(new Event('input'));
1782
- await new Promise<void>((r) => setTimeout(r, 600));
1783
- // null → debounce does not call the setter → value stays at '1k'
1784
- assert.equal(element.value, '1k');
1785
- });
1786
-
1787
1867
  test('string return from stepParser is used directly as a step label', async () => {
1788
1868
  element.steps = [
1789
1869
  { value: 1000, label: '1k' },