@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.
@@ -30,7 +30,6 @@ suite('zui-slider', () => {
30
30
  assert.equal(element.max, 100);
31
31
  assert.equal(element.step, 0);
32
32
  assert.equal(element.disabled, false);
33
- assert.isFalse(element.showMinMax);
34
33
  assert.isFalse(element.range);
35
34
  });
36
35
 
@@ -98,17 +97,14 @@ suite('zui-slider', () => {
98
97
  assert.equal(element.value, '33.5');
99
98
  });
100
99
 
101
- test('float input position and track background update after drag', async () => {
100
+ test('float input position updates after drag', async () => {
102
101
  await element.updateComplete;
103
102
  const input = element.shadowRoot!.querySelector<HTMLInputElement>('input[type="range"]')!;
104
103
  const thumbDiv = element.shadowRoot!.querySelector<HTMLElement>('.thumb-input')!;
105
- const bgBefore = input.style.getPropertyValue('--zui-slider-track-bg');
106
104
  input.value = '75';
107
105
  input.dispatchEvent(new Event('input'));
108
106
  await element.updateComplete;
109
107
  assert.include(thumbDiv.style.left, '75%');
110
- assert.notEqual(input.style.getPropertyValue('--zui-slider-track-bg'), bgBefore);
111
- assert.include(input.style.getPropertyValue('--zui-slider-track-bg'), '75%');
112
108
  });
113
109
 
114
110
  test('floating input change fires component change event', async () => {
@@ -197,81 +193,38 @@ suite('zui-slider', () => {
197
193
  assert.equal(new FormData(form).get(name), '50');
198
194
  });
199
195
 
200
- test('native inputs use step="1" when step is 0 or negative', async () => {
201
- for (const s of [0, -5]) {
202
- element.step = s;
203
- await element.updateComplete;
204
- const input = element.shadowRoot!.querySelector<HTMLInputElement>('input[type="range"]')!;
205
- const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input[type="number"]')!;
206
- assert.equal(input.step, '1');
207
- assert.equal(floatInput.step, '1');
208
- }
209
- });
210
-
211
- test('native inputs have correct step attribute when step is set', async () => {
212
- element.step = 25;
196
+ test('element.value, native range input, and floating input stay in sync after drag', async () => {
213
197
  await element.updateComplete;
214
- const input = element.shadowRoot!.querySelector<HTMLInputElement>('input[type="range"]')!;
215
- const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input[type="number"]')!;
216
- assert.equal(input.step, '25');
217
- assert.equal(floatInput.step, '25');
218
- });
198
+ const rangeInput = element.shadowRoot!.querySelector<HTMLInputElement>('input[type="range"]')!;
199
+ const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input')!;
219
200
 
220
- test('native inputs reflect min and max properties in DOM', async () => {
221
- element.min = 10;
222
- element.max = 90;
201
+ rangeInput.value = '75';
202
+ rangeInput.dispatchEvent(new Event('input'));
223
203
  await element.updateComplete;
224
- const input = element.shadowRoot!.querySelector<HTMLInputElement>('input[type="range"]')!;
225
- const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input[type="number"]')!;
226
- assert.equal(input.min, '10');
227
- assert.equal(input.max, '90');
228
- assert.equal(floatInput.min, '10');
229
- assert.equal(floatInput.max, '90');
230
- });
231
204
 
232
- test('single mode input has inline linear-gradient background with transparent insets', async () => {
233
- await element.updateComplete;
234
- const input = element.shadowRoot!.querySelector<HTMLInputElement>('input[type="range"]')!;
235
- const bg = input.style.getPropertyValue('--zui-slider-track-bg');
236
- assert.include(bg, 'linear-gradient');
237
- assert.include(bg, 'transparent var(--zui-slider-thumb-size)');
238
- assert.include(bg, 'transparent calc(100% - var(--zui-slider-thumb-size))');
205
+ assert.equal(element.value, '75');
206
+ assert.equal(rangeInput.value, '75');
207
+ assert.equal(floatInput.value, '75');
239
208
  });
240
209
 
241
- test('single mode gradient uses gray when disabled and reverts to blue when re-enabled', async () => {
242
- 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';
243
214
  await element.updateComplete;
244
215
  const input = element.shadowRoot!.querySelector<HTMLInputElement>('input[type="range"]')!;
245
- assert.include(input.style.getPropertyValue('--zui-slider-track-bg'), 'var(--zui-gray)');
246
- assert.notInclude(input.style.getPropertyValue('--zui-slider-track-bg'), 'var(--zui-blue)');
247
216
 
248
- element.disabled = false;
249
- await element.updateComplete;
250
- assert.include(input.style.getPropertyValue('--zui-slider-track-bg'), 'var(--zui-blue)');
251
- assert.notInclude(input.style.getPropertyValue('--zui-slider-track-bg'), 'var(--zui-gray)');
252
- });
217
+ // Simulate drag: browser writes directly to DOM, then fires input event
218
+ input.value = '75';
219
+ input.dispatchEvent(new Event('input'));
253
220
 
254
- test('single mode gradient progress stop shifts when value changes', async () => {
255
- // At value=50 (progress=50): offset = 1.5 - (3*50)/100 = 0
256
- element.value = '50';
257
- await element.updateComplete;
258
- const input = element.shadowRoot!.querySelector<HTMLInputElement>('input[type="range"]')!;
259
- assert.include(
260
- input.style.getPropertyValue('--zui-slider-track-bg'),
261
- 'calc(50% + var(--zui-slider-thumb-size) * 0)'
262
- );
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
263
223
 
264
- // At value=75 (progress=75): offset = 1.5 - (3*75)/100 = -0.75
265
- element.value = '75';
266
- await element.updateComplete;
267
- assert.include(
268
- input.style.getPropertyValue('--zui-slider-track-bg'),
269
- 'calc(75% + var(--zui-slider-thumb-size) * -0.75)'
270
- );
271
- assert.notInclude(
272
- input.style.getPropertyValue('--zui-slider-track-bg'),
273
- 'calc(50% + var(--zui-slider-thumb-size) * 0)'
274
- );
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');
275
228
  });
276
229
 
277
230
  test('form reset hides visible floating input', async () => {
@@ -359,82 +312,66 @@ suite('zui-slider', () => {
359
312
  document.body.removeChild(f);
360
313
  });
361
314
 
362
- test('floating input updates value after debounce delay but not immediately', async () => {
363
- await element.updateComplete;
364
- element.value = '50';
365
- const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input[type="number"]')!;
366
- floatInput.value = '75';
367
- floatInput.dispatchEvent(new Event('input'));
368
- // Debounce is 300ms — value must not update synchronously
369
- assert.equal(element.value, '50');
370
- await new Promise<void>((r) => setTimeout(r, 350));
371
- assert.equal(element.value, '75');
372
- });
373
-
374
- test('form reset cancels pending floating input debounce', async () => {
315
+ test('committing empty floating input reverts display to current value', async () => {
375
316
  await element.updateComplete;
376
- const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input[type="number"]')!;
377
- floatInput.value = '80';
378
- floatInput.dispatchEvent(new Event('input'));
379
- // Debounce is pending — reset before it fires
380
- form.reset();
381
- await new Promise<void>((r) => setTimeout(r, 350));
382
- // Debounce timer must have been cancelled by formResetCallback
383
- assert.equal(element.value, '50');
384
- });
385
-
386
- test('floating input empty-string input does not trigger value update', async () => {
387
- await element.updateComplete;
388
- element.value = '50';
317
+ element.value = '75';
389
318
  const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input[type="number"]')!;
390
319
  floatInput.value = '';
391
- floatInput.dispatchEvent(new Event('input'));
392
- // Empty string is an in-progress edit — value must not be updated
393
- assert.equal(element.value, '50');
320
+ floatInput.dispatchEvent(new Event('change'));
321
+ assert.equal(element.value, '75');
322
+ assert.equal(floatInput.value, '75');
394
323
  });
395
324
 
396
- 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 () => {
397
326
  await element.updateComplete;
398
327
  const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input[type="number"]')!;
399
328
  floatInput.value = '150';
400
- floatInput.dispatchEvent(new Event('input'));
401
- await new Promise<void>((r) => setTimeout(r, 350));
329
+ floatInput.dispatchEvent(new Event('change'));
402
330
  assert.equal(element.value, '100');
403
331
 
404
332
  element.min = 20;
405
333
  floatInput.value = '5';
406
- floatInput.dispatchEvent(new Event('input'));
407
- await new Promise<void>((r) => setTimeout(r, 350));
334
+ floatInput.dispatchEvent(new Event('change'));
408
335
  assert.equal(element.value, '20');
409
336
  });
410
337
 
411
- 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 () => {
412
339
  await element.updateComplete;
413
340
  element.step = 10;
414
341
  const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input[type="number"]')!;
415
342
  floatInput.value = '47';
416
- floatInput.dispatchEvent(new Event('input'));
417
- await new Promise<void>((r) => setTimeout(r, 350));
343
+ floatInput.dispatchEvent(new Event('change'));
418
344
  assert.equal(element.value, '50');
419
345
  });
420
346
 
421
- 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 () => {
422
348
  await element.updateComplete;
349
+ let changeCount = 0;
423
350
  let detail: string | undefined;
424
351
  element.addEventListener('change', (e: Event) => {
352
+ changeCount++;
425
353
  detail = (e as CustomEvent<string>).detail;
426
354
  });
427
355
 
428
356
  const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input[type="number"]')!;
429
- // Simulate typing then immediately committing before the 300ms debounce settles
430
357
  floatInput.value = '75';
431
- floatInput.dispatchEvent(new Event('input'));
432
- assert.equal(element.value, '50'); // debounce not yet fired
433
- floatInput.dispatchEvent(new Event('change')); // commits immediately
358
+ floatInput.dispatchEvent(new Event('change'));
434
359
  assert.equal(element.value, '75');
435
360
  assert.equal(detail, '75');
436
- // Debounce timer was cleared — value must not update again after 300ms
437
- await new Promise<void>((r) => setTimeout(r, 350));
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 without waiting for blur', async () => {
370
+ await element.updateComplete;
371
+ element.value = '50';
372
+ const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input[type="number"]')!;
373
+ floatInput.value = '75';
374
+ floatInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
438
375
  assert.equal(element.value, '75');
439
376
  });
440
377
 
@@ -503,8 +440,7 @@ suite('zui-slider', () => {
503
440
  input.dispatchEvent(new Event('input'));
504
441
  assert.equal(element.value, '50');
505
442
  floatInput.value = '75';
506
- floatInput.dispatchEvent(new Event('input'));
507
- await new Promise<void>((r) => setTimeout(r, 350));
443
+ floatInput.dispatchEvent(new Event('change'));
508
444
  assert.equal(element.value, '50');
509
445
  });
510
446
 
@@ -552,7 +488,7 @@ suite('zui-slider', () => {
552
488
  });
553
489
  });
554
490
 
555
- suite('zui-slider showMinMax', () => {
491
+ suite('zui-slider min-max labels', () => {
556
492
  let element: ZuiSlider;
557
493
 
558
494
  setup(() => {
@@ -564,26 +500,30 @@ suite('zui-slider showMinMax', () => {
564
500
  document.body.removeChild(element);
565
501
  });
566
502
 
567
- test('min-max labels toggle with showMinMax and reflect correct values', async () => {
503
+ test('min-max labels are always rendered when showStepLabels is false', async () => {
568
504
  await element.updateComplete;
569
- assert.notExists(element.shadowRoot!.querySelector('.min-max-labels'));
505
+ assert.exists(element.shadowRoot!.querySelector('.min-max-labels'));
506
+ });
570
507
 
508
+ test('min-max labels are hidden when showStepLabels is true', async () => {
509
+ element.steps = ['Small', 'Medium', 'Large'];
510
+ element.showStepLabels = true;
511
+ await element.updateComplete;
512
+ const labels = element.shadowRoot!.querySelector<HTMLElement>('.min-max-labels');
513
+ assert.exists(labels);
514
+ assert.equal(labels!.style.visibility, 'hidden');
515
+ });
516
+
517
+ test('min-max labels reflect min and max values', async () => {
571
518
  element.min = 10;
572
519
  element.max = 90;
573
- element.showMinMax = true;
574
520
  await element.updateComplete;
575
521
  const labels = element.shadowRoot!.querySelectorAll('.min-max-label');
576
- assert.exists(element.shadowRoot!.querySelector('.min-max-labels'));
577
522
  assert.equal(labels[0].textContent!.trim(), '10');
578
523
  assert.equal(labels[1].textContent!.trim(), '90');
579
-
580
- element.showMinMax = false;
581
- await element.updateComplete;
582
- assert.notExists(element.shadowRoot!.querySelector('.min-max-labels'));
583
524
  });
584
525
 
585
- test('min-max labels update when min and max change after initial render', async () => {
586
- element.showMinMax = true;
526
+ test('min-max labels update when min and max change', async () => {
587
527
  element.min = 10;
588
528
  element.max = 90;
589
529
  await element.updateComplete;
@@ -595,17 +535,10 @@ suite('zui-slider showMinMax', () => {
595
535
  assert.equal(labels[1].textContent!.trim(), '95');
596
536
  });
597
537
 
598
- test('show-min-max HTML attribute enables min-max labels', async () => {
599
- element.setAttribute('show-min-max', '');
600
- await element.updateComplete;
601
- assert.exists(element.shadowRoot!.querySelector('.min-max-labels'));
602
- });
603
-
604
538
  test('min-max labels render in range mode', async () => {
605
539
  element.setAttribute('range', '');
606
540
  element.min = 5;
607
541
  element.max = 95;
608
- element.showMinMax = true;
609
542
  await element.updateComplete;
610
543
  assert.exists(element.shadowRoot!.querySelector('.min-max-labels'));
611
544
  const labels = element.shadowRoot!.querySelectorAll('.min-max-label');
@@ -613,6 +546,15 @@ suite('zui-slider showMinMax', () => {
613
546
  assert.equal(labels[0].textContent!.trim(), '5');
614
547
  assert.equal(labels[1].textContent!.trim(), '95');
615
548
  });
549
+
550
+ test('show-step-labels attribute hides min-max labels', async () => {
551
+ element.steps = ['Small', 'Medium', 'Large'];
552
+ element.setAttribute('show-step-labels', '');
553
+ await element.updateComplete;
554
+ const labels = element.shadowRoot!.querySelector<HTMLElement>('.min-max-labels');
555
+ assert.exists(labels);
556
+ assert.equal(labels!.style.visibility, 'hidden');
557
+ });
616
558
  });
617
559
 
618
560
  faceTests<ZuiSlider>('zui-slider', {
@@ -649,7 +591,6 @@ suite('zui-slider range', () => {
649
591
  assert.equal(element.max, 100);
650
592
  assert.equal(element.step, 0);
651
593
  assert.isFalse(element.disabled);
652
- assert.isFalse(element.showMinMax);
653
594
  });
654
595
 
655
596
  test('accepts value-start and value-end attributes', () => {
@@ -917,62 +858,109 @@ suite('zui-slider range', () => {
917
858
  assert.equal(element.valueStart, '33.5');
918
859
  });
919
860
 
920
- test('range start float input position and wrapper background update after drag', async () => {
861
+ test('range start float input position updates after drag', async () => {
921
862
  await element.updateComplete;
922
863
  const startInput = element.shadowRoot!.querySelector<HTMLInputElement>('input.range-start')!;
923
864
  const thumbInputDivs = element.shadowRoot!.querySelectorAll<HTMLElement>('.thumb-input');
924
- const bgBefore = startInput.style.getPropertyValue('--zui-slider-track-bg');
925
865
  startInput.value = '30';
926
866
  startInput.dispatchEvent(new Event('input'));
927
867
  await element.updateComplete;
928
868
  assert.include(thumbInputDivs[0].style.left, '30%');
929
- assert.notEqual(startInput.style.getPropertyValue('--zui-slider-track-bg'), bgBefore);
930
- assert.include(startInput.style.getPropertyValue('--zui-slider-track-bg'), '30%');
931
869
  });
932
870
 
933
- test('change event fires with valueStart and valueEnd detail on range-start change', async () => {
871
+ test('valueStart, range-start native input, and start floating input stay in sync after drag', async () => {
934
872
  await element.updateComplete;
935
- let detail: { valueStart: string; valueEnd: string } | undefined;
936
- element.addEventListener('change', (e: Event) => {
937
- detail = (e as CustomEvent).detail;
938
- });
873
+ const startInput = element.shadowRoot!.querySelector<HTMLInputElement>('input.range-start')!;
874
+ const floatInputs = element.shadowRoot!.querySelectorAll<HTMLInputElement>('.thumb-input input');
939
875
 
940
- element.valueStart = '10';
941
- element.valueEnd = '40';
876
+ startInput.value = '30';
877
+ startInput.dispatchEvent(new Event('input'));
878
+ await element.updateComplete;
879
+
880
+ assert.equal(element.valueStart, '30');
881
+ assert.equal(startInput.value, '30');
882
+ assert.equal(floatInputs[0].value, '30');
883
+ });
942
884
 
885
+ test('valueEnd, range-end native input, and end floating input stay in sync after drag', async () => {
886
+ await element.updateComplete;
887
+ const endInput = element.shadowRoot!.querySelector<HTMLInputElement>('input.range-end')!;
888
+ const floatInputs = element.shadowRoot!.querySelectorAll<HTMLInputElement>('.thumb-input input');
889
+
890
+ endInput.value = '60';
891
+ endInput.dispatchEvent(new Event('input'));
892
+ await element.updateComplete;
893
+
894
+ assert.equal(element.valueEnd, '60');
895
+ assert.equal(endInput.value, '60');
896
+ assert.equal(floatInputs[1].value, '60');
897
+ });
898
+
899
+ test('programmatic valueStart reset after drag keeps range-start native input in sync', async () => {
900
+ element.valueStart = '20';
901
+ await element.updateComplete;
943
902
  const startInput = element.shadowRoot!.querySelector<HTMLInputElement>('input.range-start')!;
944
- startInput.dispatchEvent(new Event('change'));
903
+ const floatInputs = element.shadowRoot!.querySelectorAll<HTMLInputElement>('.thumb-input input');
945
904
 
946
- assert.deepEqual(detail, { valueStart: '10', valueEnd: '40' });
905
+ startInput.value = '40';
906
+ startInput.dispatchEvent(new Event('input'));
907
+ element.valueStart = '20'; // Lit virtual is still '20' from the last render
908
+
909
+ await element.updateComplete; // live() ensures '20' is written despite virtual==='20'
910
+
911
+ assert.equal(element.valueStart, '20');
912
+ assert.equal(startInput.value, '20');
913
+ assert.equal(floatInputs[0].value, '20');
947
914
  });
948
915
 
949
- test('range change event bubbles', async () => {
916
+ test('programmatic valueEnd reset after drag keeps range-end native input in sync', async () => {
917
+ element.valueEnd = '80';
950
918
  await element.updateComplete;
951
- let bubbled = false;
952
- document.body.addEventListener('change', () => (bubbled = true), { once: true });
919
+ const endInput = element.shadowRoot!.querySelector<HTMLInputElement>('input.range-end')!;
920
+ const floatInputs = element.shadowRoot!.querySelectorAll<HTMLInputElement>('.thumb-input input');
953
921
 
954
- const startInput = element.shadowRoot!.querySelector<HTMLInputElement>('input.range-start')!;
955
- startInput.dispatchEvent(new Event('change'));
922
+ endInput.value = '60';
923
+ endInput.dispatchEvent(new Event('input'));
924
+ element.valueEnd = '80'; // Lit virtual is still '80' from the last render
956
925
 
957
- assert.isTrue(bubbled);
926
+ await element.updateComplete; // live() ensures '80' is written despite virtual==='80'
927
+
928
+ assert.equal(element.valueEnd, '80');
929
+ assert.equal(endInput.value, '80');
930
+ assert.equal(floatInputs[1].value, '80');
958
931
  });
959
932
 
960
- test('change event fires with valueStart and valueEnd detail on range-end change', async () => {
933
+ test('change event fires with valueStart and valueEnd detail from both thumbs', async () => {
961
934
  await element.updateComplete;
962
935
  let detail: { valueStart: string; valueEnd: string } | undefined;
963
936
  element.addEventListener('change', (e: Event) => {
964
937
  detail = (e as CustomEvent).detail;
965
938
  });
966
939
 
940
+ element.valueStart = '10';
941
+ element.valueEnd = '40';
942
+ const startInput = element.shadowRoot!.querySelector<HTMLInputElement>('input.range-start')!;
943
+ startInput.dispatchEvent(new Event('change'));
944
+ assert.deepEqual(detail, { valueStart: '10', valueEnd: '40' });
945
+
967
946
  element.valueStart = '15';
968
947
  element.valueEnd = '55';
969
-
970
948
  const endInput = element.shadowRoot!.querySelector<HTMLInputElement>('input.range-end')!;
971
949
  endInput.dispatchEvent(new Event('change'));
972
-
973
950
  assert.deepEqual(detail, { valueStart: '15', valueEnd: '55' });
974
951
  });
975
952
 
953
+ test('range change event bubbles', async () => {
954
+ await element.updateComplete;
955
+ let bubbled = false;
956
+ document.body.addEventListener('change', () => (bubbled = true), { once: true });
957
+
958
+ const startInput = element.shadowRoot!.querySelector<HTMLInputElement>('input.range-start')!;
959
+ startInput.dispatchEvent(new Event('change'));
960
+
961
+ assert.isTrue(bubbled);
962
+ });
963
+
976
964
  test('floating input change fires component range change event', async () => {
977
965
  await element.updateComplete;
978
966
  let detail: { valueStart: string; valueEnd: string } | undefined;
@@ -987,58 +975,6 @@ suite('zui-slider range', () => {
987
975
  assert.deepEqual(detail, { valueStart: '20', valueEnd: '100' });
988
976
  });
989
977
 
990
- test('range inputs reflect min and max properties in DOM', async () => {
991
- element.min = 10;
992
- element.max = 90;
993
- await element.updateComplete;
994
- const startInput = element.shadowRoot!.querySelector<HTMLInputElement>('input.range-start')!;
995
- const endInput = element.shadowRoot!.querySelector<HTMLInputElement>('input.range-end')!;
996
- assert.equal(startInput.min, '10');
997
- assert.equal(startInput.max, '90');
998
- assert.equal(endInput.min, '10');
999
- assert.equal(endInput.max, '90');
1000
- const floatInputs = element.shadowRoot!.querySelectorAll<HTMLInputElement>('.thumb-input input[type="number"]');
1001
- floatInputs.forEach((fi) => {
1002
- assert.equal(fi.min, '10');
1003
- assert.equal(fi.max, '90');
1004
- });
1005
- });
1006
-
1007
- test('all range inputs use step="1" when component step is 0', async () => {
1008
- element.step = 0;
1009
- await element.updateComplete;
1010
- const startInput = element.shadowRoot!.querySelector<HTMLInputElement>('input.range-start')!;
1011
- const endInput = element.shadowRoot!.querySelector<HTMLInputElement>('input.range-end')!;
1012
- const floatInputs = element.shadowRoot!.querySelectorAll<HTMLInputElement>('.thumb-input input[type="number"]');
1013
- assert.equal(startInput.step, '1');
1014
- assert.equal(endInput.step, '1');
1015
- floatInputs.forEach((fi) => assert.equal(fi.step, '1'));
1016
- });
1017
-
1018
- test('all range inputs have correct step attribute when step is set', async () => {
1019
- element.step = 10;
1020
- await element.updateComplete;
1021
- const startInput = element.shadowRoot!.querySelector<HTMLInputElement>('input.range-start')!;
1022
- const endInput = element.shadowRoot!.querySelector<HTMLInputElement>('input.range-end')!;
1023
- const floatInputs = element.shadowRoot!.querySelectorAll<HTMLInputElement>('.thumb-input input[type="number"]');
1024
- assert.equal(startInput.step, '10');
1025
- assert.equal(endInput.step, '10');
1026
- floatInputs.forEach((fi) => assert.equal(fi.step, '10'));
1027
- });
1028
-
1029
- test('range-wrapper gradient uses gray when disabled and reverts to blue when re-enabled', async () => {
1030
- element.disabled = true;
1031
- await element.updateComplete;
1032
- const startInput = element.shadowRoot!.querySelector<HTMLInputElement>('input.range-start')!;
1033
- assert.include(startInput.style.getPropertyValue('--zui-slider-track-bg'), 'var(--zui-gray)');
1034
- assert.notInclude(startInput.style.getPropertyValue('--zui-slider-track-bg'), 'var(--zui-blue)');
1035
-
1036
- element.disabled = false;
1037
- await element.updateComplete;
1038
- assert.include(startInput.style.getPropertyValue('--zui-slider-track-bg'), 'var(--zui-blue)');
1039
- assert.notInclude(startInput.style.getPropertyValue('--zui-slider-track-bg'), 'var(--zui-gray)');
1040
- });
1041
-
1042
978
  test('form reset hides visible floating inputs in range mode', async () => {
1043
979
  await element.updateComplete;
1044
980
  const startInput = element.shadowRoot!.querySelector<HTMLInputElement>('input.range-start')!;
@@ -1052,19 +988,6 @@ suite('zui-slider range', () => {
1052
988
  thumbInputs.forEach((ti) => assert.notInclude(ti.className, 'thumb-input--visible'));
1053
989
  });
1054
990
 
1055
- test('range floating input empty-string input does not trigger value update', async () => {
1056
- await element.updateComplete;
1057
- element.valueStart = '20';
1058
- element.valueEnd = '80';
1059
- const floatInputs = element.shadowRoot!.querySelectorAll<HTMLInputElement>('.thumb-input input[type="number"]');
1060
- floatInputs[0].value = '';
1061
- floatInputs[0].dispatchEvent(new Event('input'));
1062
- floatInputs[1].value = '';
1063
- floatInputs[1].dispatchEvent(new Event('input'));
1064
- assert.equal(element.valueStart, '20');
1065
- assert.equal(element.valueEnd, '80');
1066
- });
1067
-
1068
991
  test('range floating inputs hide when disabled is set while visible', async () => {
1069
992
  await element.updateComplete;
1070
993
  const startInput = element.shadowRoot!.querySelector<HTMLInputElement>('input.range-start')!;
@@ -1078,14 +1001,6 @@ suite('zui-slider range', () => {
1078
1001
  thumbInputs.forEach((ti) => assert.notInclude(ti.className, 'thumb-input--visible'));
1079
1002
  });
1080
1003
 
1081
- test('range-wrapper gradient insets are transparent outside thumb-size bounds', async () => {
1082
- await element.updateComplete;
1083
- const startInput = element.shadowRoot!.querySelector<HTMLInputElement>('input.range-start')!;
1084
- const bg = startInput.style.getPropertyValue('--zui-slider-track-bg');
1085
- assert.include(bg, 'transparent var(--zui-slider-thumb-size)');
1086
- assert.include(bg, 'transparent calc(100% - var(--zui-slider-thumb-size))');
1087
- });
1088
-
1089
1004
  test('disconnectedCallback clears timers and resets thumb input visibility in range mode', async () => {
1090
1005
  await element.updateComplete;
1091
1006
  const startInput = element.shadowRoot!.querySelector<HTMLInputElement>('input.range-start')!;
@@ -1102,45 +1017,6 @@ suite('zui-slider range', () => {
1102
1017
  thumbInputs.forEach((ti) => assert.notInclude(ti.className, 'thumb-input--visible'));
1103
1018
  });
1104
1019
 
1105
- test('range-wrapper gradient updates when valueStart and valueEnd change', async () => {
1106
- element.valueStart = '25';
1107
- element.valueEnd = '75';
1108
- await element.updateComplete;
1109
- const startInput = element.shadowRoot!.querySelector<HTMLInputElement>('input.range-start')!;
1110
- const bg25 = startInput.style.getPropertyValue('--zui-slider-track-bg');
1111
-
1112
- element.valueStart = '10';
1113
- element.valueEnd = '90';
1114
- await element.updateComplete;
1115
- const bg10 = startInput.style.getPropertyValue('--zui-slider-track-bg');
1116
-
1117
- assert.notEqual(bg25, bg10);
1118
- });
1119
-
1120
- test('range start floating input updates valueStart after debounce delay but not immediately', async () => {
1121
- await element.updateComplete;
1122
- element.valueStart = '20';
1123
- const floatInputs = element.shadowRoot!.querySelectorAll<HTMLInputElement>('.thumb-input input[type="number"]');
1124
- floatInputs[0].value = '40';
1125
- floatInputs[0].dispatchEvent(new Event('input'));
1126
- // Debounce is 300ms — valueStart must not update synchronously
1127
- assert.equal(element.valueStart, '20');
1128
- await new Promise<void>((r) => setTimeout(r, 350));
1129
- assert.equal(element.valueStart, '40');
1130
- });
1131
-
1132
- test('range end floating input updates valueEnd after debounce delay but not immediately', async () => {
1133
- await element.updateComplete;
1134
- element.valueEnd = '80';
1135
- const floatInputs = element.shadowRoot!.querySelectorAll<HTMLInputElement>('.thumb-input input[type="number"]');
1136
- floatInputs[1].value = '60';
1137
- floatInputs[1].dispatchEvent(new Event('input'));
1138
- // Debounce is 300ms — valueEnd must not update synchronously
1139
- assert.equal(element.valueEnd, '80');
1140
- await new Promise<void>((r) => setTimeout(r, 350));
1141
- assert.equal(element.valueEnd, '60');
1142
- });
1143
-
1144
1020
  test('focused range start floating input stays visible after pointerleave', async () => {
1145
1021
  await element.updateComplete;
1146
1022
  const startRangeInput = element.shadowRoot!.querySelector<HTMLInputElement>('input.range-start')!;
@@ -1158,32 +1034,29 @@ suite('zui-slider range', () => {
1158
1034
  assert.isTrue(thumbInputDivs[0].classList.contains('thumb-input--visible'));
1159
1035
  });
1160
1036
 
1161
- test('range floating inputs clamp out-of-bounds values to min/max after debounce', async () => {
1037
+ test('range floating inputs clamp out-of-bounds values to min/max on commit', async () => {
1162
1038
  await element.updateComplete;
1163
1039
  const floatInputs = element.shadowRoot!.querySelectorAll<HTMLInputElement>('.thumb-input input[type="number"]');
1164
1040
  floatInputs[0].value = '150';
1165
- floatInputs[0].dispatchEvent(new Event('input'));
1166
- await new Promise<void>((r) => setTimeout(r, 350));
1041
+ floatInputs[0].dispatchEvent(new Event('change'));
1167
1042
  assert.equal(element.valueStart, '100');
1168
1043
 
1169
1044
  element.min = 20;
1170
1045
  floatInputs[1].value = '5';
1171
- floatInputs[1].dispatchEvent(new Event('input'));
1172
- await new Promise<void>((r) => setTimeout(r, 350));
1046
+ floatInputs[1].dispatchEvent(new Event('change'));
1173
1047
  assert.equal(element.valueEnd, '20');
1174
1048
  });
1175
1049
 
1176
- test('range floating input snaps typed valueStart to nearest step after debounce', async () => {
1050
+ test('range floating input snaps typed valueStart to nearest step on commit', async () => {
1177
1051
  await element.updateComplete;
1178
1052
  element.step = 10;
1179
1053
  const floatInputs = element.shadowRoot!.querySelectorAll<HTMLInputElement>('.thumb-input input[type="number"]');
1180
1054
  floatInputs[0].value = '23';
1181
- floatInputs[0].dispatchEvent(new Event('input'));
1182
- await new Promise<void>((r) => setTimeout(r, 350));
1055
+ floatInputs[0].dispatchEvent(new Event('change'));
1183
1056
  assert.equal(element.valueStart, '20');
1184
1057
  });
1185
1058
 
1186
- test('range start floating input change flushes debounce and dispatches correct value immediately', async () => {
1059
+ test('range start floating input change dispatches correct value', async () => {
1187
1060
  await element.updateComplete;
1188
1061
  let detail: { valueStart: string; valueEnd: string } | undefined;
1189
1062
  element.addEventListener('change', (e: Event) => {
@@ -1192,14 +1065,37 @@ suite('zui-slider range', () => {
1192
1065
 
1193
1066
  const floatInputs = element.shadowRoot!.querySelectorAll<HTMLInputElement>('.thumb-input input[type="number"]');
1194
1067
  floatInputs[0].value = '30';
1195
- floatInputs[0].dispatchEvent(new Event('input'));
1196
- assert.equal(element.valueStart, '0'); // debounce not yet fired
1197
- floatInputs[0].dispatchEvent(new Event('change')); // commits immediately
1068
+ floatInputs[0].dispatchEvent(new Event('change'));
1198
1069
  assert.equal(element.valueStart, '30');
1199
1070
  assert.deepEqual(detail, { valueStart: '30', valueEnd: '100' });
1200
- // Debounce timer was cleared — value must not update again after 300ms
1201
- await new Promise<void>((r) => setTimeout(r, 350));
1071
+ });
1072
+
1073
+ test('pressing Enter in range floating inputs commits values', async () => {
1074
+ await element.updateComplete;
1075
+ const floatInputs = element.shadowRoot!.querySelectorAll<HTMLInputElement>('.thumb-input input[type="number"]');
1076
+
1077
+ floatInputs[0].value = '30';
1078
+ floatInputs[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
1202
1079
  assert.equal(element.valueStart, '30');
1080
+
1081
+ floatInputs[1].value = '70';
1082
+ floatInputs[1].dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
1083
+ assert.equal(element.valueEnd, '70');
1084
+ });
1085
+
1086
+ test('range floating input change does not dispatch change event when value is unchanged', async () => {
1087
+ await element.updateComplete;
1088
+ element.valueStart = '30';
1089
+ element.valueEnd = '70';
1090
+ let changeCount = 0;
1091
+ element.addEventListener('change', () => changeCount++);
1092
+
1093
+ const floatInputs = element.shadowRoot!.querySelectorAll<HTMLInputElement>('.thumb-input input[type="number"]');
1094
+ floatInputs[0].value = '30';
1095
+ floatInputs[0].dispatchEvent(new Event('change'));
1096
+ floatInputs[1].value = '70';
1097
+ floatInputs[1].dispatchEvent(new Event('change'));
1098
+ assert.equal(changeCount, 0);
1203
1099
  });
1204
1100
  });
1205
1101
 
@@ -1358,15 +1254,6 @@ suite('zui-slider step dots', () => {
1358
1254
  assert.notExists(element.shadowRoot!.querySelector('.step-dots'));
1359
1255
  });
1360
1256
 
1361
- test('step dot positions are correctly offset by thumb size', async () => {
1362
- await element.updateComplete;
1363
- const dots = element.shadowRoot!.querySelectorAll<HTMLElement>('.step-dot');
1364
- assert.equal(dots[0].style.left, 'var(--zui-slider-thumb-size)');
1365
- assert.equal(dots[dots.length - 1].style.left, 'calc(100% - var(--zui-slider-thumb-size))');
1366
- // pos=50: offset = 1.5 - (3*50)/100 = 0
1367
- assert.equal(dots[2].style.left, 'calc(50% + var(--zui-slider-thumb-size) * 0)');
1368
- });
1369
-
1370
1257
  test('step dots toggle when step is set to 0 then back', async () => {
1371
1258
  await element.updateComplete;
1372
1259
  assert.exists(element.shadowRoot!.querySelector('.step-dots'));
@@ -1407,3 +1294,484 @@ suite('zui-slider step dots', () => {
1407
1294
  assert.equal(wrapper.querySelectorAll('.step-dot').length, 5); // 0, 25, 50, 75, 100
1408
1295
  });
1409
1296
  });
1297
+
1298
+ suite('zui-slider steps', () => {
1299
+ let element: ZuiSlider;
1300
+ let form: HTMLFormElement;
1301
+
1302
+ setup(() => {
1303
+ element = document.createElement('zui-slider') as ZuiSlider;
1304
+ form = buildForm({ enableSubmit: false, appendChildren: [element] });
1305
+ });
1306
+
1307
+ teardown(() => {
1308
+ document.body.removeChild(form);
1309
+ });
1310
+
1311
+ test('initializes value to first step when current value is not a step label', async () => {
1312
+ element.steps = ['Small', 'Medium', 'Large'];
1313
+ await element.updateComplete;
1314
+ assert.equal(element.value, 'Small');
1315
+ });
1316
+
1317
+ test('value set to a valid step label is preserved', async () => {
1318
+ element.steps = ['Small', 'Medium', 'Large'];
1319
+ element.value = 'Medium';
1320
+ await element.updateComplete;
1321
+ assert.equal(element.value, 'Medium');
1322
+ });
1323
+
1324
+ test('native range input has index-based min, max, and step attributes', async () => {
1325
+ element.steps = ['Small', 'Medium', 'Large'];
1326
+ await element.updateComplete;
1327
+ const rangeInput = element.shadowRoot!.querySelector<HTMLInputElement>('input[type="range"]')!;
1328
+ assert.equal(rangeInput.min, '0');
1329
+ assert.equal(rangeInput.max, '2');
1330
+ assert.equal(rangeInput.step, '1');
1331
+ });
1332
+
1333
+ test('native range input value reflects current step index', async () => {
1334
+ element.steps = ['Small', 'Medium', 'Large'];
1335
+ element.value = 'Medium';
1336
+ await element.updateComplete;
1337
+ const rangeInput = element.shadowRoot!.querySelector<HTMLInputElement>('input[type="range"]')!;
1338
+ assert.equal(rangeInput.value, '1');
1339
+ });
1340
+
1341
+ test('dragging native range to index sets value to corresponding step label', async () => {
1342
+ element.steps = ['Small', 'Medium', 'Large'];
1343
+ await element.updateComplete;
1344
+ const rangeInput = element.shadowRoot!.querySelector<HTMLInputElement>('input[type="range"]')!;
1345
+ rangeInput.value = '2';
1346
+ rangeInput.dispatchEvent(new Event('input'));
1347
+ assert.equal(element.value, 'Large');
1348
+ });
1349
+
1350
+ test('progress is computed by step index, not by numeric value', async () => {
1351
+ element.steps = ['Small', 'Medium', 'Large'];
1352
+ element.value = 'Small';
1353
+ await element.updateComplete;
1354
+ assert.equal(element.progress, 0);
1355
+ element.value = 'Medium';
1356
+ assert.equal(element.progress, 50);
1357
+ element.value = 'Large';
1358
+ assert.equal(element.progress, 100);
1359
+ });
1360
+
1361
+ test('floating input is type="text" in steps mode', async () => {
1362
+ element.steps = ['Small', 'Medium', 'Large'];
1363
+ await element.updateComplete;
1364
+ const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input')!;
1365
+ assert.equal(floatInput.type, 'text');
1366
+ });
1367
+
1368
+ test('floating input change to valid step label updates value immediately', async () => {
1369
+ element.steps = ['Small', 'Medium', 'Large'];
1370
+ await element.updateComplete;
1371
+ const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input')!;
1372
+ floatInput.value = 'Large';
1373
+ floatInput.dispatchEvent(new Event('change'));
1374
+ assert.equal(element.value, 'Large');
1375
+ });
1376
+
1377
+ test('steps floating input change does not dispatch change event when value is unchanged', async () => {
1378
+ element.steps = ['Small', 'Medium', 'Large'];
1379
+ element.value = 'Medium';
1380
+ await element.updateComplete;
1381
+ let changeCount = 0;
1382
+ element.addEventListener('change', () => changeCount++);
1383
+
1384
+ const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input')!;
1385
+ floatInput.value = 'Medium';
1386
+ floatInput.dispatchEvent(new Event('change'));
1387
+ assert.equal(changeCount, 0);
1388
+ assert.equal(element.value, 'Medium');
1389
+ });
1390
+
1391
+ test('floating input change to invalid label reverts input to current value', async () => {
1392
+ element.steps = ['Small', 'Medium', 'Large'];
1393
+ element.value = 'Medium';
1394
+ await element.updateComplete;
1395
+ const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input')!;
1396
+ floatInput.value = 'ExtraLarge';
1397
+ floatInput.dispatchEvent(new Event('change'));
1398
+ assert.equal(floatInput.value, 'Medium');
1399
+ assert.equal(element.value, 'Medium');
1400
+ });
1401
+
1402
+ test('floating input resolves typed numeric alias to nearest step on commit', async () => {
1403
+ element.steps = [0, 25, 50, 75, 100];
1404
+ await element.updateComplete;
1405
+ assert.equal(element.value, '50'); // '50' is a valid label — not snapped on init
1406
+ const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input')!;
1407
+ floatInput.value = '30';
1408
+ floatInput.dispatchEvent(new Event('change'));
1409
+ // |30-25|=5, |30-50|=20 → snaps to '25'
1410
+ assert.equal(element.value, '25');
1411
+ });
1412
+
1413
+ test('overflow step is preferred when value exceeds last finite step', async () => {
1414
+ element.steps = [
1415
+ { value: 0, label: '0' },
1416
+ { value: 100, label: '100' },
1417
+ { value: Infinity, label: '100+' },
1418
+ ];
1419
+ await element.updateComplete;
1420
+ // 150 > lastFiniteValue=100 → overflow step wins
1421
+ element.value = '150';
1422
+ assert.equal(element.value, '100+');
1423
+ });
1424
+
1425
+ test('step dots count equals steps.length', async () => {
1426
+ element.steps = ['Small', 'Medium', 'Large'];
1427
+ await element.updateComplete;
1428
+ assert.equal(element.shadowRoot!.querySelectorAll('.step-dot').length, 3);
1429
+ });
1430
+
1431
+ test('min-max labels show first and last step labels', async () => {
1432
+ element.steps = ['Small', 'Medium', 'Large'];
1433
+ await element.updateComplete;
1434
+ const labels = element.shadowRoot!.querySelectorAll('.min-max-label');
1435
+ assert.equal(labels[0].textContent!.trim(), 'Small');
1436
+ assert.equal(labels[1].textContent!.trim(), 'Large');
1437
+ });
1438
+
1439
+ test('updating steps snaps current value to first step when no longer a valid label', async () => {
1440
+ element.steps = ['A', 'B', 'C'];
1441
+ element.value = 'B';
1442
+ await element.updateComplete;
1443
+ element.steps = ['X', 'Y', 'Z'];
1444
+ await element.updateComplete;
1445
+ // 'B' not in new steps → snaps to first step
1446
+ assert.equal(element.value, 'X');
1447
+ });
1448
+
1449
+ test('value is included in form submission as the step label', async () => {
1450
+ const name = randString();
1451
+ element.setAttribute('name', name);
1452
+ element.steps = ['Small', 'Medium', 'Large'];
1453
+ element.value = 'Large';
1454
+ await element.updateComplete;
1455
+ assert.equal(new FormData(form).get(name), 'Large');
1456
+ });
1457
+
1458
+ test('object form steps expose label as public value and use numeric value for snapping', async () => {
1459
+ element.steps = [
1460
+ { value: 0, label: 'None' },
1461
+ { value: 500, label: 'Half' },
1462
+ { value: 1000, label: 'Full' },
1463
+ ];
1464
+ // |400-0|=400, |400-500|=100, |400-1000|=600 → snaps to 'Half'
1465
+ element.value = '400';
1466
+ await element.updateComplete;
1467
+ assert.equal(element.value, 'Half');
1468
+ });
1469
+
1470
+ test('showStepLabels renders a label element for each step', async () => {
1471
+ element.steps = ['Small', 'Medium', 'Large'];
1472
+ element.showStepLabels = true;
1473
+ await element.updateComplete;
1474
+ const labels = element.shadowRoot!.querySelectorAll('.step-dot-label');
1475
+ assert.equal(labels.length, 3);
1476
+ assert.equal(labels[0].textContent!.trim(), 'Small');
1477
+ assert.equal(labels[1].textContent!.trim(), 'Medium');
1478
+ assert.equal(labels[2].textContent!.trim(), 'Large');
1479
+ });
1480
+
1481
+ test('showStepLabels uses label when provided, value otherwise', async () => {
1482
+ element.steps = [{ value: 0, label: 'None' }, { value: 500 }, { value: 1000, label: 'Full' }];
1483
+ element.showStepLabels = true;
1484
+ await element.updateComplete;
1485
+ const labels = element.shadowRoot!.querySelectorAll('.step-dot-label');
1486
+ assert.equal(labels[0].textContent!.trim(), 'None');
1487
+ assert.equal(labels[1].textContent!.trim(), '500');
1488
+ assert.equal(labels[2].textContent!.trim(), 'Full');
1489
+ });
1490
+
1491
+ test('showStepLabels false renders no label elements', async () => {
1492
+ element.steps = ['Small', 'Medium', 'Large'];
1493
+ await element.updateComplete;
1494
+ assert.equal(element.shadowRoot!.querySelectorAll('.step-dot-label').length, 0);
1495
+ });
1496
+
1497
+ test('pressing Enter in steps floating input commits the step label', async () => {
1498
+ element.steps = ['Small', 'Medium', 'Large'];
1499
+ await element.updateComplete;
1500
+ const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input')!;
1501
+ floatInput.value = 'Large';
1502
+ floatInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
1503
+ assert.equal(element.value, 'Large');
1504
+ });
1505
+
1506
+ test('committing empty steps floating input reverts display to current step label', async () => {
1507
+ element.steps = ['Small', 'Medium', 'Large'];
1508
+ element.value = 'Medium';
1509
+ await element.updateComplete;
1510
+ const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input')!;
1511
+ floatInput.value = '';
1512
+ floatInput.dispatchEvent(new Event('change'));
1513
+ assert.equal(element.value, 'Medium');
1514
+ assert.equal(floatInput.value, 'Medium');
1515
+ });
1516
+
1517
+ test('steps attribute is parsed from comma-separated string', async () => {
1518
+ element.setAttribute('steps', 'Small,Medium,Large');
1519
+ await element.updateComplete;
1520
+ assert.equal(element.value, 'Small');
1521
+ assert.equal(element.shadowRoot!.querySelectorAll('.step-dot').length, 3);
1522
+ });
1523
+
1524
+ test('form reset restores initial step label', async () => {
1525
+ const el = document.createElement('zui-slider') as ZuiSlider;
1526
+ const name = randString();
1527
+ el.setAttribute('name', name);
1528
+ el.steps = ['Small', 'Medium', 'Large'];
1529
+ el.value = 'Medium';
1530
+ const f = buildForm({ enableSubmit: false, appendChildren: [el] });
1531
+ await el.updateComplete;
1532
+
1533
+ el.value = 'Large';
1534
+ f.reset();
1535
+
1536
+ assert.equal(el.value, 'Medium');
1537
+ assert.equal(new FormData(f).get(name), 'Medium');
1538
+ document.body.removeChild(f);
1539
+ });
1540
+ });
1541
+
1542
+ suite('zui-slider steps range', () => {
1543
+ let element: ZuiSlider;
1544
+ let form: HTMLFormElement;
1545
+
1546
+ setup(() => {
1547
+ element = document.createElement('zui-slider') as ZuiSlider;
1548
+ element.setAttribute('range', '');
1549
+ form = buildForm({ enableSubmit: false, appendChildren: [element] });
1550
+ });
1551
+
1552
+ teardown(() => {
1553
+ document.body.removeChild(form);
1554
+ });
1555
+
1556
+ test('invalid default valueStart and valueEnd snap to first and last step', async () => {
1557
+ element.steps = ['A', 'B', 'C', 'D', 'E'];
1558
+ await element.updateComplete;
1559
+ assert.equal(element.valueStart, 'A');
1560
+ assert.equal(element.valueEnd, 'E');
1561
+ });
1562
+
1563
+ test('valid step labels for valueStart and valueEnd are preserved', async () => {
1564
+ element.steps = ['A', 'B', 'C', 'D', 'E'];
1565
+ element.valueStart = 'B';
1566
+ element.valueEnd = 'D';
1567
+ await element.updateComplete;
1568
+ assert.equal(element.valueStart, 'B');
1569
+ assert.equal(element.valueEnd, 'D');
1570
+ });
1571
+
1572
+ test('dragging start thumb to index equal to endIdx is rejected', async () => {
1573
+ element.steps = ['A', 'B', 'C', 'D', 'E'];
1574
+ await element.updateComplete;
1575
+ // valueStart='A'(idx=0), valueEnd='E'(idx=4)
1576
+ const startInput = element.shadowRoot!.querySelector<HTMLInputElement>('input.range-start')!;
1577
+ startInput.value = '4'; // equal to endIdx → rejected
1578
+ startInput.dispatchEvent(new Event('input'));
1579
+ assert.equal(element.valueStart, 'A');
1580
+ assert.equal(startInput.value, '0');
1581
+ });
1582
+
1583
+ test('dragging start thumb to valid index advances valueStart', async () => {
1584
+ element.steps = ['A', 'B', 'C', 'D', 'E'];
1585
+ await element.updateComplete;
1586
+ const startInput = element.shadowRoot!.querySelector<HTMLInputElement>('input.range-start')!;
1587
+ startInput.value = '2'; // index of 'C', less than endIdx=4 → accepted
1588
+ startInput.dispatchEvent(new Event('input'));
1589
+ assert.equal(element.valueStart, 'C');
1590
+ });
1591
+
1592
+ test('progressStart and progressEnd are computed by step index', async () => {
1593
+ element.steps = ['A', 'B', 'C', 'D', 'E'];
1594
+ await element.updateComplete;
1595
+ assert.equal(element.progressStart, 0); // 'A' at idx 0/4 = 0%
1596
+ assert.equal(element.progressEnd, 100); // 'E' at idx 4/4 = 100%
1597
+ element.valueStart = 'B'; // idx 1/4 = 25%
1598
+ element.valueEnd = 'D'; // idx 3/4 = 75%
1599
+ assert.equal(element.progressStart, 25);
1600
+ assert.equal(element.progressEnd, 75);
1601
+ });
1602
+
1603
+ test('form value is step labels joined as "start,end"', async () => {
1604
+ const name = randString();
1605
+ element.setAttribute('name', name);
1606
+ element.steps = ['A', 'B', 'C', 'D', 'E'];
1607
+ await element.updateComplete;
1608
+ assert.equal(new FormData(form).get(name), 'A,E');
1609
+ });
1610
+
1611
+ test('dynamic steps change snaps both invalid values to first and last step', async () => {
1612
+ element.steps = ['A', 'B', 'C'];
1613
+ element.valueStart = 'A';
1614
+ element.valueEnd = 'C';
1615
+ await element.updateComplete;
1616
+ element.steps = ['X', 'Y', 'Z'];
1617
+ await element.updateComplete;
1618
+ // 'A' and 'C' not in new steps → snap to first and last
1619
+ assert.equal(element.valueStart, 'X');
1620
+ assert.equal(element.valueEnd, 'Z');
1621
+ });
1622
+
1623
+ test('form reset restores initial step labels in range mode', async () => {
1624
+ const el = document.createElement('zui-slider') as ZuiSlider;
1625
+ el.setAttribute('range', '');
1626
+ const name = randString();
1627
+ el.setAttribute('name', name);
1628
+ el.steps = ['A', 'B', 'C', 'D', 'E'];
1629
+ el.valueStart = 'B';
1630
+ el.valueEnd = 'D';
1631
+ const f = buildForm({ enableSubmit: false, appendChildren: [el] });
1632
+ await el.updateComplete;
1633
+
1634
+ el.valueStart = 'A';
1635
+ el.valueEnd = 'E';
1636
+ f.reset();
1637
+
1638
+ assert.equal(el.valueStart, 'B');
1639
+ assert.equal(el.valueEnd, 'D');
1640
+ assert.equal(new FormData(f).get(name), 'B,D');
1641
+ document.body.removeChild(f);
1642
+ });
1643
+
1644
+ test('track click moves nearer thumb in steps mode', async () => {
1645
+ // 5 steps → indices 0-4 (total=4); fraction 0.25 → idx 1 ('B'), fraction 0.75 → idx 3 ('D')
1646
+ element.steps = ['A', 'B', 'C', 'D', 'E'];
1647
+ await element.updateComplete;
1648
+
1649
+ clickAtFraction(element, 0.25); // closer to start (idx=0 'A') than end (idx=4 'E') → moves start
1650
+ assert.equal(element.valueStart, 'B');
1651
+ assert.equal(element.valueEnd, 'E');
1652
+
1653
+ clickAtFraction(element, 0.75); // closer to end (idx=4 'E') than start (idx=1 'B') → moves end
1654
+ assert.equal(element.valueStart, 'B');
1655
+ assert.equal(element.valueEnd, 'D');
1656
+ });
1657
+ });
1658
+
1659
+ suite('zui-slider stepParser', () => {
1660
+ let element: ZuiSlider;
1661
+ let form: HTMLFormElement;
1662
+
1663
+ setup(() => {
1664
+ element = document.createElement('zui-slider') as ZuiSlider;
1665
+ form = buildForm({ enableSubmit: false, appendChildren: [element] });
1666
+ });
1667
+
1668
+ teardown(() => {
1669
+ document.body.removeChild(form);
1670
+ });
1671
+
1672
+ test('exact step label match does not invoke stepParser', async () => {
1673
+ let callCount = 0;
1674
+ element.steps = [
1675
+ { value: 1000, label: '1k' },
1676
+ { value: 2000, label: '2k' },
1677
+ { value: 3000, label: '3k' },
1678
+ ];
1679
+ element.stepParser = (input) => {
1680
+ callCount++;
1681
+ return parseFloat(input) * 1000;
1682
+ };
1683
+ await element.updateComplete;
1684
+ const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input')!;
1685
+ floatInput.value = '2k'; // exact step label — resolved before stepParser is consulted
1686
+ floatInput.dispatchEvent(new Event('change'));
1687
+ assert.equal(element.value, '2k');
1688
+ assert.equal(callCount, 0);
1689
+ });
1690
+
1691
+ test('number return from stepParser snaps to nearest step by numeric value', async () => {
1692
+ element.steps = [
1693
+ { value: 1000, label: '1k' },
1694
+ { value: 2000, label: '2k' },
1695
+ { value: 3000, label: '3k' },
1696
+ ];
1697
+ element.stepParser = (input) => {
1698
+ const m = input.match(/^([\d.]+)k$/);
1699
+ return m ? parseFloat(m[1]) * 1000 : null;
1700
+ };
1701
+ await element.updateComplete;
1702
+ const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input')!;
1703
+ floatInput.value = '1.5k'; // stepParser returns 1500; |1500-1000|=|1500-2000|=500 → tie → first ('1k') wins
1704
+ floatInput.dispatchEvent(new Event('change'));
1705
+ assert.equal(element.value, '1k');
1706
+ });
1707
+
1708
+ test('null return from stepParser reverts floating input to current value', async () => {
1709
+ element.steps = [
1710
+ { value: 1000, label: '1k' },
1711
+ { value: 2000, label: '2k' },
1712
+ { value: 3000, label: '3k' },
1713
+ ];
1714
+ element.stepParser = (input) => (input === 'bad' ? null : parseFloat(input) * 1000);
1715
+ element.value = '2k';
1716
+ await element.updateComplete;
1717
+ const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input')!;
1718
+ floatInput.value = 'bad';
1719
+ floatInput.dispatchEvent(new Event('change'));
1720
+ // null → rejected; floating input reverts to the current step value
1721
+ assert.equal(floatInput.value, '2k');
1722
+ assert.equal(element.value, '2k');
1723
+ });
1724
+
1725
+ test('string return from stepParser is used directly as a step label', async () => {
1726
+ element.steps = [
1727
+ { value: 1000, label: '1k' },
1728
+ { value: 2000, label: '2k' },
1729
+ { value: 3000, label: '3k' },
1730
+ ];
1731
+ element.stepParser = (input) => (input.startsWith('3') ? '3k' : null);
1732
+ await element.updateComplete;
1733
+ const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input')!;
1734
+ floatInput.value = '3000'; // stepParser returns '3k', an exact step label
1735
+ floatInput.dispatchEvent(new Event('change'));
1736
+ assert.equal(element.value, '3k');
1737
+ });
1738
+
1739
+ test('programmatic value set uses stepParser for non-label strings via #clampToRange', async () => {
1740
+ element.steps = [
1741
+ { value: 1000, label: '1k' },
1742
+ { value: 2000, label: '2k' },
1743
+ { value: 3000, label: '3k' },
1744
+ ];
1745
+ element.stepParser = (input) => {
1746
+ const m = input.match(/^([\d.]+)k$/);
1747
+ return m ? parseFloat(m[1]) * 1000 : null;
1748
+ };
1749
+ await element.updateComplete;
1750
+ // '2.5k' → stepParser returns 2500; |2500-2000|=|2500-3000|=500 → tie → '2k' wins
1751
+ element.value = '2.5k';
1752
+ assert.equal(element.value, '2k');
1753
+ });
1754
+
1755
+ test('stepParser resolves typed aliases in range mode floating inputs', async () => {
1756
+ element.setAttribute('range', '');
1757
+ element.steps = [
1758
+ { value: 1000, label: '1k' },
1759
+ { value: 2000, label: '2k' },
1760
+ { value: 3000, label: '3k' },
1761
+ ];
1762
+ element.stepParser = (input) => {
1763
+ const m = input.match(/^([\d.]+)k$/);
1764
+ return m ? parseFloat(m[1]) * 1000 : null;
1765
+ };
1766
+ await element.updateComplete;
1767
+ const floatInputs = element.shadowRoot!.querySelectorAll<HTMLInputElement>('.thumb-input input');
1768
+ // '1.5k' → stepParser returns 1500; tie at 500 from both '1k' and '2k' → first ('1k') wins
1769
+ floatInputs[0].value = '1.5k';
1770
+ floatInputs[0].dispatchEvent(new Event('change'));
1771
+ assert.equal(element.valueStart, '1k');
1772
+ // '2.5k' → stepParser returns 2500; tie → first ('2k') wins
1773
+ floatInputs[1].value = '2.5k';
1774
+ floatInputs[1].dispatchEvent(new Event('change'));
1775
+ assert.equal(element.valueEnd, '2k');
1776
+ });
1777
+ });