bits-ui 1.5.2 → 1.6.0

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.
@@ -13,6 +13,60 @@ import { getDaysInMonth, isBefore, toDate } from "../../internal/date-time/utils
13
13
  import { getFirstSegment, handleSegmentNavigation, isSegmentNavigationKey, moveToNextSegment, moveToPrevSegment, } from "../../internal/date-time/field/segments.js";
14
14
  export const DATE_FIELD_INPUT_ATTR = "data-date-field-input";
15
15
  const DATE_FIELD_LABEL_ATTR = "data-date-field-label";
16
+ const SEGMENT_CONFIGS = {
17
+ day: {
18
+ min: 1,
19
+ max: (root) => {
20
+ const segmentMonthValue = root.segmentValues.month;
21
+ const placeholder = root.value.current ?? root.placeholder.current;
22
+ return segmentMonthValue
23
+ ? getDaysInMonth(placeholder.set({ month: Number.parseInt(segmentMonthValue) }))
24
+ : getDaysInMonth(placeholder);
25
+ },
26
+ cycle: 1,
27
+ padZero: true,
28
+ },
29
+ month: {
30
+ min: 1,
31
+ max: 12,
32
+ cycle: 1,
33
+ padZero: true,
34
+ getAnnouncement: (month, root) => `${month} - ${root.formatter.fullMonth(toDate(root.placeholder.current.set({ month })))}`,
35
+ },
36
+ year: {
37
+ min: 1,
38
+ max: 9999,
39
+ cycle: 1,
40
+ padZero: false,
41
+ },
42
+ hour: {
43
+ min: (root) => (root.hourCycle.current === 12 ? 1 : 0),
44
+ max: (root) => {
45
+ if (root.hourCycle.current === 24)
46
+ return 23;
47
+ if ("dayPeriod" in root.segmentValues && root.segmentValues.dayPeriod !== null)
48
+ return 12;
49
+ return 23;
50
+ },
51
+ cycle: 1,
52
+ canBeZero: true,
53
+ padZero: true,
54
+ },
55
+ minute: {
56
+ min: 0,
57
+ max: 59,
58
+ cycle: 1,
59
+ canBeZero: true,
60
+ padZero: true,
61
+ },
62
+ second: {
63
+ min: 0,
64
+ max: 59,
65
+ cycle: 1,
66
+ canBeZero: true,
67
+ padZero: true,
68
+ },
69
+ };
16
70
  export class DateFieldRootState {
17
71
  value;
18
72
  placeholder;
@@ -579,498 +633,277 @@ class DateFieldLabelState {
579
633
  onclick: this.onclick,
580
634
  }));
581
635
  }
582
- class DateFieldDaySegmentState {
636
+ // Base class for numeric segments
637
+ class BaseNumericSegmentState {
583
638
  opts;
584
639
  root;
585
- #announcer;
586
- constructor(opts, root) {
640
+ announcer;
641
+ part;
642
+ config;
643
+ constructor(opts, root, part, config) {
587
644
  this.opts = opts;
588
645
  this.root = root;
589
- this.#announcer = this.root.announcer;
646
+ this.part = part;
647
+ this.config = config;
648
+ this.announcer = root.announcer;
590
649
  this.onkeydown = this.onkeydown.bind(this);
591
650
  this.onfocusout = this.onfocusout.bind(this);
592
651
  useRefById(opts);
593
652
  }
653
+ #getMax() {
654
+ return typeof this.config.max === "function" ? this.config.max(this.root) : this.config.max;
655
+ }
656
+ #getMin() {
657
+ return typeof this.config.min === "function" ? this.config.min(this.root) : this.config.min;
658
+ }
659
+ #getAnnouncement(value) {
660
+ if (this.config.getAnnouncement) {
661
+ return this.config.getAnnouncement(value, this.root);
662
+ }
663
+ return value;
664
+ }
665
+ #formatValue(value, forDisplay = true) {
666
+ const str = String(value);
667
+ if (forDisplay && this.config.padZero && str.length === 1) {
668
+ return `0${value}`;
669
+ }
670
+ return str;
671
+ }
594
672
  onkeydown(e) {
595
- const placeholder = this.root.placeholder.current;
673
+ const placeholder = this.root.value.current ?? this.root.placeholder.current;
596
674
  if (e.ctrlKey || e.metaKey || this.root.disabled.current)
597
675
  return;
676
+ // Special check for time segments
677
+ if ((this.part === "hour" || this.part === "minute" || this.part === "second") &&
678
+ !(this.part in placeholder))
679
+ return;
598
680
  if (e.key !== kbd.TAB)
599
681
  e.preventDefault();
600
682
  if (!isAcceptableSegmentKey(e.key))
601
683
  return;
602
- const segmentMonthValue = this.root.segmentValues.month;
603
- const daysInMonth = segmentMonthValue
604
- ? getDaysInMonth(placeholder.set({ month: Number.parseInt(segmentMonthValue) }))
605
- : getDaysInMonth(placeholder);
606
684
  if (isArrowUp(e.key)) {
607
- this.root.updateSegment("day", (prev) => {
608
- if (prev === null) {
609
- const next = placeholder.day;
610
- this.#announcer.announce(next);
611
- if (next < 10)
612
- return `0${next}`;
613
- return `${next}`;
614
- }
615
- const next = placeholder.set({ day: Number.parseInt(prev) }).cycle("day", 1).day;
616
- this.#announcer.announce(next);
617
- if (next < 10)
618
- return `0${next}`;
619
- return `${next}`;
620
- });
685
+ this.#handleArrowUp(placeholder);
621
686
  return;
622
687
  }
623
688
  if (isArrowDown(e.key)) {
624
- this.root.updateSegment("day", (prev) => {
625
- if (prev === null) {
626
- const next = placeholder.day;
627
- this.#announcer.announce(next);
628
- if (next < 10)
629
- return `0${next}`;
630
- return `${next}`;
631
- }
632
- const next = placeholder.set({ day: Number.parseInt(prev) }).cycle("day", -1).day;
633
- this.#announcer.announce(next);
634
- if (next < 10)
635
- return `0${next}`;
636
- return `${next}`;
637
- });
689
+ this.#handleArrowDown(placeholder);
638
690
  return;
639
691
  }
640
- const fieldNode = this.root.getFieldNode();
641
692
  if (isNumberString(e.key)) {
642
- const num = Number.parseInt(e.key);
643
- let moveToNext = false;
644
- this.root.updateSegment("day", (prev) => {
645
- const max = daysInMonth;
646
- const maxStart = Math.floor(max / 10);
647
- const numIsZero = num === 0;
648
- /**
649
- * If the user has left the segment, we want to reset the
650
- * `prev` value so that we can start the segment over again
651
- * when the user types a number.
652
- */
653
- if (this.root.states.day.hasLeftFocus) {
654
- prev = null;
655
- this.root.states.day.hasLeftFocus = false;
656
- }
657
- /**
658
- * We are starting over in the segment if prev is null, which could
659
- * happen in one of two scenarios:
660
- * - the user has left the segment and then comes back to it
661
- * - the segment was empty and the user begins typing a number
662
- */
663
- if (prev === null) {
664
- /**
665
- * If the user types a 0 as the first number, we want
666
- * to keep track of that so that when they type the next
667
- * number, we can move to the next segment.
668
- */
669
- if (numIsZero) {
670
- this.root.states.day.lastKeyZero = true;
671
- this.#announcer.announce("0");
672
- return "0";
673
- }
674
- ///////////////////////////
675
- /**
676
- * If the last key was a 0, or if the first number is
677
- * greater than the max start digit (0-3 in most cases), then
678
- * we want to move to the next segment, since it's not possible
679
- * to continue typing a valid number in this segment.
680
- */
681
- if (this.root.states.day.lastKeyZero || num > maxStart) {
682
- moveToNext = true;
683
- }
684
- this.root.states.day.lastKeyZero = false;
685
- /**
686
- * If we're moving to the next segment and the number is less than
687
- * two digits, we want to announce the number and return it with a
688
- * leading zero to follow the placeholder format of `MM/DD/YYYY`.
689
- */
690
- if (moveToNext && String(num).length === 1) {
691
- this.#announcer.announce(num);
692
- return `0${num}`;
693
- }
694
- /**
695
- * If none of the above conditions are met, then we can just
696
- * return the number as the segment value and continue typing
697
- * in this segment.
698
- */
699
- return `${num}`;
700
- }
701
- /**
702
- * If the number of digits is 2, or if the total with the existing digit
703
- * and the pressed digit is greater than the maximum value for this
704
- * month, then we will reset the segment as if the user had pressed the
705
- * backspace key and then typed the number.
706
- */
707
- const total = Number.parseInt(prev + num.toString());
708
- if (this.root.states.day.lastKeyZero) {
709
- /**
710
- * If the new number is not 0, then we reset the lastKeyZero state and
711
- * move to the next segment, returning the new number with a leading 0.
712
- */
713
- if (num !== 0) {
714
- moveToNext = true;
715
- this.root.states.day.lastKeyZero = false;
716
- return `0${num}`;
717
- }
718
- /**
719
- * If the new number is 0, then we simply return the previous value, since
720
- * they didn't actually type a new number.
721
- */
722
- return prev;
723
- }
724
- /**
725
- * If the total is greater than the max day value possible for this month, then
726
- * we want to move to the next segment, trimming the first digit from the total,
727
- * replacing it with a 0.
728
- */
729
- if (total > max) {
730
- moveToNext = true;
731
- return `0${num}`;
732
- }
733
- /**
734
- * If the total has two digits and is less than or equal to the max day value,
735
- * we will move to the next segment and return the total as the segment value.
736
- */
737
- moveToNext = true;
738
- return `${total}`;
739
- });
740
- if (moveToNext) {
741
- moveToNextSegment(e, fieldNode);
742
- }
693
+ this.#handleNumberKey(e);
694
+ return;
743
695
  }
744
696
  if (isBackspace(e.key)) {
745
- let moveToPrev = false;
746
- this.root.updateSegment("day", (prev) => {
747
- this.root.states.day.hasLeftFocus = false;
748
- if (prev === null) {
749
- moveToPrev = true;
750
- return null;
751
- }
752
- if (prev.length === 2 && prev.startsWith("0")) {
753
- return null;
754
- }
755
- const str = prev.toString();
756
- if (str.length === 1)
757
- return null;
758
- return str.slice(0, -1);
759
- });
760
- if (moveToPrev) {
761
- moveToPrevSegment(e, fieldNode);
762
- }
697
+ this.#handleBackspace(e);
698
+ return;
763
699
  }
764
700
  if (isSegmentNavigationKey(e.key)) {
765
- handleSegmentNavigation(e, fieldNode);
701
+ handleSegmentNavigation(e, this.root.getFieldNode());
766
702
  }
767
703
  }
768
- onfocusout(_) {
769
- this.root.states.day.hasLeftFocus = true;
770
- this.root.updateSegment("month", (prev) => {
771
- if (prev && prev.length === 1) {
772
- return `0${prev}`;
704
+ #handleArrowUp(placeholder) {
705
+ const stateKey = this.part;
706
+ if (stateKey in this.root.states) {
707
+ this.root.states[stateKey].hasLeftFocus = false;
708
+ }
709
+ // @ts-expect-error this is a part
710
+ this.root.updateSegment(this.part, (prev) => {
711
+ if (prev === null) {
712
+ const next = placeholder[this.part];
713
+ this.announcer.announce(this.#getAnnouncement(next));
714
+ return this.#formatValue(next);
773
715
  }
774
- return prev;
716
+ const current = placeholder.set({
717
+ [this.part]: Number.parseInt(prev),
718
+ });
719
+ // @ts-expect-error this is a part
720
+ const next = current.cycle(this.part, this.config.cycle)[this.part];
721
+ this.announcer.announce(this.#getAnnouncement(next));
722
+ return this.#formatValue(next);
775
723
  });
776
724
  }
777
- props = $derived.by(() => {
778
- const date = this.root.segmentValues.day
779
- ? this.root.placeholder.current.set({
780
- day: Number.parseInt(this.root.segmentValues.day),
781
- })
782
- : this.root.placeholder.current;
783
- return {
784
- ...this.root.sharedSegmentAttrs,
785
- id: this.opts.id.current,
786
- "aria-label": "day,",
787
- "aria-valuemin": 1,
788
- "aria-valuemax": getDaysInMonth(toDate(date)),
789
- "aria-valuenow": date.day,
790
- "aria-valuetext": this.root.segmentValues.day === null ? "Empty" : `${date.day}`,
791
- onkeydown: this.onkeydown,
792
- onfocusout: this.onfocusout,
793
- onclick: this.root.handleSegmentClick,
794
- ...this.root.getBaseSegmentAttrs("day", this.opts.id.current),
795
- };
796
- });
797
- }
798
- class DateFieldMonthSegmentState {
799
- opts;
800
- root;
801
- #announcer;
802
- constructor(opts, root) {
803
- this.opts = opts;
804
- this.root = root;
805
- this.#announcer = this.root.announcer;
806
- this.onkeydown = this.onkeydown.bind(this);
807
- this.onfocusout = this.onfocusout.bind(this);
808
- useRefById(opts);
809
- }
810
- #getAnnouncement(month) {
811
- return `${month} - ${this.root.formatter.fullMonth(toDate(this.root.placeholder.current.set({ month })))}`;
725
+ #handleArrowDown(placeholder) {
726
+ const stateKey = this.part;
727
+ if (stateKey in this.root.states) {
728
+ this.root.states[stateKey].hasLeftFocus = false;
729
+ }
730
+ // @ts-expect-error this is a part
731
+ this.root.updateSegment(this.part, (prev) => {
732
+ if (prev === null) {
733
+ const next = placeholder[this.part];
734
+ this.announcer.announce(this.#getAnnouncement(next));
735
+ return this.#formatValue(next);
736
+ }
737
+ const current = placeholder.set({
738
+ [this.part]: Number.parseInt(prev),
739
+ });
740
+ // @ts-expect-error this is a part
741
+ const next = current.cycle(this.part, -this.config.cycle)[this.part];
742
+ this.announcer.announce(this.#getAnnouncement(next));
743
+ return this.#formatValue(next);
744
+ });
812
745
  }
813
- onkeydown(e) {
814
- if (e.ctrlKey || e.metaKey || this.root.disabled.current)
815
- return;
816
- if (e.key !== kbd.TAB)
817
- e.preventDefault();
818
- if (!isAcceptableSegmentKey(e.key))
819
- return;
820
- const max = 12;
821
- if (isArrowUp(e.key)) {
822
- this.root.updateSegment("month", (prev) => {
823
- if (prev === null) {
824
- const next = this.root.placeholder.current.month;
825
- this.#announcer.announce(this.#getAnnouncement(next));
826
- if (String(next).length === 1) {
827
- return `0${next}`;
746
+ #handleNumberKey(e) {
747
+ const num = Number.parseInt(e.key);
748
+ let moveToNext = false;
749
+ const max = this.#getMax();
750
+ const maxStart = Math.floor(max / 10);
751
+ const numIsZero = num === 0;
752
+ const stateKey = this.part;
753
+ // @ts-expect-error this is a part
754
+ this.root.updateSegment(this.part, (prev) => {
755
+ // Check if user has left focus
756
+ if (stateKey in this.root.states && this.root.states[stateKey].hasLeftFocus) {
757
+ prev = null;
758
+ this.root.states[stateKey].hasLeftFocus = false;
759
+ }
760
+ // Starting fresh
761
+ if (prev === null) {
762
+ if (numIsZero) {
763
+ if (stateKey in this.root.states) {
764
+ this.root.states[stateKey].lastKeyZero = true;
828
765
  }
829
- return `${next}`;
830
- }
831
- const next = this.root.placeholder.current
832
- .set({ month: Number.parseInt(prev) })
833
- .cycle("month", 1).month;
834
- this.#announcer.announce(this.#getAnnouncement(next));
835
- if (String(next).length === 1) {
836
- return `0${next}`;
766
+ this.announcer.announce("0");
767
+ return "0";
837
768
  }
838
- return `${next}`;
839
- });
840
- return;
841
- }
842
- if (isArrowDown(e.key)) {
843
- this.root.updateSegment("month", (prev) => {
844
- if (prev === null) {
845
- const next = this.root.placeholder.current.month;
846
- this.#announcer.announce(this.#getAnnouncement(next));
847
- if (String(next).length === 1) {
848
- return `0${next}`;
849
- }
850
- return `${next}`;
769
+ if (stateKey in this.root.states &&
770
+ (this.root.states[stateKey].lastKeyZero || num > maxStart)) {
771
+ moveToNext = true;
851
772
  }
852
- const next = this.root.placeholder.current
853
- .set({ month: Number.parseInt(prev) })
854
- .cycle("month", -1).month;
855
- this.#announcer.announce(this.#getAnnouncement(next));
856
- if (String(next).length === 1) {
857
- return `0${next}`;
773
+ if (stateKey in this.root.states) {
774
+ this.root.states[stateKey].lastKeyZero = false;
858
775
  }
859
- return `${next}`;
860
- });
861
- return;
862
- }
863
- if (isNumberString(e.key)) {
864
- const num = Number.parseInt(e.key);
865
- let moveToNext = false;
866
- this.root.updateSegment("month", (prev) => {
867
- const maxStart = Math.floor(max / 10);
868
- const numIsZero = num === 0;
869
- /**
870
- * If the user has left the segment, we want to reset the
871
- * `prev` value so that we can start the segment over again
872
- * when the user types a number.
873
- */
874
- if (this.root.states.month.hasLeftFocus) {
875
- prev = null;
876
- this.root.states.month.hasLeftFocus = false;
776
+ if (moveToNext && String(num).length === 1) {
777
+ this.announcer.announce(num);
778
+ return `0${num}`;
877
779
  }
878
- /**
879
- * We are starting over in the segment if prev is null, which could
880
- * happen in one of two scenarios:
881
- * - the user has left the segment and then comes back to it
882
- * - the segment was empty and the user begins typing a number
883
- */
884
- if (prev === null) {
885
- /**
886
- * If the user types a 0 as the first number, we want
887
- * to keep track of that so that when they type the next
888
- * number, we can move to the next segment.
889
- */
890
- if (numIsZero) {
891
- this.root.states.month.lastKeyZero = true;
892
- this.#announcer.announce("0");
893
- return "0";
894
- }
895
- ///////////////////////////
896
- /**
897
- * If the last key was a 0, or if the first number is
898
- * greater than the max start digit (0-3 in most cases), then
899
- * we want to move to the next segment, since it's not possible
900
- * to continue typing a valid number in this segment.
901
- */
902
- if (this.root.states.month.lastKeyZero || num > maxStart) {
903
- moveToNext = true;
904
- }
905
- this.root.states.month.lastKeyZero = false;
906
- /**
907
- * If we're moving to the next segment and the number is less than
908
- * two digits, we want to announce the number and return it with a
909
- * leading zero to follow the placeholder format of `MM/DD/YYYY`.
910
- */
911
- if (moveToNext && String(num).length === 1) {
912
- this.#announcer.announce(num);
913
- return `0${num}`;
914
- }
915
- /**
916
- * If none of the above conditions are met, then we can just
917
- * return the number as the segment value and continue typing
918
- * in this segment.
919
- */
920
- return `${num}`;
780
+ return `${num}`;
781
+ }
782
+ // Handle special cases for segments with lastKeyZero tracking
783
+ if (stateKey in this.root.states && this.root.states[stateKey].lastKeyZero) {
784
+ if (num !== 0) {
785
+ moveToNext = true;
786
+ this.root.states[stateKey].lastKeyZero = false;
787
+ return `0${num}`;
921
788
  }
922
- /**
923
- * If the number of digits is 2, or if the total with the existing digit
924
- * and the pressed digit is greater than the maximum value for this
925
- * month, then we will reset the segment as if the user had pressed the
926
- * backspace key and then typed the number.
927
- */
928
- const total = Number.parseInt(prev + num.toString());
929
- if (this.root.states.month.lastKeyZero) {
930
- /**
931
- * If the new number is not 0, then we reset the lastKeyZero state and
932
- * move to the next segment, returning the new number with a leading 0.
933
- */
934
- if (num !== 0) {
935
- moveToNext = true;
936
- this.root.states.month.lastKeyZero = false;
937
- return `0${num}`;
938
- }
939
- /**
940
- * If the new number is 0, then we simply return the previous value, since
941
- * they didn't actually type a new number.
942
- */
943
- return prev;
789
+ // Special handling for hour segment with 24-hour cycle
790
+ if (this.part === "hour" && num === 0 && this.root.hourCycle.current === 24) {
791
+ moveToNext = true;
792
+ this.root.states[stateKey].lastKeyZero = false;
793
+ return `00`;
944
794
  }
945
- /**
946
- * If the total is greater than the max day value possible for this month, then
947
- * we want to move to the next segment, trimming the first digit from the total,
948
- * replacing it with a 0.
949
- */
950
- if (total > max) {
795
+ // Special handling for minute/second segments
796
+ if ((this.part === "minute" || this.part === "second") && num === 0) {
951
797
  moveToNext = true;
952
- return `0${num}`;
798
+ this.root.states[stateKey].lastKeyZero = false;
799
+ return "00";
953
800
  }
954
- /**
955
- * If the total has two digits and is less than or equal to the max day value,
956
- * we will move to the next segment and return the total as the segment value.
957
- */
801
+ return prev;
802
+ }
803
+ const total = Number.parseInt(prev + num.toString());
804
+ if (total > max) {
958
805
  moveToNext = true;
959
- return `${total}`;
960
- });
961
- if (moveToNext) {
962
- moveToNextSegment(e, this.root.getFieldNode());
806
+ return `0${num}`;
963
807
  }
808
+ moveToNext = true;
809
+ return `${total}`;
810
+ });
811
+ if (moveToNext) {
812
+ moveToNextSegment(e, this.root.getFieldNode());
964
813
  }
965
- if (isBackspace(e.key)) {
966
- this.root.states.month.hasLeftFocus = false;
967
- let moveToPrev = false;
968
- this.root.updateSegment("month", (prev) => {
969
- if (prev === null) {
970
- this.#announcer.announce(null);
971
- moveToPrev = true;
972
- return null;
973
- }
974
- if (prev.length === 2 && prev.startsWith("0")) {
975
- this.#announcer.announce(null);
976
- return null;
977
- }
978
- const str = prev.toString();
979
- if (str.length === 1) {
980
- this.#announcer.announce(null);
981
- return null;
982
- }
983
- const next = Number.parseInt(str.slice(0, -1));
984
- this.#announcer.announce(this.#getAnnouncement(next));
985
- return `${next}`;
986
- });
987
- if (moveToPrev) {
988
- moveToPrevSegment(e, this.root.getFieldNode());
989
- }
814
+ }
815
+ #handleBackspace(e) {
816
+ const stateKey = this.part;
817
+ if (stateKey in this.root.states) {
818
+ this.root.states[stateKey].hasLeftFocus = false;
990
819
  }
991
- if (isSegmentNavigationKey(e.key)) {
992
- handleSegmentNavigation(e, this.root.getFieldNode());
820
+ let moveToPrev = false;
821
+ // @ts-expect-error this is a part
822
+ this.root.updateSegment(this.part, (prev) => {
823
+ if (prev === null) {
824
+ moveToPrev = true;
825
+ this.announcer.announce(null);
826
+ return null;
827
+ }
828
+ if (prev.length === 2 && prev.startsWith("0")) {
829
+ this.announcer.announce(null);
830
+ return null;
831
+ }
832
+ const str = prev.toString();
833
+ if (str.length === 1) {
834
+ this.announcer.announce(null);
835
+ return null;
836
+ }
837
+ const next = Number.parseInt(str.slice(0, -1));
838
+ this.announcer.announce(this.#getAnnouncement(next));
839
+ return `${next}`;
840
+ });
841
+ if (moveToPrev) {
842
+ moveToPrevSegment(e, this.root.getFieldNode());
993
843
  }
994
844
  }
995
845
  onfocusout(_) {
996
- this.root.states.month.hasLeftFocus = true;
997
- this.root.updateSegment("month", (prev) => {
998
- if (prev && prev.length === 1) {
999
- return `0${prev}`;
1000
- }
1001
- return prev;
1002
- });
846
+ const stateKey = this.part;
847
+ if (stateKey in this.root.states) {
848
+ this.root.states[stateKey].hasLeftFocus = true;
849
+ }
850
+ // Pad with zero if needed
851
+ if (this.config.padZero) {
852
+ // @ts-expect-error this is a part
853
+ this.root.updateSegment(this.part, (prev) => {
854
+ if (prev && prev.length === 1) {
855
+ return `0${prev}`;
856
+ }
857
+ return prev;
858
+ });
859
+ }
860
+ }
861
+ getSegmentProps() {
862
+ const segmentValues = this.root.segmentValues;
863
+ const placeholder = this.root.placeholder.current;
864
+ const isEmpty = segmentValues[this.part] === null;
865
+ let date = placeholder;
866
+ if (segmentValues[this.part]) {
867
+ date = placeholder.set({
868
+ [this.part]: Number.parseInt(segmentValues[this.part]),
869
+ });
870
+ }
871
+ const valueNow = date[this.part];
872
+ const valueMin = this.#getMin();
873
+ const valueMax = this.#getMax();
874
+ let valueText = isEmpty ? "Empty" : `${valueNow}`;
875
+ // Special handling for hour segment with dayPeriod
876
+ if (this.part === "hour" && "dayPeriod" in segmentValues && segmentValues.dayPeriod) {
877
+ valueText = isEmpty ? "Empty" : `${valueNow} ${segmentValues.dayPeriod}`;
878
+ }
879
+ return {
880
+ "aria-label": `${this.part}, `,
881
+ "aria-valuemin": valueMin,
882
+ "aria-valuemax": valueMax,
883
+ "aria-valuenow": valueNow,
884
+ "aria-valuetext": valueText,
885
+ };
1003
886
  }
1004
887
  props = $derived.by(() => {
1005
- const date = this.root.segmentValues.month
1006
- ? this.root.placeholder.current.set({
1007
- month: Number.parseInt(this.root.segmentValues.month),
1008
- })
1009
- : this.root.placeholder.current;
1010
888
  return {
1011
889
  ...this.root.sharedSegmentAttrs,
1012
890
  id: this.opts.id.current,
1013
- "aria-label": "month, ",
1014
- contenteditable: "true",
1015
- "aria-valuemin": 1,
1016
- "aria-valuemax": 12,
1017
- "aria-valuenow": date.month,
1018
- "aria-valuetext": this.root.segmentValues.month === null
1019
- ? "Empty"
1020
- : `${date.month} - ${this.root.formatter.fullMonth(toDate(date))}`,
891
+ ...this.getSegmentProps(),
1021
892
  onkeydown: this.onkeydown,
1022
893
  onfocusout: this.onfocusout,
1023
894
  onclick: this.root.handleSegmentClick,
1024
- ...this.root.getBaseSegmentAttrs("month", this.opts.id.current),
895
+ ...this.root.getBaseSegmentAttrs(this.part, this.opts.id.current),
1025
896
  };
1026
897
  });
1027
898
  }
1028
- class DateFieldYearSegmentState {
1029
- opts;
1030
- root;
1031
- #announcer;
1032
- /**
1033
- * When typing a year, a user may want to type `0090` to represent `90`.
1034
- * So we track the keys they've pressed in this specific interaction to
1035
- * determine once they've pressed four to move to the next segment.
1036
- *
1037
- * On `focusout` this is reset to an empty array.
1038
- */
899
+ // Year segment needs special handling
900
+ class DateFieldYearSegmentState extends BaseNumericSegmentState {
1039
901
  #pressedKeys = [];
1040
- /**
1041
- * When a user re-enters a completed segment and backspaces, if they have
1042
- * leading zeroes on the year, they won't automatically be sent to the next
1043
- * segment even if they complete all 4 digits. This is because the leading zeroes
1044
- * get stripped out for the digit count.
1045
- *
1046
- * This lets us keep track of how many times the user has backspaced in a row
1047
- * to determine how many additional key presses should move them to the next segment.
1048
- *
1049
- * For example, if the user has `0098` in the year segment and backspaces once,
1050
- * the segment will contain `009` and if the user types `7`, the segment should
1051
- * contain `0097` and move to the next segment.
1052
- *
1053
- * If the segment contains `0100` and the user backspaces twice, the segment will
1054
- * contain `01` and if the user types `2`, the segment should contain `012` and
1055
- * it should _not_ move to the next segment until the user types another digit.
1056
- */
1057
902
  #backspaceCount = 0;
1058
903
  constructor(opts, root) {
1059
- this.opts = opts;
1060
- this.root = root;
1061
- this.#announcer = this.root.announcer;
1062
- this.onkeydown = this.onkeydown.bind(this);
1063
- this.onfocusout = this.onfocusout.bind(this);
1064
- useRefById(opts);
1065
- }
1066
- #resetBackspaceCount() {
1067
- this.#backspaceCount = 0;
1068
- }
1069
- #incrementBackspaceCount() {
1070
- this.#backspaceCount++;
904
+ super(opts, root, "year", SEGMENT_CONFIGS.year);
1071
905
  }
1072
906
  onkeydown(e) {
1073
- const placeholder = this.root.placeholder.current;
1074
907
  if (e.ctrlKey || e.metaKey || this.root.disabled.current)
1075
908
  return;
1076
909
  if (e.key !== kbd.TAB)
@@ -1079,114 +912,97 @@ class DateFieldYearSegmentState {
1079
912
  return;
1080
913
  if (isArrowUp(e.key)) {
1081
914
  this.#resetBackspaceCount();
1082
- this.root.updateSegment("year", (prev) => {
1083
- if (prev === null) {
1084
- const next = placeholder.year;
1085
- this.#announcer.announce(next);
1086
- return `${next}`;
1087
- }
1088
- const next = placeholder.set({ year: Number.parseInt(prev) }).cycle("year", 1).year;
1089
- this.#announcer.announce(next);
1090
- return `${next}`;
1091
- });
915
+ super.onkeydown(e);
1092
916
  return;
1093
917
  }
1094
918
  if (isArrowDown(e.key)) {
1095
919
  this.#resetBackspaceCount();
1096
- this.root.updateSegment("year", (prev) => {
1097
- if (prev === null) {
1098
- const next = placeholder.year;
1099
- this.#announcer.announce(next);
1100
- return `${next}`;
1101
- }
1102
- const next = placeholder
1103
- .set({ year: Number.parseInt(prev) })
1104
- .cycle("year", -1).year;
1105
- this.#announcer.announce(next);
1106
- return `${next}`;
1107
- });
920
+ super.onkeydown(e);
1108
921
  return;
1109
922
  }
1110
923
  if (isNumberString(e.key)) {
1111
- this.#pressedKeys.push(e.key);
1112
- let moveToNext = false;
1113
- const num = Number.parseInt(e.key);
1114
- this.root.updateSegment("year", (prev) => {
1115
- if (this.root.states.year.hasLeftFocus) {
1116
- prev = null;
1117
- this.root.states.year.hasLeftFocus = false;
1118
- }
1119
- if (prev === null) {
1120
- this.#announcer.announce(num);
1121
- return `000${num}`;
1122
- }
1123
- const str = prev.toString() + num.toString();
1124
- const mergedInt = Number.parseInt(str);
1125
- const mergedIntDigits = String(mergedInt).length;
1126
- if (mergedIntDigits < 4) {
1127
- /**
1128
- * If the user has backspaced and hasn't typed enough digits to make up
1129
- * for the amount of backspaces, then we want to keep them in the segment
1130
- * and not prepend any zeroes to the number.
1131
- */
1132
- if (this.#backspaceCount > 0 &&
1133
- this.#pressedKeys.length <= this.#backspaceCount &&
1134
- str.length <= 4) {
1135
- this.#announcer.announce(mergedInt);
1136
- return str;
1137
- }
1138
- /**
1139
- * If the mergedInt is less than 4 digits and we haven't backspaced,
1140
- * then we want to prepend zeroes to the number to keep the format
1141
- * of `YYYY`
1142
- */
1143
- this.#announcer.announce(mergedInt);
1144
- return prependYearZeros(mergedInt);
1145
- }
1146
- this.#announcer.announce(mergedInt);
1147
- moveToNext = true;
1148
- const mergedIntStr = `${mergedInt}`;
1149
- if (mergedIntStr.length > 4) {
1150
- return mergedIntStr.slice(0, 4);
1151
- }
1152
- return mergedIntStr;
1153
- });
1154
- if (this.#pressedKeys.length === 4 ||
1155
- this.#pressedKeys.length === this.#backspaceCount) {
1156
- moveToNext = true;
1157
- }
1158
- if (moveToNext) {
1159
- moveToNextSegment(e, this.root.getFieldNode());
1160
- }
924
+ this.#handleYearNumberKey(e);
925
+ return;
1161
926
  }
1162
927
  if (isBackspace(e.key)) {
1163
- this.#pressedKeys = [];
1164
- this.#incrementBackspaceCount();
1165
- let moveToPrev = false;
1166
- this.root.updateSegment("year", (prev) => {
1167
- this.root.states.year.hasLeftFocus = false;
1168
- if (prev === null) {
1169
- moveToPrev = true;
1170
- this.#announcer.announce(null);
1171
- return null;
1172
- }
1173
- const str = prev.toString();
1174
- if (str.length === 1) {
1175
- this.#announcer.announce(null);
1176
- return null;
1177
- }
1178
- const next = str.slice(0, -1);
1179
- this.#announcer.announce(next);
1180
- return `${next}`;
1181
- });
1182
- if (moveToPrev) {
1183
- moveToPrevSegment(e, this.root.getFieldNode());
1184
- }
928
+ this.#handleYearBackspace(e);
929
+ return;
1185
930
  }
1186
931
  if (isSegmentNavigationKey(e.key)) {
1187
932
  handleSegmentNavigation(e, this.root.getFieldNode());
1188
933
  }
1189
934
  }
935
+ #resetBackspaceCount() {
936
+ this.#backspaceCount = 0;
937
+ }
938
+ #incrementBackspaceCount() {
939
+ this.#backspaceCount++;
940
+ }
941
+ #handleYearNumberKey(e) {
942
+ this.#pressedKeys.push(e.key);
943
+ let moveToNext = false;
944
+ const num = Number.parseInt(e.key);
945
+ this.root.updateSegment("year", (prev) => {
946
+ if (this.root.states.year.hasLeftFocus) {
947
+ prev = null;
948
+ this.root.states.year.hasLeftFocus = false;
949
+ }
950
+ if (prev === null) {
951
+ this.announcer.announce(num);
952
+ return `000${num}`;
953
+ }
954
+ const str = prev.toString() + num.toString();
955
+ const mergedInt = Number.parseInt(str);
956
+ const mergedIntDigits = String(mergedInt).length;
957
+ if (mergedIntDigits < 4) {
958
+ if (this.#backspaceCount > 0 &&
959
+ this.#pressedKeys.length <= this.#backspaceCount &&
960
+ str.length <= 4) {
961
+ this.announcer.announce(mergedInt);
962
+ return str;
963
+ }
964
+ this.announcer.announce(mergedInt);
965
+ return prependYearZeros(mergedInt);
966
+ }
967
+ this.announcer.announce(mergedInt);
968
+ moveToNext = true;
969
+ const mergedIntStr = `${mergedInt}`;
970
+ if (mergedIntStr.length > 4) {
971
+ return mergedIntStr.slice(0, 4);
972
+ }
973
+ return mergedIntStr;
974
+ });
975
+ if (this.#pressedKeys.length === 4 || this.#pressedKeys.length === this.#backspaceCount) {
976
+ moveToNext = true;
977
+ }
978
+ if (moveToNext) {
979
+ moveToNextSegment(e, this.root.getFieldNode());
980
+ }
981
+ }
982
+ #handleYearBackspace(e) {
983
+ this.#pressedKeys = [];
984
+ this.#incrementBackspaceCount();
985
+ let moveToPrev = false;
986
+ this.root.updateSegment("year", (prev) => {
987
+ this.root.states.year.hasLeftFocus = false;
988
+ if (prev === null) {
989
+ moveToPrev = true;
990
+ this.announcer.announce(null);
991
+ return null;
992
+ }
993
+ const str = prev.toString();
994
+ if (str.length === 1) {
995
+ this.announcer.announce(null);
996
+ return null;
997
+ }
998
+ const next = str.slice(0, -1);
999
+ this.announcer.announce(next);
1000
+ return `${next}`;
1001
+ });
1002
+ if (moveToPrev) {
1003
+ moveToPrevSegment(e, this.root.getFieldNode());
1004
+ }
1005
+ }
1190
1006
  onfocusout(_) {
1191
1007
  this.root.states.year.hasLeftFocus = true;
1192
1008
  this.#pressedKeys = [];
@@ -1198,703 +1014,56 @@ class DateFieldYearSegmentState {
1198
1014
  return prev;
1199
1015
  });
1200
1016
  }
1201
- props = $derived.by(() => {
1202
- const segmentValues = this.root.segmentValues;
1203
- const placeholder = this.root.placeholder.current;
1204
- const isEmpty = segmentValues.year === null;
1205
- const date = segmentValues.year
1206
- ? placeholder.set({ year: Number.parseInt(segmentValues.year) })
1207
- : placeholder;
1208
- const valueMin = 1;
1209
- const valueMax = 9999;
1210
- const valueNow = date.year;
1211
- const valueText = isEmpty ? "Empty" : `${valueNow}`;
1212
- return {
1213
- ...this.root.sharedSegmentAttrs,
1214
- id: this.opts.id.current,
1215
- "aria-label": "year, ",
1216
- "aria-valuemin": valueMin,
1217
- "aria-valuemax": valueMax,
1218
- "aria-valuenow": valueNow,
1219
- "aria-valuetext": valueText,
1220
- onkeydown: this.onkeydown,
1221
- onclick: this.root.handleSegmentClick,
1222
- onfocusout: this.onfocusout,
1223
- ...this.root.getBaseSegmentAttrs("year", this.opts.id.current),
1224
- };
1225
- });
1226
1017
  }
1227
- class DateFieldHourSegmentState {
1228
- opts;
1229
- root;
1230
- #announcer;
1018
+ // Create segment states using the base class
1019
+ class DateFieldDaySegmentState extends BaseNumericSegmentState {
1231
1020
  constructor(opts, root) {
1232
- this.opts = opts;
1233
- this.root = root;
1234
- this.#announcer = this.root.announcer;
1235
- this.onkeydown = this.onkeydown.bind(this);
1236
- this.onfocusout = this.onfocusout.bind(this);
1237
- useRefById(opts);
1238
- }
1239
- onkeydown(e) {
1240
- const placeholder = this.root.placeholder.current;
1241
- if (e.ctrlKey || e.metaKey || this.root.disabled.current || !("hour" in placeholder))
1242
- return;
1243
- if (e.key !== kbd.TAB)
1244
- e.preventDefault();
1245
- if (!isAcceptableSegmentKey(e.key))
1246
- return;
1247
- const hourCycle = this.root.hourCycle.current;
1248
- if (isArrowUp(e.key)) {
1249
- this.root.updateSegment("hour", (prev) => {
1250
- if (prev === null) {
1251
- const next = placeholder.cycle("hour", 1, { hourCycle }).hour;
1252
- this.#announcer.announce(next);
1253
- return `${next}`;
1254
- }
1255
- const next = placeholder
1256
- .set({ hour: Number.parseInt(prev) })
1257
- .cycle("hour", 1, { hourCycle }).hour;
1258
- if (next === 0 &&
1259
- "dayPeriod" in this.root.segmentValues &&
1260
- this.root.segmentValues.dayPeriod !== null &&
1261
- this.root.hourCycle.current !== 24) {
1262
- this.#announcer.announce("12");
1263
- return "12";
1264
- }
1265
- if (next === 0 && this.root.hourCycle.current === 24) {
1266
- this.#announcer.announce("00");
1267
- return "00";
1268
- }
1269
- this.#announcer.announce(next);
1270
- return `${next}`;
1271
- });
1272
- return;
1273
- }
1274
- if (isArrowDown(e.key)) {
1275
- this.root.updateSegment("hour", (prev) => {
1276
- if (prev === null) {
1277
- const next = placeholder.cycle("hour", -1, { hourCycle }).hour;
1278
- this.#announcer.announce(next);
1279
- return `${next}`;
1280
- }
1281
- const next = placeholder
1282
- .set({ hour: Number.parseInt(prev) })
1283
- .cycle("hour", -1, { hourCycle }).hour;
1284
- if (next === 0 &&
1285
- "dayPeriod" in this.root.segmentValues &&
1286
- this.root.segmentValues.dayPeriod !== null &&
1287
- this.root.hourCycle.current !== 24) {
1288
- this.#announcer.announce("12");
1289
- return "12";
1290
- }
1291
- if (next === 0 && this.root.hourCycle.current === 24) {
1292
- this.#announcer.announce("00");
1293
- return "00";
1294
- }
1295
- this.#announcer.announce(next);
1296
- return `${next}`;
1297
- });
1298
- return;
1299
- }
1300
- if (isNumberString(e.key)) {
1301
- const num = Number.parseInt(e.key);
1302
- const max = this.root.hourCycle.current === 24
1303
- ? 23
1304
- : "dayPeriod" in this.root.segmentValues &&
1305
- this.root.segmentValues.dayPeriod !== null
1306
- ? 12
1307
- : 23;
1308
- const maxStart = Math.floor(max / 10);
1309
- let moveToNext = false;
1310
- const numIsZero = num === 0;
1311
- this.root.updateSegment("hour", (prev) => {
1312
- /**
1313
- * If the user has left the segment, we want to reset the
1314
- * `prev` value so that we can start the segment over again
1315
- * when the user types a number.
1316
- */
1317
- if (this.root.states.hour.hasLeftFocus) {
1318
- prev = null;
1319
- this.root.states.hour.hasLeftFocus = false;
1320
- }
1321
- /**
1322
- * We are starting over in the segment if prev is null, which could
1323
- * happen in one of two scenarios:
1324
- * - the user has left the segment and then comes back to it
1325
- * - the segment was empty and the user begins typing a number
1326
- */
1327
- if (prev === null) {
1328
- /**
1329
- * If the user types a 0 as the first number, we want
1330
- * to keep track of that so that when they type the next
1331
- * number, we can move to the next segment.
1332
- */
1333
- if (numIsZero) {
1334
- this.root.states.hour.lastKeyZero = true;
1335
- this.#announcer.announce("0");
1336
- return "0";
1337
- }
1338
- ///////////////////////////
1339
- /**
1340
- * If the last key was a 0, or if the first number is
1341
- * greater than the max start digit (0-3 in most cases), then
1342
- * we want to move to the next segment, since it's not possible
1343
- * to continue typing a valid number in this segment.
1344
- */
1345
- if (this.root.states.hour.lastKeyZero || num > maxStart) {
1346
- moveToNext = true;
1347
- }
1348
- this.root.states.hour.lastKeyZero = false;
1349
- /**
1350
- * If we're moving to the next segment and the number is less than
1351
- * two digits, we want to announce the number and return it with a
1352
- * leading zero to follow the placeholder format of `MM/DD/YYYY`.
1353
- */
1354
- if (moveToNext && String(num).length === 1) {
1355
- this.#announcer.announce(num);
1356
- return `0${num}`;
1357
- }
1358
- /**
1359
- * If none of the above conditions are met, then we can just
1360
- * return the number as the segment value and continue typing
1361
- * in this segment.
1362
- */
1363
- return `${num}`;
1364
- }
1365
- /**
1366
- * If the number of digits is 2, or if the total with the existing digit
1367
- * and the pressed digit is greater than the maximum value for this
1368
- * hour, then we will reset the segment as if the user had pressed the
1369
- * backspace key and then typed the number.
1370
- */
1371
- const total = Number.parseInt(prev + num.toString());
1372
- if (this.root.states.hour.lastKeyZero) {
1373
- /**
1374
- * If the new number is not 0, then we reset the lastKeyZero state and
1375
- * move to the next segment, returning the new number with a leading 0.
1376
- */
1377
- if (num !== 0) {
1378
- moveToNext = true;
1379
- this.root.states.hour.lastKeyZero = false;
1380
- return `0${num}`;
1381
- }
1382
- /**
1383
- * If the new number is 0 and the hour cycle is set to 24, then we move
1384
- * to the next segment, returning the new number with a leading 0.
1385
- */
1386
- if (num === 0 && this.root.hourCycle.current === 24) {
1387
- moveToNext = true;
1388
- this.root.states.hour.lastKeyZero = false;
1389
- return `0${num}`;
1390
- }
1391
- /**
1392
- * If the new number is 0, then we simply return the previous value, since
1393
- * they didn't actually type a new number.
1394
- */
1395
- return prev;
1396
- }
1397
- /**
1398
- * If the total is greater than the max day value possible for this month, then
1399
- * we want to move to the next segment, trimming the first digit from the total,
1400
- * replacing it with a 0.
1401
- */
1402
- if (total > max) {
1403
- moveToNext = true;
1404
- return `0${num}`;
1405
- }
1406
- /**
1407
- * If the total has two digits and is less than or equal to the max day value,
1408
- * we will move to the next segment and return the total as the segment value.
1409
- */
1410
- moveToNext = true;
1411
- return `${total}`;
1412
- });
1413
- if (moveToNext) {
1414
- moveToNextSegment(e, this.root.getFieldNode());
1415
- }
1416
- }
1417
- if (isBackspace(e.key)) {
1418
- this.root.states.hour.hasLeftFocus = false;
1419
- let moveToPrev = false;
1420
- this.root.updateSegment("hour", (prev) => {
1421
- if (prev === null) {
1422
- this.#announcer.announce(null);
1423
- moveToPrev = true;
1424
- return null;
1425
- }
1426
- const str = prev.toString();
1427
- if (str.length === 1) {
1428
- this.#announcer.announce(null);
1429
- return null;
1430
- }
1431
- const next = Number.parseInt(str.slice(0, -1));
1432
- this.#announcer.announce(next);
1433
- return `${next}`;
1434
- });
1435
- if (moveToPrev) {
1436
- moveToPrevSegment(e, this.root.getFieldNode());
1437
- }
1438
- }
1439
- if (isSegmentNavigationKey(e.key)) {
1440
- handleSegmentNavigation(e, this.root.getFieldNode());
1441
- }
1021
+ super(opts, root, "day", SEGMENT_CONFIGS.day);
1442
1022
  }
1443
- onfocusout(_) {
1444
- this.root.states.hour.hasLeftFocus = true;
1023
+ }
1024
+ class DateFieldMonthSegmentState extends BaseNumericSegmentState {
1025
+ constructor(opts, root) {
1026
+ super(opts, root, "month", SEGMENT_CONFIGS.month);
1445
1027
  }
1446
- props = $derived.by(() => {
1447
- const segmentValues = this.root.segmentValues;
1448
- const hourCycle = this.root.hourCycle.current;
1449
- const placeholder = this.root.placeholder.current;
1450
- if (!("hour" in segmentValues) || !("hour" in placeholder))
1451
- return {};
1452
- const isEmpty = segmentValues.hour === null;
1453
- const date = segmentValues.hour
1454
- ? placeholder.set({ hour: Number.parseInt(segmentValues.hour) })
1455
- : placeholder;
1456
- const valueMin = hourCycle === 12 ? 1 : 0;
1457
- const valueMax = hourCycle === 12 ? 12 : 23;
1458
- const valueNow = date.hour;
1459
- const valueText = isEmpty ? "Empty" : `${valueNow} ${segmentValues.dayPeriod ?? ""}`;
1460
- return {
1461
- ...this.root.sharedSegmentAttrs,
1462
- id: this.opts.id.current,
1463
- "aria-label": "hour, ",
1464
- "aria-valuemin": valueMin,
1465
- "aria-valuemax": valueMax,
1466
- "aria-valuenow": valueNow,
1467
- "aria-valuetext": valueText,
1468
- onkeydown: this.onkeydown,
1469
- onfocusout: this.onfocusout,
1470
- onclick: this.root.handleSegmentClick,
1471
- ...this.root.getBaseSegmentAttrs("hour", this.opts.id.current),
1472
- };
1473
- });
1474
1028
  }
1475
- class DateFieldMinuteSegmentState {
1476
- opts;
1477
- root;
1478
- #announcer;
1029
+ class DateFieldHourSegmentState extends BaseNumericSegmentState {
1479
1030
  constructor(opts, root) {
1480
- this.opts = opts;
1481
- this.root = root;
1482
- this.#announcer = this.root.announcer;
1483
- this.onkeydown = this.onkeydown.bind(this);
1484
- this.onfocusout = this.onfocusout.bind(this);
1485
- useRefById(opts);
1031
+ super(opts, root, "hour", SEGMENT_CONFIGS.hour);
1486
1032
  }
1033
+ // Override to handle special hour logic
1487
1034
  onkeydown(e) {
1488
- const placeholder = this.root.placeholder.current;
1489
- if (e.ctrlKey || e.metaKey || this.root.disabled.current || !("minute" in placeholder))
1490
- return;
1491
- if (e.key !== kbd.TAB)
1492
- e.preventDefault();
1493
- if (!isAcceptableSegmentKey(e.key))
1494
- return;
1495
- const min = 0;
1496
- const max = 59;
1497
- if (isArrowUp(e.key)) {
1498
- this.root.updateSegment("minute", (prev) => {
1499
- if (prev === null) {
1500
- this.#announcer.announce(min);
1501
- return `${min}`;
1502
- }
1503
- const next = placeholder
1504
- .set({ minute: Number.parseInt(prev) })
1505
- .cycle("minute", 1).minute;
1506
- this.#announcer.announce(next);
1507
- return `${next}`;
1508
- });
1509
- return;
1510
- }
1511
- if (isArrowDown(e.key)) {
1512
- this.root.updateSegment("minute", (prev) => {
1513
- if (prev === null) {
1514
- this.#announcer.announce(max);
1515
- return `${max}`;
1516
- }
1517
- const next = placeholder
1518
- .set({ minute: Number.parseInt(prev) })
1519
- .cycle("minute", -1).minute;
1520
- this.#announcer.announce(next);
1521
- return `${next}`;
1522
- });
1523
- return;
1524
- }
1035
+ // Add special handling for hour display with dayPeriod
1525
1036
  if (isNumberString(e.key)) {
1526
- const num = Number.parseInt(e.key);
1527
- let moveToNext = false;
1528
- const numIsZero = num === 0;
1529
- this.root.updateSegment("minute", (prev) => {
1530
- const maxStart = Math.floor(max / 10);
1531
- /**
1532
- * If the user has left the segment, we want to reset the
1533
- * `prev` value so that we can start the segment over again
1534
- * when the user types a number.
1535
- */
1536
- if (this.root.states.minute.hasLeftFocus) {
1537
- prev = null;
1538
- this.root.states.minute.hasLeftFocus = false;
1539
- }
1540
- /**
1541
- * We are starting over in the segment if prev is null, which could
1542
- * happen in one of two scenarios:
1543
- * - the user has left the segment and then comes back to it
1544
- * - the segment was empty and the user begins typing a number
1545
- */
1546
- if (prev === null) {
1547
- /**
1548
- * If the user types a 0 as the first number, we want
1549
- * to keep track of that so that when they type the next
1550
- * number, we can move to the next segment.
1551
- */
1552
- if (numIsZero) {
1553
- this.root.states.minute.lastKeyZero = true;
1554
- this.#announcer.announce("0");
1555
- return "0";
1037
+ const oldUpdateSegment = this.root.updateSegment.bind(this.root);
1038
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1039
+ this.root.updateSegment = (part, cb) => {
1040
+ const result = oldUpdateSegment(part, cb);
1041
+ // After updating hour, check if we need to display "12" instead of "0"
1042
+ if (part === "hour" && "hour" in this.root.segmentValues) {
1043
+ const hourValue = this.root.segmentValues.hour;
1044
+ if (hourValue === "0" &&
1045
+ this.root.dayPeriodNode &&
1046
+ this.root.hourCycle.current !== 24) {
1047
+ this.root.segmentValues.hour = "12";
1556
1048
  }
1557
- ///////////////////////////
1558
- /**
1559
- * If the last key was a 0, or if the first number is
1560
- * greater than the max start digit (0-3 in most cases), then
1561
- * we want to move to the next segment, since it's not possible
1562
- * to continue typing a valid number in this segment.
1563
- */
1564
- if (this.root.states.minute.lastKeyZero || num > maxStart) {
1565
- moveToNext = true;
1566
- }
1567
- this.root.states.minute.lastKeyZero = false;
1568
- /**
1569
- * If we're moving to the next segment and the number is less than
1570
- * two digits, we want to announce the number and return it with a
1571
- * leading zero to follow the placeholder format of `MM/DD/YYYY`.
1572
- */
1573
- if (moveToNext && String(num).length === 1) {
1574
- this.#announcer.announce(num);
1575
- return `0${num}`;
1576
- }
1577
- /**
1578
- * If none of the above conditions are met, then we can just
1579
- * return the number as the segment value and continue typing
1580
- * in this segment.
1581
- */
1582
- return `${num}`;
1583
1049
  }
1584
- /**
1585
- * If the number of digits is 2, or if the total with the existing digit
1586
- * and the pressed digit is greater than the maximum value for this
1587
- * minute, then we will reset the segment as if the user had pressed the
1588
- * backspace key and then typed the number.
1589
- */
1590
- const total = Number.parseInt(prev + num.toString());
1591
- if (this.root.states.minute.lastKeyZero) {
1592
- /**
1593
- * If the new number is not 0, then we reset the lastKeyZero state and
1594
- * move to the next segment, returning the new number with a leading 0.
1595
- */
1596
- if (num !== 0) {
1597
- moveToNext = true;
1598
- this.root.states.minute.lastKeyZero = false;
1599
- return `0${num}`;
1600
- }
1601
- /**
1602
- * If the new number is 0, then we simply return `00` since that is
1603
- * an acceptable minute value.
1604
- */
1605
- moveToNext = true;
1606
- this.root.states.minute.lastKeyZero = false;
1607
- return "00";
1608
- }
1609
- /**
1610
- * If the total is greater than the max day value possible for this month, then
1611
- * we want to move to the next segment, trimming the first digit from the total,
1612
- * replacing it with a 0.
1613
- */
1614
- if (total > max) {
1615
- moveToNext = true;
1616
- return `0${num}`;
1617
- }
1618
- /**
1619
- * If the total has two digits and is less than or equal to the max day value,
1620
- * we will move to the next segment and return the total as the segment value.
1621
- */
1622
- moveToNext = true;
1623
- return `${total}`;
1624
- });
1625
- if (moveToNext) {
1626
- moveToNextSegment(e, this.root.getFieldNode());
1627
- }
1628
- return;
1629
- }
1630
- if (isBackspace(e.key)) {
1631
- this.root.states.minute.hasLeftFocus = false;
1632
- let moveToPrev = false;
1633
- this.root.updateSegment("minute", (prev) => {
1634
- if (prev === null) {
1635
- moveToPrev = true;
1636
- this.#announcer.announce("Empty");
1637
- return null;
1638
- }
1639
- const str = prev.toString();
1640
- if (str.length === 1) {
1641
- this.#announcer.announce("Empty");
1642
- return null;
1643
- }
1644
- const next = Number.parseInt(str.slice(0, -1));
1645
- this.#announcer.announce(next);
1646
- return `${next}`;
1647
- });
1648
- if (moveToPrev) {
1649
- moveToPrevSegment(e, this.root.getFieldNode());
1650
- }
1651
- return;
1652
- }
1653
- if (isSegmentNavigationKey(e.key)) {
1654
- handleSegmentNavigation(e, this.root.getFieldNode());
1050
+ return result;
1051
+ };
1655
1052
  }
1053
+ super.onkeydown(e);
1054
+ // Restore original updateSegment
1055
+ this.root.updateSegment = this.root.updateSegment.bind(this.root);
1656
1056
  }
1657
- onfocusout(_) {
1658
- this.root.states.minute.hasLeftFocus = true;
1659
- }
1660
- props = $derived.by(() => {
1661
- const segmentValues = this.root.segmentValues;
1662
- const placeholder = this.root.placeholder.current;
1663
- if (!("minute" in segmentValues) || !("minute" in placeholder))
1664
- return {};
1665
- const isEmpty = segmentValues.minute === null;
1666
- const date = segmentValues.minute
1667
- ? placeholder.set({ minute: Number.parseInt(segmentValues.minute) })
1668
- : placeholder;
1669
- const valueNow = date.minute;
1670
- const valueMin = 0;
1671
- const valueMax = 59;
1672
- const valueText = isEmpty ? "Empty" : `${valueNow}`;
1673
- return {
1674
- ...this.root.sharedSegmentAttrs,
1675
- id: this.opts.id.current,
1676
- "aria-label": "minute, ",
1677
- "aria-valuemin": valueMin,
1678
- "aria-valuemax": valueMax,
1679
- "aria-valuenow": valueNow,
1680
- "aria-valuetext": valueText,
1681
- onkeydown: this.onkeydown,
1682
- onfocusout: this.onfocusout,
1683
- onclick: this.root.handleSegmentClick,
1684
- ...this.root.getBaseSegmentAttrs("minute", this.opts.id.current),
1685
- };
1686
- });
1687
1057
  }
1688
- class DateFieldSecondSegmentState {
1689
- opts;
1690
- root;
1691
- #announcer;
1058
+ class DateFieldMinuteSegmentState extends BaseNumericSegmentState {
1692
1059
  constructor(opts, root) {
1693
- this.opts = opts;
1694
- this.root = root;
1695
- this.#announcer = this.root.announcer;
1696
- this.onkeydown = this.onkeydown.bind(this);
1697
- this.onfocusout = this.onfocusout.bind(this);
1698
- useRefById(opts);
1060
+ super(opts, root, "minute", SEGMENT_CONFIGS.minute);
1699
1061
  }
1700
- onkeydown(e) {
1701
- const placeholder = this.root.placeholder.current;
1702
- if (e.ctrlKey || e.metaKey || this.root.disabled.current || !("second" in placeholder))
1703
- return;
1704
- if (e.key !== kbd.TAB)
1705
- e.preventDefault();
1706
- if (!isAcceptableSegmentKey(e.key))
1707
- return;
1708
- const min = 0;
1709
- const max = 59;
1710
- if (isArrowUp(e.key)) {
1711
- this.root.updateSegment("second", (prev) => {
1712
- if (prev === null) {
1713
- this.#announcer.announce(min);
1714
- return `${min}`;
1715
- }
1716
- const next = placeholder
1717
- .set({ second: Number.parseInt(prev) })
1718
- .cycle("second", 1).second;
1719
- this.#announcer.announce(next);
1720
- return `${next}`;
1721
- });
1722
- return;
1723
- }
1724
- if (isArrowDown(e.key)) {
1725
- this.root.updateSegment("second", (prev) => {
1726
- if (prev === null) {
1727
- this.#announcer.announce(max);
1728
- return `${max}`;
1729
- }
1730
- const next = placeholder
1731
- .set({ second: Number.parseInt(prev) })
1732
- .cycle("second", -1).second;
1733
- this.#announcer.announce(next);
1734
- return `${next}`;
1735
- });
1736
- return;
1737
- }
1738
- if (isNumberString(e.key)) {
1739
- const num = Number.parseInt(e.key);
1740
- const numIsZero = num === 0;
1741
- let moveToNext = false;
1742
- this.root.updateSegment("second", (prev) => {
1743
- const maxStart = Math.floor(max / 10);
1744
- /**
1745
- * If the user has left the segment, we want to reset the
1746
- * `prev` value so that we can start the segment over again
1747
- * when the user types a number.
1748
- */
1749
- if (this.root.states.second.hasLeftFocus) {
1750
- prev = null;
1751
- this.root.states.second.hasLeftFocus = false;
1752
- }
1753
- /**
1754
- * We are starting over in the segment if prev is null, which could
1755
- * happen in one of two scenarios:
1756
- * - the user has left the segment and then comes back to it
1757
- * - the segment was empty and the user begins typing a number
1758
- */
1759
- if (prev === null) {
1760
- /**
1761
- * If the user types a 0 as the first number, we want
1762
- * to keep track of that so that when they type the next
1763
- * number, we can move to the next segment.
1764
- */
1765
- if (numIsZero) {
1766
- this.root.states.second.lastKeyZero = true;
1767
- this.#announcer.announce("0");
1768
- return "0";
1769
- }
1770
- ///////////////////////////
1771
- /**
1772
- * If the last key was a 0, or if the first number is
1773
- * greater than the max start digit (0-3 in most cases), then
1774
- * we want to move to the next segment, since it's not possible
1775
- * to continue typing a valid number in this segment.
1776
- */
1777
- if (this.root.states.second.lastKeyZero || num > maxStart) {
1778
- moveToNext = true;
1779
- }
1780
- this.root.states.second.lastKeyZero = false;
1781
- /**
1782
- * If we're moving to the next segment and the number is less than
1783
- * two digits, we want to announce the number and return it with a
1784
- * leading zero to follow the placeholder format of `MM/DD/YYYY`.
1785
- */
1786
- if (moveToNext && String(num).length === 1) {
1787
- this.#announcer.announce(num);
1788
- return `0${num}`;
1789
- }
1790
- /**
1791
- * If none of the above conditions are met, then we can just
1792
- * return the number as the segment value and continue typing
1793
- * in this segment.
1794
- */
1795
- return `${num}`;
1796
- }
1797
- /**
1798
- * If the number of digits is 2, or if the total with the existing digit
1799
- * and the pressed digit is greater than the maximum value for this
1800
- * second, then we will reset the segment as if the user had pressed the
1801
- * backspace key and then typed the number.
1802
- */
1803
- const total = Number.parseInt(prev + num.toString());
1804
- if (this.root.states.second.lastKeyZero) {
1805
- /**
1806
- * If the new number is not 0, then we reset the lastKeyZero state and
1807
- * move to the next segment, returning the new number with a leading 0.
1808
- */
1809
- if (num !== 0) {
1810
- moveToNext = true;
1811
- this.root.states.second.lastKeyZero = false;
1812
- return `0${num}`;
1813
- }
1814
- /**
1815
- * If the new number is 0, then we simply return `00` since that is
1816
- * an acceptable second value.
1817
- */
1818
- moveToNext = true;
1819
- this.root.states.second.lastKeyZero = false;
1820
- return "00";
1821
- }
1822
- /**
1823
- * If the total is greater than the max day value possible for this month, then
1824
- * we want to move to the next segment, trimming the first digit from the total,
1825
- * replacing it with a 0.
1826
- */
1827
- if (total > max) {
1828
- moveToNext = true;
1829
- return `0${num}`;
1830
- }
1831
- /**
1832
- * If the total has two digits and is less than or equal to the max day value,
1833
- * we will move to the next segment and return the total as the segment value.
1834
- */
1835
- moveToNext = true;
1836
- return `${total}`;
1837
- });
1838
- if (moveToNext) {
1839
- moveToNextSegment(e, this.root.getFieldNode());
1840
- }
1841
- }
1842
- if (isBackspace(e.key)) {
1843
- this.root.states.second.hasLeftFocus = false;
1844
- let moveToPrev = false;
1845
- this.root.updateSegment("second", (prev) => {
1846
- if (prev === null) {
1847
- moveToPrev = true;
1848
- this.#announcer.announce(null);
1849
- return null;
1850
- }
1851
- const str = prev.toString();
1852
- if (str.length === 1) {
1853
- this.#announcer.announce(null);
1854
- return null;
1855
- }
1856
- const next = Number.parseInt(str.slice(0, -1));
1857
- this.#announcer.announce(next);
1858
- return `${next}`;
1859
- });
1860
- if (moveToPrev) {
1861
- moveToPrevSegment(e, this.root.getFieldNode());
1862
- }
1863
- }
1864
- if (isSegmentNavigationKey(e.key)) {
1865
- handleSegmentNavigation(e, this.root.getFieldNode());
1866
- }
1867
- }
1868
- onfocusout(_) {
1869
- this.root.states.second.hasLeftFocus = true;
1062
+ }
1063
+ class DateFieldSecondSegmentState extends BaseNumericSegmentState {
1064
+ constructor(opts, root) {
1065
+ super(opts, root, "second", SEGMENT_CONFIGS.second);
1870
1066
  }
1871
- props = $derived.by(() => {
1872
- const segmentValues = this.root.segmentValues;
1873
- const placeholder = this.root.placeholder.current;
1874
- if (!("second" in segmentValues) || !("second" in placeholder))
1875
- return {};
1876
- const isEmpty = segmentValues.second === null;
1877
- const date = segmentValues.second
1878
- ? placeholder.set({ second: Number.parseInt(segmentValues.second) })
1879
- : placeholder;
1880
- const valueNow = date.second;
1881
- const valueMin = 0;
1882
- const valueMax = 59;
1883
- const valueText = isEmpty ? "Empty" : `${valueNow}`;
1884
- return {
1885
- ...this.root.sharedSegmentAttrs,
1886
- id: this.opts.id.current,
1887
- "aria-label": "second, ",
1888
- "aria-valuemin": valueMin,
1889
- "aria-valuemax": valueMax,
1890
- "aria-valuenow": valueNow,
1891
- "aria-valuetext": valueText,
1892
- onkeydown: this.onkeydown,
1893
- onfocusout: this.onfocusout,
1894
- onclick: this.root.handleSegmentClick,
1895
- ...this.root.getBaseSegmentAttrs("second", this.opts.id.current),
1896
- };
1897
- });
1898
1067
  }
1899
1068
  class DateFieldDayPeriodSegmentState {
1900
1069
  opts;