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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
 
@@ -197,38 +196,6 @@ suite('zui-slider', () => {
197
196
  assert.equal(new FormData(form).get(name), '50');
198
197
  });
199
198
 
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;
213
- 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
- });
219
-
220
- test('native inputs reflect min and max properties in DOM', async () => {
221
- element.min = 10;
222
- element.max = 90;
223
- 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
-
232
199
  test('single mode input has inline linear-gradient background with transparent insets', async () => {
233
200
  await element.updateComplete;
234
201
  const input = element.shadowRoot!.querySelector<HTMLInputElement>('input[type="range"]')!;
@@ -251,29 +218,6 @@ suite('zui-slider', () => {
251
218
  assert.notInclude(input.style.getPropertyValue('--zui-slider-track-bg'), 'var(--zui-gray)');
252
219
  });
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
- );
263
-
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
- );
275
- });
276
-
277
221
  test('form reset hides visible floating input', async () => {
278
222
  await element.updateComplete;
279
223
  const input = element.shadowRoot!.querySelector<HTMLInputElement>('input[type="range"]')!;
@@ -365,9 +309,9 @@ suite('zui-slider', () => {
365
309
  const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input[type="number"]')!;
366
310
  floatInput.value = '75';
367
311
  floatInput.dispatchEvent(new Event('input'));
368
- // Debounce is 300ms — value must not update synchronously
312
+ // Debounce is 500ms — value must not update synchronously
369
313
  assert.equal(element.value, '50');
370
- await new Promise<void>((r) => setTimeout(r, 350));
314
+ await new Promise<void>((r) => setTimeout(r, 600));
371
315
  assert.equal(element.value, '75');
372
316
  });
373
317
 
@@ -378,7 +322,7 @@ suite('zui-slider', () => {
378
322
  floatInput.dispatchEvent(new Event('input'));
379
323
  // Debounce is pending — reset before it fires
380
324
  form.reset();
381
- await new Promise<void>((r) => setTimeout(r, 350));
325
+ await new Promise<void>((r) => setTimeout(r, 600));
382
326
  // Debounce timer must have been cancelled by formResetCallback
383
327
  assert.equal(element.value, '50');
384
328
  });
@@ -393,18 +337,44 @@ suite('zui-slider', () => {
393
337
  assert.equal(element.value, '50');
394
338
  });
395
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
+ test('committing empty floating input reverts display to current value', async () => {
357
+ await element.updateComplete;
358
+ element.value = '75';
359
+ const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input[type="number"]')!;
360
+ floatInput.value = '';
361
+ floatInput.dispatchEvent(new Event('change'));
362
+ assert.equal(element.value, '75');
363
+ assert.equal(floatInput.value, '75');
364
+ });
365
+
396
366
  test('floating input clamps out-of-bounds values to min/max after debounce', async () => {
397
367
  await element.updateComplete;
398
368
  const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input[type="number"]')!;
399
369
  floatInput.value = '150';
400
370
  floatInput.dispatchEvent(new Event('input'));
401
- await new Promise<void>((r) => setTimeout(r, 350));
371
+ await new Promise<void>((r) => setTimeout(r, 600));
402
372
  assert.equal(element.value, '100');
403
373
 
404
374
  element.min = 20;
405
375
  floatInput.value = '5';
406
376
  floatInput.dispatchEvent(new Event('input'));
407
- await new Promise<void>((r) => setTimeout(r, 350));
377
+ await new Promise<void>((r) => setTimeout(r, 600));
408
378
  assert.equal(element.value, '20');
409
379
  });
410
380
 
@@ -414,7 +384,7 @@ suite('zui-slider', () => {
414
384
  const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input[type="number"]')!;
415
385
  floatInput.value = '47';
416
386
  floatInput.dispatchEvent(new Event('input'));
417
- await new Promise<void>((r) => setTimeout(r, 350));
387
+ await new Promise<void>((r) => setTimeout(r, 600));
418
388
  assert.equal(element.value, '50');
419
389
  });
420
390
 
@@ -433,8 +403,8 @@ suite('zui-slider', () => {
433
403
  floatInput.dispatchEvent(new Event('change')); // commits immediately
434
404
  assert.equal(element.value, '75');
435
405
  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));
406
+ // Debounce timer was cleared — value must not update again after 500ms
407
+ await new Promise<void>((r) => setTimeout(r, 600));
438
408
  assert.equal(element.value, '75');
439
409
  });
440
410
 
@@ -552,7 +522,7 @@ suite('zui-slider', () => {
552
522
  });
553
523
  });
554
524
 
555
- suite('zui-slider showMinMax', () => {
525
+ suite('zui-slider min-max labels', () => {
556
526
  let element: ZuiSlider;
557
527
 
558
528
  setup(() => {
@@ -564,26 +534,30 @@ suite('zui-slider showMinMax', () => {
564
534
  document.body.removeChild(element);
565
535
  });
566
536
 
567
- test('min-max labels toggle with showMinMax and reflect correct values', async () => {
537
+ test('min-max labels are always rendered when showStepLabels is false', async () => {
538
+ await element.updateComplete;
539
+ assert.exists(element.shadowRoot!.querySelector('.min-max-labels'));
540
+ });
541
+
542
+ test('min-max labels are hidden when showStepLabels is true', async () => {
543
+ element.steps = ['Small', 'Medium', 'Large'];
544
+ element.showStepLabels = true;
568
545
  await element.updateComplete;
569
- assert.notExists(element.shadowRoot!.querySelector('.min-max-labels'));
546
+ const labels = element.shadowRoot!.querySelector<HTMLElement>('.min-max-labels');
547
+ assert.exists(labels);
548
+ assert.equal(labels!.style.visibility, 'hidden');
549
+ });
570
550
 
551
+ test('min-max labels reflect min and max values', async () => {
571
552
  element.min = 10;
572
553
  element.max = 90;
573
- element.showMinMax = true;
574
554
  await element.updateComplete;
575
555
  const labels = element.shadowRoot!.querySelectorAll('.min-max-label');
576
- assert.exists(element.shadowRoot!.querySelector('.min-max-labels'));
577
556
  assert.equal(labels[0].textContent!.trim(), '10');
578
557
  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
558
  });
584
559
 
585
- test('min-max labels update when min and max change after initial render', async () => {
586
- element.showMinMax = true;
560
+ test('min-max labels update when min and max change', async () => {
587
561
  element.min = 10;
588
562
  element.max = 90;
589
563
  await element.updateComplete;
@@ -595,17 +569,10 @@ suite('zui-slider showMinMax', () => {
595
569
  assert.equal(labels[1].textContent!.trim(), '95');
596
570
  });
597
571
 
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
572
  test('min-max labels render in range mode', async () => {
605
573
  element.setAttribute('range', '');
606
574
  element.min = 5;
607
575
  element.max = 95;
608
- element.showMinMax = true;
609
576
  await element.updateComplete;
610
577
  assert.exists(element.shadowRoot!.querySelector('.min-max-labels'));
611
578
  const labels = element.shadowRoot!.querySelectorAll('.min-max-label');
@@ -613,6 +580,15 @@ suite('zui-slider showMinMax', () => {
613
580
  assert.equal(labels[0].textContent!.trim(), '5');
614
581
  assert.equal(labels[1].textContent!.trim(), '95');
615
582
  });
583
+
584
+ test('show-step-labels attribute hides min-max labels', async () => {
585
+ element.steps = ['Small', 'Medium', 'Large'];
586
+ element.setAttribute('show-step-labels', '');
587
+ await element.updateComplete;
588
+ const labels = element.shadowRoot!.querySelector<HTMLElement>('.min-max-labels');
589
+ assert.exists(labels);
590
+ assert.equal(labels!.style.visibility, 'hidden');
591
+ });
616
592
  });
617
593
 
618
594
  faceTests<ZuiSlider>('zui-slider', {
@@ -649,7 +625,6 @@ suite('zui-slider range', () => {
649
625
  assert.equal(element.max, 100);
650
626
  assert.equal(element.step, 0);
651
627
  assert.isFalse(element.disabled);
652
- assert.isFalse(element.showMinMax);
653
628
  });
654
629
 
655
630
  test('accepts value-start and value-end attributes', () => {
@@ -987,45 +962,6 @@ suite('zui-slider range', () => {
987
962
  assert.deepEqual(detail, { valueStart: '20', valueEnd: '100' });
988
963
  });
989
964
 
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
965
  test('range-wrapper gradient uses gray when disabled and reverts to blue when re-enabled', async () => {
1030
966
  element.disabled = true;
1031
967
  await element.updateComplete;
@@ -1123,9 +1059,9 @@ suite('zui-slider range', () => {
1123
1059
  const floatInputs = element.shadowRoot!.querySelectorAll<HTMLInputElement>('.thumb-input input[type="number"]');
1124
1060
  floatInputs[0].value = '40';
1125
1061
  floatInputs[0].dispatchEvent(new Event('input'));
1126
- // Debounce is 300ms — valueStart must not update synchronously
1062
+ // Debounce is 500ms — valueStart must not update synchronously
1127
1063
  assert.equal(element.valueStart, '20');
1128
- await new Promise<void>((r) => setTimeout(r, 350));
1064
+ await new Promise<void>((r) => setTimeout(r, 600));
1129
1065
  assert.equal(element.valueStart, '40');
1130
1066
  });
1131
1067
 
@@ -1135,9 +1071,9 @@ suite('zui-slider range', () => {
1135
1071
  const floatInputs = element.shadowRoot!.querySelectorAll<HTMLInputElement>('.thumb-input input[type="number"]');
1136
1072
  floatInputs[1].value = '60';
1137
1073
  floatInputs[1].dispatchEvent(new Event('input'));
1138
- // Debounce is 300ms — valueEnd must not update synchronously
1074
+ // Debounce is 500ms — valueEnd must not update synchronously
1139
1075
  assert.equal(element.valueEnd, '80');
1140
- await new Promise<void>((r) => setTimeout(r, 350));
1076
+ await new Promise<void>((r) => setTimeout(r, 600));
1141
1077
  assert.equal(element.valueEnd, '60');
1142
1078
  });
1143
1079
 
@@ -1163,13 +1099,13 @@ suite('zui-slider range', () => {
1163
1099
  const floatInputs = element.shadowRoot!.querySelectorAll<HTMLInputElement>('.thumb-input input[type="number"]');
1164
1100
  floatInputs[0].value = '150';
1165
1101
  floatInputs[0].dispatchEvent(new Event('input'));
1166
- await new Promise<void>((r) => setTimeout(r, 350));
1102
+ await new Promise<void>((r) => setTimeout(r, 600));
1167
1103
  assert.equal(element.valueStart, '100');
1168
1104
 
1169
1105
  element.min = 20;
1170
1106
  floatInputs[1].value = '5';
1171
1107
  floatInputs[1].dispatchEvent(new Event('input'));
1172
- await new Promise<void>((r) => setTimeout(r, 350));
1108
+ await new Promise<void>((r) => setTimeout(r, 600));
1173
1109
  assert.equal(element.valueEnd, '20');
1174
1110
  });
1175
1111
 
@@ -1179,7 +1115,7 @@ suite('zui-slider range', () => {
1179
1115
  const floatInputs = element.shadowRoot!.querySelectorAll<HTMLInputElement>('.thumb-input input[type="number"]');
1180
1116
  floatInputs[0].value = '23';
1181
1117
  floatInputs[0].dispatchEvent(new Event('input'));
1182
- await new Promise<void>((r) => setTimeout(r, 350));
1118
+ await new Promise<void>((r) => setTimeout(r, 600));
1183
1119
  assert.equal(element.valueStart, '20');
1184
1120
  });
1185
1121
 
@@ -1197,12 +1133,122 @@ suite('zui-slider range', () => {
1197
1133
  floatInputs[0].dispatchEvent(new Event('change')); // commits immediately
1198
1134
  assert.equal(element.valueStart, '30');
1199
1135
  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));
1136
+ // Debounce timer was cleared — value must not update again after 500ms
1137
+ await new Promise<void>((r) => setTimeout(r, 600));
1202
1138
  assert.equal(element.valueStart, '30');
1203
1139
  });
1204
1140
  });
1205
1141
 
1142
+ // Dispatches a click at a given 0–1 fraction of the effective track, matching the
1143
+ // coordinate math in #onTrackClick so the component computes the same fraction back.
1144
+ function clickAtFraction(element: ZuiSlider, fraction: number): void {
1145
+ const wrapper = element.shadowRoot!.querySelector<HTMLElement>('.range-wrapper')!;
1146
+ const rect = wrapper.getBoundingClientRect();
1147
+ const thumbRadius = 1.5 * parseFloat(getComputedStyle(element).getPropertyValue('--zui-slider-thumb-size'));
1148
+ const effectiveWidth = rect.width - 2 * thumbRadius;
1149
+ const clientX = rect.left + thumbRadius + fraction * effectiveWidth;
1150
+ wrapper.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX }));
1151
+ }
1152
+
1153
+ suite('zui-slider range track click', () => {
1154
+ let element: ZuiSlider;
1155
+ let form: HTMLFormElement;
1156
+
1157
+ setup(() => {
1158
+ element = document.createElement('zui-slider') as ZuiSlider;
1159
+ element.setAttribute('range', '');
1160
+ form = buildForm({ enableSubmit: false, appendChildren: [element] });
1161
+ });
1162
+
1163
+ teardown(() => {
1164
+ document.body.removeChild(form);
1165
+ });
1166
+
1167
+ test('moves start thumb when clicking left of both thumbs', async () => {
1168
+ await element.updateComplete;
1169
+ clickAtFraction(element, 0.25);
1170
+ assert.equal(element.valueStart, '25');
1171
+ assert.equal(element.valueEnd, '100');
1172
+ });
1173
+
1174
+ test('moves end thumb when clicking right of both thumbs', async () => {
1175
+ await element.updateComplete;
1176
+ clickAtFraction(element, 0.75);
1177
+ assert.equal(element.valueStart, '0');
1178
+ assert.equal(element.valueEnd, '75');
1179
+ });
1180
+
1181
+ test('moves start thumb when closer to start than end', async () => {
1182
+ element.valueStart = '20';
1183
+ element.valueEnd = '60';
1184
+ await element.updateComplete;
1185
+ clickAtFraction(element, 0.3);
1186
+ assert.equal(element.valueStart, '30');
1187
+ assert.equal(element.valueEnd, '60');
1188
+ });
1189
+
1190
+ test('moves end thumb when closer to end than start', async () => {
1191
+ element.valueStart = '20';
1192
+ element.valueEnd = '60';
1193
+ await element.updateComplete;
1194
+ clickAtFraction(element, 0.5);
1195
+ assert.equal(element.valueStart, '20');
1196
+ assert.equal(element.valueEnd, '50');
1197
+ });
1198
+
1199
+ test('prefers start thumb when equidistant from both', async () => {
1200
+ await element.updateComplete;
1201
+ clickAtFraction(element, 0.5);
1202
+ assert.equal(element.valueStart, '50');
1203
+ assert.equal(element.valueEnd, '100');
1204
+ });
1205
+
1206
+ test('does nothing when disabled', async () => {
1207
+ element.disabled = true;
1208
+ await element.updateComplete;
1209
+ clickAtFraction(element, 0.25);
1210
+ assert.equal(element.valueStart, '0');
1211
+ assert.equal(element.valueEnd, '100');
1212
+ });
1213
+
1214
+ test('does nothing when readonly', async () => {
1215
+ element.setAttribute('readonly', '');
1216
+ await element.updateComplete;
1217
+ clickAtFraction(element, 0.25);
1218
+ assert.equal(element.valueStart, '0');
1219
+ assert.equal(element.valueEnd, '100');
1220
+ });
1221
+
1222
+ test('fires change event with valueStart and valueEnd detail', async () => {
1223
+ await element.updateComplete;
1224
+ let detail: { valueStart: string; valueEnd: string } | undefined;
1225
+ element.addEventListener('change', (e: Event) => {
1226
+ detail = (e as CustomEvent).detail;
1227
+ });
1228
+ clickAtFraction(element, 0.25);
1229
+ assert.deepEqual(detail, { valueStart: '25', valueEnd: '100' });
1230
+ });
1231
+
1232
+ test('snaps clicked value to nearest step', async () => {
1233
+ element.step = 10;
1234
+ await element.updateComplete;
1235
+ clickAtFraction(element, 0.23);
1236
+ assert.equal(element.valueStart, '20');
1237
+ assert.equal(element.valueEnd, '100');
1238
+ });
1239
+
1240
+ test('does not move start thumb when snapped value would equal end', async () => {
1241
+ element.valueStart = '40';
1242
+ element.valueEnd = '50';
1243
+ element.step = 10;
1244
+ await element.updateComplete;
1245
+ // 45% is equidistant (tie → start preferred), but snaps to 50 which is not < endNum=50 → no move
1246
+ clickAtFraction(element, 0.45);
1247
+ assert.equal(element.valueStart, '40');
1248
+ assert.equal(element.valueEnd, '50');
1249
+ });
1250
+ });
1251
+
1206
1252
  suite('zui-slider step dots', () => {
1207
1253
  let element: ZuiSlider;
1208
1254
 
@@ -1297,3 +1343,497 @@ suite('zui-slider step dots', () => {
1297
1343
  assert.equal(wrapper.querySelectorAll('.step-dot').length, 5); // 0, 25, 50, 75, 100
1298
1344
  });
1299
1345
  });
1346
+
1347
+ suite('zui-slider steps', () => {
1348
+ let element: ZuiSlider;
1349
+ let form: HTMLFormElement;
1350
+
1351
+ setup(() => {
1352
+ element = document.createElement('zui-slider') as ZuiSlider;
1353
+ form = buildForm({ enableSubmit: false, appendChildren: [element] });
1354
+ });
1355
+
1356
+ teardown(() => {
1357
+ document.body.removeChild(form);
1358
+ });
1359
+
1360
+ test('initializes value to first step when current value is not a step label', async () => {
1361
+ element.steps = ['Small', 'Medium', 'Large'];
1362
+ await element.updateComplete;
1363
+ assert.equal(element.value, 'Small');
1364
+ });
1365
+
1366
+ test('value set to a valid step label is preserved', async () => {
1367
+ element.steps = ['Small', 'Medium', 'Large'];
1368
+ element.value = 'Medium';
1369
+ await element.updateComplete;
1370
+ assert.equal(element.value, 'Medium');
1371
+ });
1372
+
1373
+ test('native range input has index-based min, max, and step attributes', async () => {
1374
+ element.steps = ['Small', 'Medium', 'Large'];
1375
+ await element.updateComplete;
1376
+ const rangeInput = element.shadowRoot!.querySelector<HTMLInputElement>('input[type="range"]')!;
1377
+ assert.equal(rangeInput.min, '0');
1378
+ assert.equal(rangeInput.max, '2');
1379
+ assert.equal(rangeInput.step, '1');
1380
+ });
1381
+
1382
+ test('native range input value reflects current step index', async () => {
1383
+ element.steps = ['Small', 'Medium', 'Large'];
1384
+ element.value = 'Medium';
1385
+ await element.updateComplete;
1386
+ const rangeInput = element.shadowRoot!.querySelector<HTMLInputElement>('input[type="range"]')!;
1387
+ assert.equal(rangeInput.value, '1');
1388
+ });
1389
+
1390
+ test('dragging native range to index sets value to corresponding step label', async () => {
1391
+ element.steps = ['Small', 'Medium', 'Large'];
1392
+ await element.updateComplete;
1393
+ const rangeInput = element.shadowRoot!.querySelector<HTMLInputElement>('input[type="range"]')!;
1394
+ rangeInput.value = '2';
1395
+ rangeInput.dispatchEvent(new Event('input'));
1396
+ assert.equal(element.value, 'Large');
1397
+ });
1398
+
1399
+ test('progress is computed by step index, not by numeric value', async () => {
1400
+ element.steps = ['Small', 'Medium', 'Large'];
1401
+ element.value = 'Small';
1402
+ await element.updateComplete;
1403
+ assert.equal(element.progress, 0);
1404
+ element.value = 'Medium';
1405
+ assert.equal(element.progress, 50);
1406
+ element.value = 'Large';
1407
+ assert.equal(element.progress, 100);
1408
+ });
1409
+
1410
+ test('floating input is type="text" in steps mode', async () => {
1411
+ element.steps = ['Small', 'Medium', 'Large'];
1412
+ await element.updateComplete;
1413
+ const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input')!;
1414
+ assert.equal(floatInput.type, 'text');
1415
+ });
1416
+
1417
+ test('floating input change to valid step label updates value immediately', async () => {
1418
+ element.steps = ['Small', 'Medium', 'Large'];
1419
+ await element.updateComplete;
1420
+ const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input')!;
1421
+ floatInput.value = 'Large';
1422
+ floatInput.dispatchEvent(new Event('change'));
1423
+ assert.equal(element.value, 'Large');
1424
+ });
1425
+
1426
+ test('floating input change to invalid label reverts input to current value', async () => {
1427
+ element.steps = ['Small', 'Medium', 'Large'];
1428
+ element.value = 'Medium';
1429
+ await element.updateComplete;
1430
+ const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input')!;
1431
+ floatInput.value = 'ExtraLarge';
1432
+ floatInput.dispatchEvent(new Event('change'));
1433
+ assert.equal(floatInput.value, 'Medium');
1434
+ assert.equal(element.value, 'Medium');
1435
+ });
1436
+
1437
+ test('floating input debounce resolves typed numeric alias to nearest step', async () => {
1438
+ element.steps = [0, 25, 50, 75, 100];
1439
+ await element.updateComplete;
1440
+ assert.equal(element.value, '50'); // '50' is a valid label — not snapped on init
1441
+ const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input')!;
1442
+ 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));
1446
+ // |30-25|=5, |30-50|=20 → snaps to '25'
1447
+ assert.equal(element.value, '25');
1448
+ });
1449
+
1450
+ test('overflow step is preferred when value exceeds last finite step', async () => {
1451
+ element.steps = [
1452
+ { value: 0, label: '0' },
1453
+ { value: 100, label: '100' },
1454
+ { value: Infinity, label: '100+' },
1455
+ ];
1456
+ await element.updateComplete;
1457
+ // 150 > lastFiniteValue=100 → overflow step wins
1458
+ element.value = '150';
1459
+ assert.equal(element.value, '100+');
1460
+ });
1461
+
1462
+ test('step dots count equals steps.length', async () => {
1463
+ element.steps = ['Small', 'Medium', 'Large'];
1464
+ await element.updateComplete;
1465
+ assert.equal(element.shadowRoot!.querySelectorAll('.step-dot').length, 3);
1466
+ });
1467
+
1468
+ test('min-max labels show first and last step labels', async () => {
1469
+ element.steps = ['Small', 'Medium', 'Large'];
1470
+ await element.updateComplete;
1471
+ const labels = element.shadowRoot!.querySelectorAll('.min-max-label');
1472
+ assert.equal(labels[0].textContent!.trim(), 'Small');
1473
+ assert.equal(labels[1].textContent!.trim(), 'Large');
1474
+ });
1475
+
1476
+ test('updating steps snaps current value to first step when no longer a valid label', async () => {
1477
+ element.steps = ['A', 'B', 'C'];
1478
+ element.value = 'B';
1479
+ await element.updateComplete;
1480
+ element.steps = ['X', 'Y', 'Z'];
1481
+ await element.updateComplete;
1482
+ // 'B' not in new steps → snaps to first step
1483
+ assert.equal(element.value, 'X');
1484
+ });
1485
+
1486
+ test('value is included in form submission as the step label', async () => {
1487
+ const name = randString();
1488
+ element.setAttribute('name', name);
1489
+ element.steps = ['Small', 'Medium', 'Large'];
1490
+ element.value = 'Large';
1491
+ await element.updateComplete;
1492
+ assert.equal(new FormData(form).get(name), 'Large');
1493
+ });
1494
+
1495
+ test('object form steps expose label as public value and use numeric value for snapping', async () => {
1496
+ element.steps = [
1497
+ { value: 0, label: 'None' },
1498
+ { value: 500, label: 'Half' },
1499
+ { value: 1000, label: 'Full' },
1500
+ ];
1501
+ // |400-0|=400, |400-500|=100, |400-1000|=600 → snaps to 'Half'
1502
+ element.value = '400';
1503
+ await element.updateComplete;
1504
+ assert.equal(element.value, 'Half');
1505
+ });
1506
+
1507
+ test('showStepLabels renders a label element for each step', async () => {
1508
+ element.steps = ['Small', 'Medium', 'Large'];
1509
+ element.showStepLabels = true;
1510
+ await element.updateComplete;
1511
+ const labels = element.shadowRoot!.querySelectorAll('.step-dot-label');
1512
+ assert.equal(labels.length, 3);
1513
+ assert.equal(labels[0].textContent!.trim(), 'Small');
1514
+ assert.equal(labels[1].textContent!.trim(), 'Medium');
1515
+ assert.equal(labels[2].textContent!.trim(), 'Large');
1516
+ });
1517
+
1518
+ test('showStepLabels uses label when provided, value otherwise', async () => {
1519
+ element.steps = [{ value: 0, label: 'None' }, { value: 500 }, { value: 1000, label: 'Full' }];
1520
+ element.showStepLabels = true;
1521
+ await element.updateComplete;
1522
+ const labels = element.shadowRoot!.querySelectorAll('.step-dot-label');
1523
+ assert.equal(labels[0].textContent!.trim(), 'None');
1524
+ assert.equal(labels[1].textContent!.trim(), '500');
1525
+ assert.equal(labels[2].textContent!.trim(), 'Full');
1526
+ });
1527
+
1528
+ test('showStepLabels false renders no label elements', async () => {
1529
+ element.steps = ['Small', 'Medium', 'Large'];
1530
+ await element.updateComplete;
1531
+ assert.equal(element.shadowRoot!.querySelectorAll('.step-dot-label').length, 0);
1532
+ });
1533
+
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';
1537
+ await element.updateComplete;
1538
+ 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');
1549
+ });
1550
+
1551
+ test('committing empty steps floating input reverts display to current step label', async () => {
1552
+ element.steps = ['Small', 'Medium', 'Large'];
1553
+ element.value = 'Medium';
1554
+ await element.updateComplete;
1555
+ const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input')!;
1556
+ floatInput.value = '';
1557
+ floatInput.dispatchEvent(new Event('change'));
1558
+ assert.equal(element.value, 'Medium');
1559
+ assert.equal(floatInput.value, 'Medium');
1560
+ });
1561
+
1562
+ test('steps attribute is parsed from comma-separated string', async () => {
1563
+ element.setAttribute('steps', 'Small,Medium,Large');
1564
+ await element.updateComplete;
1565
+ assert.equal(element.value, 'Small');
1566
+ assert.equal(element.shadowRoot!.querySelectorAll('.step-dot').length, 3);
1567
+ });
1568
+
1569
+ test('form reset restores initial step label', async () => {
1570
+ const el = document.createElement('zui-slider') as ZuiSlider;
1571
+ const name = randString();
1572
+ el.setAttribute('name', name);
1573
+ el.steps = ['Small', 'Medium', 'Large'];
1574
+ el.value = 'Medium';
1575
+ const f = buildForm({ enableSubmit: false, appendChildren: [el] });
1576
+ await el.updateComplete;
1577
+
1578
+ el.value = 'Large';
1579
+ f.reset();
1580
+
1581
+ assert.equal(el.value, 'Medium');
1582
+ assert.equal(new FormData(f).get(name), 'Medium');
1583
+ document.body.removeChild(f);
1584
+ });
1585
+ });
1586
+
1587
+ suite('zui-slider steps range', () => {
1588
+ let element: ZuiSlider;
1589
+ let form: HTMLFormElement;
1590
+
1591
+ setup(() => {
1592
+ element = document.createElement('zui-slider') as ZuiSlider;
1593
+ element.setAttribute('range', '');
1594
+ form = buildForm({ enableSubmit: false, appendChildren: [element] });
1595
+ });
1596
+
1597
+ teardown(() => {
1598
+ document.body.removeChild(form);
1599
+ });
1600
+
1601
+ test('invalid default valueStart and valueEnd snap to first and last step', async () => {
1602
+ element.steps = ['A', 'B', 'C', 'D', 'E'];
1603
+ await element.updateComplete;
1604
+ assert.equal(element.valueStart, 'A');
1605
+ assert.equal(element.valueEnd, 'E');
1606
+ });
1607
+
1608
+ test('valid step labels for valueStart and valueEnd are preserved', async () => {
1609
+ element.steps = ['A', 'B', 'C', 'D', 'E'];
1610
+ element.valueStart = 'B';
1611
+ element.valueEnd = 'D';
1612
+ await element.updateComplete;
1613
+ assert.equal(element.valueStart, 'B');
1614
+ assert.equal(element.valueEnd, 'D');
1615
+ });
1616
+
1617
+ test('dragging start thumb to index equal to endIdx is rejected', async () => {
1618
+ element.steps = ['A', 'B', 'C', 'D', 'E'];
1619
+ await element.updateComplete;
1620
+ // valueStart='A'(idx=0), valueEnd='E'(idx=4)
1621
+ const startInput = element.shadowRoot!.querySelector<HTMLInputElement>('input.range-start')!;
1622
+ startInput.value = '4'; // equal to endIdx → rejected
1623
+ startInput.dispatchEvent(new Event('input'));
1624
+ assert.equal(element.valueStart, 'A');
1625
+ assert.equal(startInput.value, '0');
1626
+ });
1627
+
1628
+ test('dragging start thumb to valid index advances valueStart', async () => {
1629
+ element.steps = ['A', 'B', 'C', 'D', 'E'];
1630
+ await element.updateComplete;
1631
+ const startInput = element.shadowRoot!.querySelector<HTMLInputElement>('input.range-start')!;
1632
+ startInput.value = '2'; // index of 'C', less than endIdx=4 → accepted
1633
+ startInput.dispatchEvent(new Event('input'));
1634
+ assert.equal(element.valueStart, 'C');
1635
+ });
1636
+
1637
+ test('progressStart and progressEnd are computed by step index', async () => {
1638
+ element.steps = ['A', 'B', 'C', 'D', 'E'];
1639
+ await element.updateComplete;
1640
+ assert.equal(element.progressStart, 0); // 'A' at idx 0/4 = 0%
1641
+ assert.equal(element.progressEnd, 100); // 'E' at idx 4/4 = 100%
1642
+ element.valueStart = 'B'; // idx 1/4 = 25%
1643
+ element.valueEnd = 'D'; // idx 3/4 = 75%
1644
+ assert.equal(element.progressStart, 25);
1645
+ assert.equal(element.progressEnd, 75);
1646
+ });
1647
+
1648
+ test('form value is step labels joined as "start,end"', async () => {
1649
+ const name = randString();
1650
+ element.setAttribute('name', name);
1651
+ element.steps = ['A', 'B', 'C', 'D', 'E'];
1652
+ await element.updateComplete;
1653
+ assert.equal(new FormData(form).get(name), 'A,E');
1654
+ });
1655
+
1656
+ test('dynamic steps change snaps both invalid values to first and last step', async () => {
1657
+ element.steps = ['A', 'B', 'C'];
1658
+ element.valueStart = 'A';
1659
+ element.valueEnd = 'C';
1660
+ await element.updateComplete;
1661
+ element.steps = ['X', 'Y', 'Z'];
1662
+ await element.updateComplete;
1663
+ // 'A' and 'C' not in new steps → snap to first and last
1664
+ assert.equal(element.valueStart, 'X');
1665
+ assert.equal(element.valueEnd, 'Z');
1666
+ });
1667
+
1668
+ test('form reset restores initial step labels in range mode', async () => {
1669
+ const el = document.createElement('zui-slider') as ZuiSlider;
1670
+ el.setAttribute('range', '');
1671
+ const name = randString();
1672
+ el.setAttribute('name', name);
1673
+ el.steps = ['A', 'B', 'C', 'D', 'E'];
1674
+ el.valueStart = 'B';
1675
+ el.valueEnd = 'D';
1676
+ const f = buildForm({ enableSubmit: false, appendChildren: [el] });
1677
+ await el.updateComplete;
1678
+
1679
+ el.valueStart = 'A';
1680
+ el.valueEnd = 'E';
1681
+ f.reset();
1682
+
1683
+ assert.equal(el.valueStart, 'B');
1684
+ assert.equal(el.valueEnd, 'D');
1685
+ assert.equal(new FormData(f).get(name), 'B,D');
1686
+ document.body.removeChild(f);
1687
+ });
1688
+
1689
+ test('track click moves nearer thumb in steps mode', async () => {
1690
+ // 5 steps → indices 0-4 (total=4); fraction 0.25 → idx 1 ('B'), fraction 0.75 → idx 3 ('D')
1691
+ element.steps = ['A', 'B', 'C', 'D', 'E'];
1692
+ await element.updateComplete;
1693
+
1694
+ clickAtFraction(element, 0.25); // closer to start (idx=0 'A') than end (idx=4 'E') → moves start
1695
+ assert.equal(element.valueStart, 'B');
1696
+ assert.equal(element.valueEnd, 'E');
1697
+
1698
+ clickAtFraction(element, 0.75); // closer to end (idx=4 'E') than start (idx=1 'B') → moves end
1699
+ assert.equal(element.valueStart, 'B');
1700
+ assert.equal(element.valueEnd, 'D');
1701
+ });
1702
+ });
1703
+
1704
+ suite('zui-slider stepParser', () => {
1705
+ let element: ZuiSlider;
1706
+ let form: HTMLFormElement;
1707
+
1708
+ setup(() => {
1709
+ element = document.createElement('zui-slider') as ZuiSlider;
1710
+ form = buildForm({ enableSubmit: false, appendChildren: [element] });
1711
+ });
1712
+
1713
+ teardown(() => {
1714
+ document.body.removeChild(form);
1715
+ });
1716
+
1717
+ test('exact step label match does not invoke stepParser', async () => {
1718
+ let callCount = 0;
1719
+ element.steps = [
1720
+ { value: 1000, label: '1k' },
1721
+ { value: 2000, label: '2k' },
1722
+ { value: 3000, label: '3k' },
1723
+ ];
1724
+ element.stepParser = (input) => {
1725
+ callCount++;
1726
+ return parseFloat(input) * 1000;
1727
+ };
1728
+ await element.updateComplete;
1729
+ const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input')!;
1730
+ floatInput.value = '2k'; // exact step label — resolved before stepParser is consulted
1731
+ floatInput.dispatchEvent(new Event('change'));
1732
+ assert.equal(element.value, '2k');
1733
+ assert.equal(callCount, 0);
1734
+ });
1735
+
1736
+ test('number return from stepParser snaps to nearest step by numeric value', async () => {
1737
+ element.steps = [
1738
+ { value: 1000, label: '1k' },
1739
+ { value: 2000, label: '2k' },
1740
+ { value: 3000, label: '3k' },
1741
+ ];
1742
+ element.stepParser = (input) => {
1743
+ const m = input.match(/^([\d.]+)k$/);
1744
+ return m ? parseFloat(m[1]) * 1000 : null;
1745
+ };
1746
+ await element.updateComplete;
1747
+ const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input')!;
1748
+ floatInput.value = '1.5k'; // stepParser returns 1500; |1500-1000|=|1500-2000|=500 → tie → first ('1k') wins
1749
+ floatInput.dispatchEvent(new Event('change'));
1750
+ assert.equal(element.value, '1k');
1751
+ });
1752
+
1753
+ test('null return from stepParser reverts floating input to current value', async () => {
1754
+ element.steps = [
1755
+ { value: 1000, label: '1k' },
1756
+ { value: 2000, label: '2k' },
1757
+ { value: 3000, label: '3k' },
1758
+ ];
1759
+ element.stepParser = (input) => (input === 'bad' ? null : parseFloat(input) * 1000);
1760
+ element.value = '2k';
1761
+ await element.updateComplete;
1762
+ const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input')!;
1763
+ floatInput.value = 'bad';
1764
+ floatInput.dispatchEvent(new Event('change'));
1765
+ // null → rejected; floating input reverts to the current step value
1766
+ assert.equal(floatInput.value, '2k');
1767
+ assert.equal(element.value, '2k');
1768
+ });
1769
+
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
+ test('string return from stepParser is used directly as a step label', async () => {
1788
+ element.steps = [
1789
+ { value: 1000, label: '1k' },
1790
+ { value: 2000, label: '2k' },
1791
+ { value: 3000, label: '3k' },
1792
+ ];
1793
+ element.stepParser = (input) => (input.startsWith('3') ? '3k' : null);
1794
+ await element.updateComplete;
1795
+ const floatInput = element.shadowRoot!.querySelector<HTMLInputElement>('.thumb-input input')!;
1796
+ floatInput.value = '3000'; // stepParser returns '3k', an exact step label
1797
+ floatInput.dispatchEvent(new Event('change'));
1798
+ assert.equal(element.value, '3k');
1799
+ });
1800
+
1801
+ test('programmatic value set uses stepParser for non-label strings via #clampToRange', async () => {
1802
+ element.steps = [
1803
+ { value: 1000, label: '1k' },
1804
+ { value: 2000, label: '2k' },
1805
+ { value: 3000, label: '3k' },
1806
+ ];
1807
+ element.stepParser = (input) => {
1808
+ const m = input.match(/^([\d.]+)k$/);
1809
+ return m ? parseFloat(m[1]) * 1000 : null;
1810
+ };
1811
+ await element.updateComplete;
1812
+ // '2.5k' → stepParser returns 2500; |2500-2000|=|2500-3000|=500 → tie → '2k' wins
1813
+ element.value = '2.5k';
1814
+ assert.equal(element.value, '2k');
1815
+ });
1816
+
1817
+ test('stepParser resolves typed aliases in range mode floating inputs', async () => {
1818
+ element.setAttribute('range', '');
1819
+ element.steps = [
1820
+ { value: 1000, label: '1k' },
1821
+ { value: 2000, label: '2k' },
1822
+ { value: 3000, label: '3k' },
1823
+ ];
1824
+ element.stepParser = (input) => {
1825
+ const m = input.match(/^([\d.]+)k$/);
1826
+ return m ? parseFloat(m[1]) * 1000 : null;
1827
+ };
1828
+ await element.updateComplete;
1829
+ const floatInputs = element.shadowRoot!.querySelectorAll<HTMLInputElement>('.thumb-input input');
1830
+ // '1.5k' → stepParser returns 1500; tie at 500 from both '1k' and '2k' → first ('1k') wins
1831
+ floatInputs[0].value = '1.5k';
1832
+ floatInputs[0].dispatchEvent(new Event('change'));
1833
+ assert.equal(element.valueStart, '1k');
1834
+ // '2.5k' → stepParser returns 2500; tie → first ('2k') wins
1835
+ floatInputs[1].value = '2.5k';
1836
+ floatInputs[1].dispatchEvent(new Event('change'));
1837
+ assert.equal(element.valueEnd, '2k');
1838
+ });
1839
+ });