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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/zui-slider.ts CHANGED
@@ -69,21 +69,15 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
69
69
  {
70
70
  visible: boolean;
71
71
  focused: boolean;
72
+ committed: boolean;
72
73
  timer?: ReturnType<typeof setTimeout>;
73
- debounceTimer?: ReturnType<typeof setTimeout>;
74
74
  }
75
75
  >([
76
- ['thumb', { visible: false, focused: false }],
77
- ['startThumb', { visible: false, focused: false }],
78
- ['endThumb', { visible: false, focused: false }],
76
+ ['thumb', { visible: false, focused: false, committed: false }],
77
+ ['startThumb', { visible: false, focused: false, committed: false }],
78
+ ['endThumb', { visible: false, focused: false, committed: false }],
79
79
  ]);
80
80
 
81
- // Pre-bound floating input handlers cached to avoid new function references on every render
82
- #onThumbFloatingInput = this.#onFloatingInput('thumb', (v) => (this.value = v));
83
- #onStartThumbFloatingInput = this.#onFloatingInput('startThumb', (v) => (this.valueStart = v));
84
- #onEndThumbFloatingInput = this.#onFloatingInput('endThumb', (v) => (this.valueEnd = v));
85
-
86
- // Cached floating input change handlers; flush debounce and dispatch immediately on commit (Enter/blur)
87
81
  #onThumbFloatingChange = this.#makeFloatingChange(
88
82
  'thumb',
89
83
  (v) => (this.value = v),
@@ -100,30 +94,27 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
100
94
  () => this.#onRangeChange()
101
95
  );
102
96
 
103
- // Cached range drag input handlers
97
+ // Cached keydown handler: Enter commits the floating input value in both number and text modes.
98
+ #onFloatingInputKeydown = (e: KeyboardEvent) => {
99
+ if (e.key === 'Enter') {
100
+ e.preventDefault();
101
+ const input = e.target as HTMLInputElement;
102
+ input.dispatchEvent(new Event('change'));
103
+ }
104
+ };
105
+
104
106
  #onRangeStartInput = this.#onRangeInput('start');
105
107
  #onRangeEndInput = this.#onRangeInput('end');
108
+ #stopClickPropagation = (e: Event) => e.stopPropagation();
106
109
 
107
110
  // Cached pointer/focus handlers per thumb; prevents new closures on every render
108
- #h: Record<ThumbFlag, { show: () => void; hide: () => void; focus: () => void; blur: () => void }> = {
109
- thumb: {
110
- show: () => this.#showThumbInput('thumb'),
111
- hide: () => this.#scheduleHideThumbInput('thumb'),
112
- focus: () => this.#focusFloatingInput('thumb'),
113
- blur: () => this.#blurFloatingInput('thumb'),
114
- },
115
- startThumb: {
116
- show: () => this.#showThumbInput('startThumb'),
117
- hide: () => this.#scheduleHideThumbInput('startThumb'),
118
- focus: () => this.#focusFloatingInput('startThumb'),
119
- blur: () => this.#blurFloatingInput('startThumb'),
120
- },
121
- endThumb: {
122
- show: () => this.#showThumbInput('endThumb'),
123
- hide: () => this.#scheduleHideThumbInput('endThumb'),
124
- focus: () => this.#focusFloatingInput('endThumb'),
125
- blur: () => this.#blurFloatingInput('endThumb'),
126
- },
111
+ #h: Record<
112
+ ThumbFlag,
113
+ { show: () => void; hide: () => void; focus: () => void; input: () => void; blurCommit: (e: FocusEvent) => void }
114
+ > = {
115
+ thumb: this.#makeThumbHandlers('thumb'),
116
+ startThumb: this.#makeThumbHandlers('startThumb'),
117
+ endThumb: this.#makeThumbHandlers('endThumb'),
127
118
  };
128
119
 
129
120
  static get styles() {
@@ -243,11 +234,10 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
243
234
  #clearAllThumbInputState() {
244
235
  for (const entry of this.#thumbInputState.values()) {
245
236
  clearTimeout(entry.timer);
246
- clearTimeout(entry.debounceTimer);
247
237
  entry.timer = undefined;
248
- entry.debounceTimer = undefined;
249
238
  entry.visible = false;
250
239
  entry.focused = false;
240
+ entry.committed = false;
251
241
  }
252
242
  }
253
243
 
@@ -524,6 +514,7 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
524
514
  #renderSingle() {
525
515
  const progress = this.progress;
526
516
  const { nativeMin, nativeMax, nativeStep } = this.#nativeRangeAttrs;
517
+ // live() required: direct DOM writes during drag don't trigger a state change, so Lit won't re-sync without it.
527
518
  const nativeValue = this.#stepsMode ? String(Math.max(0, this.#stepsIndexOf(this.#value))) : this.#value;
528
519
  return html`
529
520
  <div class="single-wrapper">
@@ -534,7 +525,7 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
534
525
  .min="${nativeMin}"
535
526
  .max="${nativeMax}"
536
527
  .step="${nativeStep}"
537
- .value="${nativeValue}"
528
+ .value="${live(nativeValue)}"
538
529
  ?disabled="${this.disabled || this.readOnly}"
539
530
  @input="${this.#onInput}"
540
531
  @change="${this.#onChange}"
@@ -545,7 +536,6 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
545
536
  />
546
537
  ${this.#renderFloatingInput(
547
538
  this.#value,
548
- this.#onThumbFloatingInput,
549
539
  this.#onThumbFloatingChange,
550
540
  'thumb',
551
541
  this.#isVisible('thumb'),
@@ -564,7 +554,6 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
564
554
  ${this.#renderRangeInput('start', this.#rangeTrackBackground(progressStart, progressEnd))}
565
555
  ${this.#renderFloatingInput(
566
556
  this.#valueStart,
567
- this.#onStartThumbFloatingInput,
568
557
  this.#onStartThumbFloatingChange,
569
558
  'startThumb',
570
559
  this.#isVisible('startThumb'),
@@ -573,7 +562,6 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
573
562
  ${this.#renderRangeInput('end')}
574
563
  ${this.#renderFloatingInput(
575
564
  this.#valueEnd,
576
- this.#onEndThumbFloatingInput,
577
565
  this.#onEndThumbFloatingChange,
578
566
  'endThumb',
579
567
  this.#isVisible('endThumb'),
@@ -603,6 +591,7 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
603
591
  .step="${nativeStep}"
604
592
  .value="${live(nativeValue)}"
605
593
  ?disabled="${this.disabled || this.readOnly}"
594
+ @click="${this.#stopClickPropagation}"
606
595
  @input="${onInput}"
607
596
  @change="${this.#onRangeChange}"
608
597
  @pointerenter="${h.show}"
@@ -615,7 +604,6 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
615
604
 
616
605
  #renderFloatingInput(
617
606
  val: string,
618
- onInput: (e: Event) => void,
619
607
  onFloatingChange: (e: Event) => void,
620
608
  flag: ThumbFlag,
621
609
  visible: boolean,
@@ -623,13 +611,14 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
623
611
  ) {
624
612
  const h = this.#h[flag];
625
613
  // type="text" in steps mode to allow label and stepParser input.
626
- // live() required: same-value debounce resolutions skip reactive updates, so Lit won't re-sync without it.
614
+ // live() required: commits that snap/clamp to the current value skip reactive updates, so Lit won't re-sync without it.
627
615
  const ariaLabel =
628
616
  flag === 'startThumb' ? 'Range start value' : flag === 'endThumb' ? 'Range end value' : 'Slider value';
629
617
  return html`
630
618
  <div
631
619
  class=${classMap({ 'thumb-input': true, 'thumb-input--visible': visible })}
632
620
  style=${styleMap({ left: ZuiSlider.#thumbPositionCSS(progress) })}
621
+ @click="${this.#stopClickPropagation}"
633
622
  @pointerenter="${h.show}"
634
623
  @pointerleave="${h.hide}"
635
624
  >
@@ -642,10 +631,11 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
642
631
  .step="${this.#stepsMode ? '' : this.step > 0 ? String(this.step) : '1'}"
643
632
  ?disabled="${this.disabled}"
644
633
  ?readonly="${this.readOnly}"
645
- @input="${onInput}"
634
+ @keydown="${this.#onFloatingInputKeydown}"
635
+ @input="${h.input}"
646
636
  @change="${onFloatingChange}"
647
637
  @focus="${h.focus}"
648
- @blur="${h.blur}"
638
+ @blur="${h.blurCommit}"
649
639
  />
650
640
  </div>
651
641
  `;
@@ -702,7 +692,7 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
702
692
  const normalized = this.#normalizedSteps;
703
693
  const minLabel = this.#stepsMode ? normalized[0]?.label ?? '' : String(this.min);
704
694
  const maxLabel = this.#stepsMode ? normalized[normalized.length - 1]?.label ?? '' : String(this.max);
705
- const hidden = this.showStepLabels && this.#stepsMode;
695
+ const hidden = this.showStepLabels;
706
696
  return html`
707
697
  <div
708
698
  class="min-max-labels"
@@ -750,64 +740,83 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
750
740
  return this.#value;
751
741
  }
752
742
 
743
+ #makeThumbHandlers(flag: ThumbFlag) {
744
+ return {
745
+ show: () => this.#showThumbInput(flag),
746
+ hide: () => this.#scheduleHideThumbInput(flag),
747
+ focus: () => this.#focusFloatingInput(flag),
748
+ input: () => {
749
+ this.#thumbInputState.get(flag)!.committed = false;
750
+ },
751
+ blurCommit: (e: FocusEvent) => {
752
+ const entry = this.#thumbInputState.get(flag)!;
753
+ if (!entry.committed) {
754
+ (e.target as HTMLInputElement).dispatchEvent(new Event('change'));
755
+ }
756
+ entry.committed = false;
757
+ this.#blurFloatingInput(flag);
758
+ },
759
+ };
760
+ }
761
+
753
762
  #makeFloatingChange(flag: ThumbFlag, setter: (val: string) => void, dispatch: () => void) {
754
763
  return (e: Event) => {
755
764
  if (this.readOnly) {
756
765
  return;
757
766
  }
758
767
  const input = e.target as HTMLInputElement;
759
- const entry = this.#thumbInputState.get(flag)!;
760
- clearTimeout(entry.debounceTimer);
761
- entry.debounceTimer = undefined;
762
768
  if (input.value === '') {
763
769
  input.value = this.#currentValueForFlag(flag);
764
770
  return;
765
771
  }
772
+ const before = this.#currentValueForFlag(flag);
773
+ const commit = (value: string) => {
774
+ setter(value);
775
+ this.#thumbInputState.get(flag)!.committed = true;
776
+ if (this.#currentValueForFlag(flag) !== before) {
777
+ dispatch();
778
+ }
779
+ };
766
780
  if (this.#stepsMode) {
767
781
  const resolved = this.#resolveFloatingInput(input.value);
768
782
  if (resolved !== null) {
769
- setter(resolved);
770
- this.requestUpdate();
771
- dispatch();
783
+ let finalResolved = resolved;
784
+ if (flag !== 'thumb') {
785
+ const resolvedIdx = this.#stepsIndexOf(resolved);
786
+ const endIdx = Math.max(0, this.#stepsIndexOf(this.#valueEnd));
787
+ const startIdx = Math.max(0, this.#stepsIndexOf(this.#valueStart));
788
+ if (flag === 'startThumb' && resolvedIdx >= endIdx) {
789
+ const nudgedIdx = endIdx - 1;
790
+ finalResolved = nudgedIdx >= 0 ? this.#stepAt(nudgedIdx) : this.#currentValueForFlag(flag);
791
+ } else if (flag === 'endThumb' && resolvedIdx <= startIdx) {
792
+ const nudgedIdx = startIdx + 1;
793
+ finalResolved =
794
+ nudgedIdx < this.#normalizedSteps.length ? this.#stepAt(nudgedIdx) : this.#currentValueForFlag(flag);
795
+ }
796
+ }
797
+ commit(finalResolved);
772
798
  } else {
773
799
  input.value = this.#currentValueForFlag(flag);
774
800
  }
775
801
  } else {
776
- setter(this.#processFloatingValue(parseFloat(input.value)));
777
- this.requestUpdate();
778
- dispatch();
779
- }
780
- };
781
- }
782
-
783
- #onFloatingInput(flag: ThumbFlag, setter: (val: string) => void) {
784
- return (e: Event) => {
785
- if (this.readOnly) {
786
- return;
787
- }
788
- const input = e.target as HTMLInputElement;
789
- const entry = this.#thumbInputState.get(flag)!;
790
- clearTimeout(entry.debounceTimer);
791
- entry.debounceTimer = undefined;
792
- if (input.value === '') {
793
- return;
794
- }
795
- if (this.#stepsMode) {
796
- // Capture raw string at event time so the debounce closure reads the right value
797
- const raw = input.value;
798
- entry.debounceTimer = setTimeout(() => {
799
- const resolved = this.#resolveFloatingInput(raw);
800
- if (resolved !== null) {
801
- setter(resolved);
802
- this.requestUpdate();
802
+ let processed = this.#processFloatingValue(parseFloat(input.value));
803
+ if (flag !== 'thumb') {
804
+ const effectiveStep = this.step > 0 ? this.step : 1;
805
+ if (flag === 'startThumb') {
806
+ const endNum = parseFloat(this.#valueEnd);
807
+ if (parseFloat(processed) >= endNum) {
808
+ const nudged = this.#processFloatingValue(endNum - effectiveStep);
809
+ processed = parseFloat(nudged) < endNum ? nudged : this.#currentValueForFlag(flag);
810
+ }
811
+ } else {
812
+ const startNum = parseFloat(this.#valueStart);
813
+ if (parseFloat(processed) <= startNum) {
814
+ const nudged = this.#processFloatingValue(startNum + effectiveStep);
815
+ processed = parseFloat(nudged) > startNum ? nudged : this.#currentValueForFlag(flag);
816
+ }
803
817
  }
804
- }, 500);
805
- } else {
806
- const raw = parseFloat(input.value);
807
- entry.debounceTimer = setTimeout(() => {
808
- setter(this.#processFloatingValue(raw));
809
- this.requestUpdate();
810
- }, 500);
818
+ }
819
+ commit(processed);
811
820
  }
812
821
  };
813
822
  }
@@ -866,7 +875,6 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
866
875
  if (this.disabled || this.readOnly) {
867
876
  return;
868
877
  }
869
-
870
878
  const wrapper = e.currentTarget as HTMLElement;
871
879
  const rect = wrapper.getBoundingClientRect();
872
880
  // Thumb diameter is 3× the custom property; radius is 1.5×. Track is inset by one radius each side.
@@ -961,7 +969,9 @@ export class ZuiSlider extends ZuiFormAssociatedElement {
961
969
  return;
962
970
  }
963
971
  this.#showThumbInput(flag);
964
- this.#thumbInputState.get(flag)!.focused = true;
972
+ const entry = this.#thumbInputState.get(flag)!;
973
+ entry.focused = true;
974
+ entry.committed = false;
965
975
  }
966
976
 
967
977
  #blurFloatingInput(flag: ThumbFlag) {