@xsolla/xui-date-picker 0.218.0 → 0.219.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.
package/README.md CHANGED
@@ -95,11 +95,15 @@ const [date, setDate] = useState<Date | null>(null);
95
95
  | `startDate` | `Date \| null` | — | Range start date. |
96
96
  | `endDate` | `Date \| null` | — | Range end date. |
97
97
  | `selectsRange` | `boolean` | `false` | Enable range selection. |
98
- | `onChange` | `(date: Date \| DateRangeType) => void` | — | Fired when the date or range changes. |
98
+ | `onChange` | `(date: Date \| DateRangeType) => void` | — | Fired when the date or range changes. Range mode also reports an emptied range as `[null, null]`; single mode never reports "no date" here — see [Clearing](#clearing). |
99
+ | `label` | `string` | — | Label rendered above the field, in the same style as `Select`'s label. |
99
100
  | `placeholder` | `string` | `'Choose a date'` / `'Choose a date range'` | Placeholder for the input, shown while it is empty. Not derived from `dateFormat`. |
100
101
  | `dateFormat` | `string` | `'MM/dd/yyyy'` | `date-fns` format used to render and parse the field. Numeric tokens only — see [Date formats](#date-formats). |
101
102
  | `size` | `'xs' \| 'sm' \| 'md' \| 'lg' \| 'xl'` | `'md'` | Input size. |
102
103
  | `disabled` | `boolean` | `false` | Disable the input. |
104
+ | `readOnly` | `boolean` | `false` | The field can open the calendar but not be edited. |
105
+ | `clearable` | `boolean` | `false` | Show a clear (✕) control inside the field once it holds a value — see [Clearing](#clearing). |
106
+ | `onClear` | `() => void` | — | Fired after the `clearable` control empties the field. The only clear signal in single-date mode. |
103
107
  | `backgroundColor` | `string` | — | Custom background colour for the input. |
104
108
  | `dropdownPosition` | `'left' \| 'right'` | `'left'` | **Deprecated** — use the alignment suffix on `placement` (`dropdownPosition="right"` → `placement="bottom-end"`). Still honoured as a fallback when `placement` has no suffix. |
105
109
  | `placement` | `DatePickerPlacement` | auto | Where the calendar opens, as `{side}[-alignment]` — same `{side}-{start\|center\|end}` convention as `ContextMenuPlacement`. A bare side aligns to `start`. When omitted, the calendar measures viewport space on open and auto-flips, preferring `bottom` → `top` → `left` → `right`, also flipping the cross-axis edge to stay on screen. When set, it always opens at that placement. Web only. |
@@ -124,6 +128,39 @@ const [date, setDate] = useState<Date | null>(null);
124
128
 
125
129
  Inherits `ThemeOverrideProps` (`themeMode`, `themeProductContext`).
126
130
 
131
+ ### Clearing
132
+
133
+ `clearable` adds a clear (✕) control inside the field, to the left of the calendar icon. It appears once the field holds a value and is suppressed while the field is `disabled` or `readOnly`.
134
+
135
+ **The clear signal differs by mode.** Range mode can express an empty value in `onChange` — it already reports half-picked ranges as `[start, end]` with nulls — so clearing fires `onChange([null, null])`. Single mode cannot: `onChange` takes a `Date`, and widening it to `Date | null` would break the handler signature of every existing consumer. Clearing a single date therefore fires **`onClear` only**. Wire `onClear` whenever you pass `clearable` in single mode, or the clear will not reach your state.
136
+
137
+ ```tsx
138
+ const [date, setDate] = useState<Date | null>(new Date());
139
+
140
+ <DatePicker
141
+ clearable
142
+ label="Published date"
143
+ selectedDate={date}
144
+ onChange={(d) => setDate(d as Date)}
145
+ onClear={() => setDate(null)}
146
+ />;
147
+ ```
148
+
149
+ ```tsx
150
+ const [range, setRange] = useState<DateRangeType>([null, null]);
151
+
152
+ <DatePicker
153
+ variant="dual"
154
+ clearable
155
+ label="Report period"
156
+ startDate={range[0]}
157
+ endDate={range[1]}
158
+ onChange={(value) => setRange(value as DateRangeType)}
159
+ />;
160
+ ```
161
+
162
+ A cleared field stays empty until the props change or the user types again — blur does not put the previous value back. The same holds for a value you clear **from outside** while the calendar is open: the field adopts it on blur rather than re-emitting the date it was showing.
163
+
127
164
  ### Date formats
128
165
 
129
166
  The field is edited segment by segment (type digits, arrow-step a segment, tab between them), so `dateFormat` supports **numeric tokens only** — exactly three, in any order, with anything between them treated as a literal separator:
@@ -338,3 +375,4 @@ const [date, setDate] = useState<Date | null>(null);
338
375
  - Calendar dates are navigable via keyboard; selected dates are announced to screen readers.
339
376
  - Focus is restored to the input when the calendar closes.
340
377
  - Dates outside `minDate`/`maxDate` are disabled.
378
+ - The clear control is a real `button` with its own focus stop; clearing keeps focus on the field.
@@ -38,12 +38,24 @@ interface DatePickerProps extends Omit<CalendarProps, "onChange"> {
38
38
  * {@link DateRangeType} tuple whenever the picker is in range mode
39
39
  * (`variant="dual"` or `selectsRange`). Either side of the tuple can be
40
40
  * `null` while a range is only half-picked.
41
+ *
42
+ * Note the asymmetry when the field is emptied via {@link clearable}: range
43
+ * mode reports `[null, null]` here, but single mode has no `null` in this
44
+ * signature, so use {@link onClear} to observe a single-date clear.
41
45
  */
42
46
  onChange?: (date: Date | DateRangeType) => void;
43
47
  /**
44
48
  * Placeholder text for the input
45
49
  */
46
50
  placeholder?: string;
51
+ /**
52
+ * Label rendered above the field, in the same style as `Select`'s label.
53
+ *
54
+ * Forwarded to the underlying `Input`. It used to work only by accident —
55
+ * unknown props were spread onto the field — which meant consumers had to
56
+ * cast to pass it, and it also leaked onto the calendar overlay (XUITSD-97).
57
+ */
58
+ label?: string;
47
59
  /** `date-fns` format for the field, defaulting to `"MM/dd/yyyy"`. Numeric `d`/`dd`, `M`/`MM` and `y`/`yyyy` tokens only, in any order (mind the casing — `mm` is minutes); anything else warns in development and falls back to the default. */
48
60
  dateFormat?: string;
49
61
  /**
@@ -67,6 +79,23 @@ interface DatePickerProps extends Omit<CalendarProps, "onChange"> {
67
79
  * Optional leading icon shown inside the field.
68
80
  */
69
81
  iconLeft?: ReactNode;
82
+ /**
83
+ * Shows a clear (✕) control inside the field, to the left of the calendar
84
+ * icon, whenever the field holds a value. Works in single and range mode,
85
+ * and is suppressed while the field is `disabled` or `readOnly`.
86
+ *
87
+ * Clearing empties the field, drops any active chip, and reports the new
88
+ * value: range mode fires `onChange([null, null])`; single mode fires
89
+ * {@link onClear} only, because `onChange` cannot express "no date". The
90
+ * cleared field is not reverted on blur — it stands until the props change.
91
+ */
92
+ clearable?: boolean;
93
+ /**
94
+ * Called when the field is emptied via the {@link clearable} control, after
95
+ * the field has been cleared. This is the only clear signal in single-date
96
+ * mode; in range mode `onChange([null, null])` fires as well.
97
+ */
98
+ onClear?: () => void;
70
99
  /**
71
100
  * Highlights the field as invalid (error state).
72
101
  */
package/native/index.d.ts CHANGED
@@ -38,12 +38,24 @@ interface DatePickerProps extends Omit<CalendarProps, "onChange"> {
38
38
  * {@link DateRangeType} tuple whenever the picker is in range mode
39
39
  * (`variant="dual"` or `selectsRange`). Either side of the tuple can be
40
40
  * `null` while a range is only half-picked.
41
+ *
42
+ * Note the asymmetry when the field is emptied via {@link clearable}: range
43
+ * mode reports `[null, null]` here, but single mode has no `null` in this
44
+ * signature, so use {@link onClear} to observe a single-date clear.
41
45
  */
42
46
  onChange?: (date: Date | DateRangeType) => void;
43
47
  /**
44
48
  * Placeholder text for the input
45
49
  */
46
50
  placeholder?: string;
51
+ /**
52
+ * Label rendered above the field, in the same style as `Select`'s label.
53
+ *
54
+ * Forwarded to the underlying `Input`. It used to work only by accident —
55
+ * unknown props were spread onto the field — which meant consumers had to
56
+ * cast to pass it, and it also leaked onto the calendar overlay (XUITSD-97).
57
+ */
58
+ label?: string;
47
59
  /** `date-fns` format for the field, defaulting to `"MM/dd/yyyy"`. Numeric `d`/`dd`, `M`/`MM` and `y`/`yyyy` tokens only, in any order (mind the casing — `mm` is minutes); anything else warns in development and falls back to the default. */
48
60
  dateFormat?: string;
49
61
  /**
@@ -67,6 +79,23 @@ interface DatePickerProps extends Omit<CalendarProps, "onChange"> {
67
79
  * Optional leading icon shown inside the field.
68
80
  */
69
81
  iconLeft?: ReactNode;
82
+ /**
83
+ * Shows a clear (✕) control inside the field, to the left of the calendar
84
+ * icon, whenever the field holds a value. Works in single and range mode,
85
+ * and is suppressed while the field is `disabled` or `readOnly`.
86
+ *
87
+ * Clearing empties the field, drops any active chip, and reports the new
88
+ * value: range mode fires `onChange([null, null])`; single mode fires
89
+ * {@link onClear} only, because `onChange` cannot express "no date". The
90
+ * cleared field is not reverted on blur — it stands until the props change.
91
+ */
92
+ clearable?: boolean;
93
+ /**
94
+ * Called when the field is emptied via the {@link clearable} control, after
95
+ * the field has been cleared. This is the only clear signal in single-date
96
+ * mode; in range mode `onChange([null, null])` fires as well.
97
+ */
98
+ onClear?: () => void;
70
99
  /**
71
100
  * Highlights the field as invalid (error state).
72
101
  */
package/native/index.js CHANGED
@@ -84,6 +84,7 @@ var toGapValue = (gap) => {
84
84
  const asNumber = Number(gap);
85
85
  return Number.isFinite(asNumber) ? asNumber : void 0;
86
86
  };
87
+ var toNativeDisplay = (display) => display === "none" ? "none" : void 0;
87
88
  var Box = ({
88
89
  children,
89
90
  onPress,
@@ -115,6 +116,7 @@ var Box = ({
115
116
  marginBottom,
116
117
  marginLeft,
117
118
  marginRight,
119
+ display,
118
120
  flexDirection,
119
121
  flexWrap,
120
122
  alignItems,
@@ -178,6 +180,7 @@ var Box = ({
178
180
  marginBottom,
179
181
  marginLeft,
180
182
  marginRight,
183
+ display: toNativeDisplay(display),
181
184
  flexDirection,
182
185
  flexWrap,
183
186
  alignItems,
@@ -638,6 +641,9 @@ var DatePicker = (0, import_react.forwardRef)(
638
641
  overlayThemeProductContext,
639
642
  iconRight = /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(CalendarFieldIcon, {}),
640
643
  iconLeft,
644
+ label,
645
+ clearable = false,
646
+ onClear,
641
647
  error,
642
648
  errorMessage,
643
649
  readOnly,
@@ -659,6 +665,7 @@ var DatePicker = (0, import_react.forwardRef)(
659
665
  () => isRange ? [datePartsFromDate(startDate), datePartsFromDate(endDate)] : [datePartsFromDate(selectedDate)],
660
666
  [isRange, startDate, endDate, selectedDate]
661
667
  );
668
+ const propKey = `${selectedDate?.getTime() ?? ""}|${startDate?.getTime() ?? ""}|${endDate?.getTime() ?? ""}|${isRange}`;
662
669
  const [open, setOpen] = (0, import_react.useState)(false);
663
670
  const [groups, setGroups] = (0, import_react.useState)(propGroups);
664
671
  const [edit, setEdit] = (0, import_react.useState)(null);
@@ -674,6 +681,15 @@ var DatePicker = (0, import_react.forwardRef)(
674
681
  const inputRef = (0, import_react.useRef)(null);
675
682
  const chipSelectionRef = (0, import_react.useRef)(false);
676
683
  const suppressReopenRef = (0, import_react.useRef)(false);
684
+ const sessionRef = (0, import_react.useRef)({
685
+ dirty: false,
686
+ cleared: false,
687
+ propKey
688
+ });
689
+ const markDirty = () => {
690
+ sessionRef.current.dirty = true;
691
+ sessionRef.current.cleared = false;
692
+ };
677
693
  const activeChipValue = activeChip !== void 0 ? activeChip : internalChip;
678
694
  const chipLabel = showChipLabel && activeChipValue && chips ? chips.find((c) => c.value === activeChipValue)?.label : void 0;
679
695
  const isWithinBounds = (date) => {
@@ -733,6 +749,7 @@ var DatePicker = (0, import_react.forwardRef)(
733
749
  );
734
750
  const showMask = hasAnyFilled || focused && isWeb;
735
751
  const inputValue = chipLabel ?? (showMask ? composed.text : "");
752
+ const showClear = clearable && editable && (hasAnyFilled || !!chipLabel);
736
753
  const orderedSegments = () => composeField().segments;
737
754
  const firstSegmentKey = () => {
738
755
  const first = orderedSegments()[0];
@@ -775,6 +792,7 @@ var DatePicker = (0, import_react.forwardRef)(
775
792
  if (start || end) applySelection([start, end], false);
776
793
  };
777
794
  const commitPart = (group, type, rawValue) => {
795
+ markDirty();
778
796
  const clamped = clampDatePart(type, rawValue, groups[group]);
779
797
  const next = groups.map((g, i) => {
780
798
  if (i !== group) return g;
@@ -823,6 +841,7 @@ var DatePicker = (0, import_react.forwardRef)(
823
841
  setEdit(null);
824
842
  if (committed !== null) advance(active);
825
843
  } else {
844
+ markDirty();
826
845
  setEdit({ group: active.group, type: active.type, buffer });
827
846
  }
828
847
  };
@@ -860,6 +879,7 @@ var DatePicker = (0, import_react.forwardRef)(
860
879
  e.preventDefault();
861
880
  const active = activeSegment ?? firstSegmentKey();
862
881
  if (!active) return;
882
+ markDirty();
863
883
  const sameActive = edit && edit.group === active.group && edit.type === active.type;
864
884
  if (sameActive && edit.buffer.length > 0) {
865
885
  const buffer = edit.buffer.slice(0, -1);
@@ -911,6 +931,11 @@ var DatePicker = (0, import_react.forwardRef)(
911
931
  suppressReopenRef.current = false;
912
932
  return;
913
933
  }
934
+ sessionRef.current = {
935
+ dirty: false,
936
+ cleared: sessionRef.current.cleared,
937
+ propKey
938
+ };
914
939
  setFocused(true);
915
940
  setOpen(true);
916
941
  if (isWeb && editable) setActiveSegment(firstSegmentKey());
@@ -927,7 +952,19 @@ var DatePicker = (0, import_react.forwardRef)(
927
952
  onBlurProp?.(e);
928
953
  setFocused(false);
929
954
  setActiveSegment(null);
955
+ if (sessionRef.current.propKey !== propKey && !sessionRef.current.dirty) {
956
+ sessionRef.current = { dirty: false, cleared: false, propKey };
957
+ setEdit(null);
958
+ setParseError(null);
959
+ setGroups(propGroups());
960
+ return;
961
+ }
930
962
  if (!editable) return;
963
+ if (sessionRef.current.cleared && !sessionRef.current.dirty) {
964
+ setEdit(null);
965
+ setParseError(null);
966
+ return;
967
+ }
931
968
  let next = groups;
932
969
  if (edit) {
933
970
  const clamped = clampDatePart(
@@ -984,6 +1021,7 @@ var DatePicker = (0, import_react.forwardRef)(
984
1021
  setParseError("Date must be within the allowed range");
985
1022
  return;
986
1023
  }
1024
+ markDirty();
987
1025
  setParseError(null);
988
1026
  setEdit(null);
989
1027
  setGroups(next);
@@ -994,13 +1032,29 @@ var DatePicker = (0, import_react.forwardRef)(
994
1032
  setInternalChip(value);
995
1033
  onChipSelect?.(value);
996
1034
  };
1035
+ const handleClear = () => {
1036
+ const next = isRange ? [datePartsFromDate(null), datePartsFromDate(null)] : [datePartsFromDate(null)];
1037
+ sessionRef.current.dirty = false;
1038
+ sessionRef.current.cleared = true;
1039
+ setGroups(next);
1040
+ setEdit(null);
1041
+ setParseError(null);
1042
+ setInternalChip(null);
1043
+ onChipSelect?.(null);
1044
+ if (isWeb && editable) setActiveSegment(firstSegmentKey());
1045
+ onClear?.();
1046
+ if (isRange) {
1047
+ const emptyRange = [null, null];
1048
+ onChange?.(emptyRange);
1049
+ }
1050
+ };
997
1051
  const handleDateChange = (date) => applySelection(date, closeOnComplete);
998
1052
  const closePanel = (0, import_react.useCallback)(() => setOpen(false), []);
999
1053
  const renderTopContent = topContent ? () => topContent({ close: closePanel }) : void 0;
1000
1054
  const renderBottomContent = bottomContent ? () => bottomContent({ close: closePanel }) : void 0;
1001
- const propKey = `${selectedDate?.getTime() ?? ""}|${startDate?.getTime() ?? ""}|${endDate?.getTime() ?? ""}|${isRange}`;
1002
1055
  (0, import_react.useEffect)(() => {
1003
1056
  if (focused) return;
1057
+ sessionRef.current = { dirty: false, cleared: false, propKey };
1004
1058
  setGroups(propGroups());
1005
1059
  setEdit(null);
1006
1060
  setParseError(null);
@@ -1177,6 +1231,7 @@ var DatePicker = (0, import_react.forwardRef)(
1177
1231
  ...rest,
1178
1232
  ref: inputRef,
1179
1233
  size,
1234
+ label,
1180
1235
  placeholder: placeholder || (isRange ? "Choose a date range" : "Choose a date"),
1181
1236
  value: inputValue,
1182
1237
  readOnly,
@@ -1187,6 +1242,8 @@ var DatePicker = (0, import_react.forwardRef)(
1187
1242
  onPaste: handlePaste,
1188
1243
  iconRight,
1189
1244
  iconLeft,
1245
+ extraClear: showClear,
1246
+ onRemove: handleClear,
1190
1247
  error: error || !!parseError,
1191
1248
  errorMessage: parseError ?? errorMessage,
1192
1249
  backgroundColor,