@xh/hoist 86.4.1 → 86.4.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 86.4.2 - 2026-08-20
4
+
5
+ ### 🎁 New Features
6
+
7
+ * Mobile `DateInput` now opens a themeable calendar picker (react-day-picker) within a dialog,
8
+ replacing the OS-provided picker of the native `<input type="date">` element. Adds a
9
+ `formatString` prop to control the in-input display of the value, and a `dayPickerProps` escape
10
+ hatch to pass props (e.g. `disabled` day matchers) directly to the underlying calendar.
11
+
12
+ ### 📚 Libraries
13
+
14
+ * react-day-picker `added @ 9.14`
15
+
3
16
  ## 86.4.1 - 2026-08-11
4
17
 
5
18
  ### 🐞 Bug Fixes
@@ -10,7 +23,7 @@
10
23
  * Revert swiper library to previous version to address swiping regressions (#4559)
11
24
 
12
25
  ### 📚 Libraries
13
- * swiper `12.1 -> 14.0`
26
+ * swiper `14.0 -> 12.1`
14
27
 
15
28
 
16
29
  ## 86.4.0 - 2026-07-15
@@ -0,0 +1,5 @@
1
+ import { DayPicker } from 'react-day-picker';
2
+ import 'react-day-picker/style.css';
3
+ export { DayPicker };
4
+ export type { DayPickerProps, Matcher } from 'react-day-picker';
5
+ export declare const dayPicker: import("@xh/hoist/core").ElementFactory<any>;
@@ -1,5 +1,6 @@
1
1
  import { HoistInputProps } from '@xh/hoist/cmp/input';
2
2
  import { HoistProps, LayoutProps, StyleProps } from '@xh/hoist/core';
3
+ import { DayPickerProps } from '@xh/hoist/kit/react-day-picker';
3
4
  import '@xh/hoist/mobile/register';
4
5
  import { LocalDate } from '@xh/hoist/utils/datetime';
5
6
  import type { Property } from 'csstype';
@@ -7,26 +8,28 @@ import { ReactElement } from 'react';
7
8
  import './DateInput.scss';
8
9
  export interface DateInputProps extends HoistProps, HoistInputProps, StyleProps, LayoutProps {
9
10
  value?: Date | LocalDate;
11
+ /**
12
+ * Escape hatch to pass props directly to the underlying react-day-picker calendar.
13
+ * `disabled` matchers are combined with those generated by minDate/maxDate. Selection
14
+ * wiring (`mode`, `selected`, `onSelect`) cannot be overridden.
15
+ */
16
+ dayPickerProps?: Partial<DayPickerProps>;
10
17
  /** True to show a "clear" button aligned to the right of the control. Defaults to false. */
11
18
  enableClear?: boolean;
19
+ /** MomentJS format string for the in-input display of the value. Defaults to `YYYY-MM-DD`. */
20
+ formatString?: string;
12
21
  /** Icon to display inline on the left side of the input. */
13
22
  leftIcon?: ReactElement;
14
23
  /** Icon to display inline on the right side of the input. Defaults to a calendar icon. */
15
24
  rightIcon?: ReactElement;
16
25
  /**
17
- * Maximum (inclusive) valid date. Applied to the native input's `max` attribute and also
18
- * enforced on commit, resetting any out-of-bounds value to `null`.
19
- *
20
- * Note this is distinct in these ways from FormModel based validation, which will leave an
21
- * invalid date entry in place but flag as invalid via FormField. For cases where it is
22
- * possible to use FormField, that is often a better choice.
26
+ * Maximum (inclusive) valid date. Days beyond this bound are disabled within the calendar,
27
+ * and the calendar cannot be navigated past its month.
23
28
  */
24
29
  maxDate?: Date | LocalDate;
25
30
  /**
26
- * Minimum (inclusive) valid date. Applied to the native input's `min` attribute and also
27
- * enforced on commit, resetting any out-of-bounds value to `null`.
28
- *
29
- * See note re. validation on maxDate, above.
31
+ * Minimum (inclusive) valid date. Days before this bound are disabled within the calendar,
32
+ * and the calendar cannot be navigated before its month.
30
33
  */
31
34
  minDate?: Date | LocalDate;
32
35
  /** Alignment of entry text within control, default 'left'. */
@@ -35,12 +38,8 @@ export interface DateInputProps extends HoistProps, HoistInputProps, StyleProps,
35
38
  valueType?: 'date' | 'localDate';
36
39
  }
37
40
  /**
38
- * A mobile-first calendar control for choosing a Date, backed by the browser's native
39
- * `<input type="date">` element. Tapping the input invokes the OS-provided date picker -
40
- * a drum/wheel on iOS, a Material date dialog on Android, and a popover calendar on desktop
41
- * browsers.
42
- *
43
- * The in-input display and picker UI follow the user's OS locale. Values are read from and
44
- * written to the underlying input as ISO-8601 (`YYYY-MM-DD`) strings.
41
+ * A mobile-first calendar control for choosing a Date. Tapping the input opens a calendar
42
+ * picker (react-day-picker) hosted within a modal dialog. Selecting a day commits the value
43
+ * and dismisses the picker.
45
44
  */
46
45
  export declare const DateInput: import("react").FC<DateInputProps>, dateInput: import("@xh/hoist/core").ElementFactory<DateInputProps>;
@@ -0,0 +1,17 @@
1
+ /*
2
+ * This file belongs to Hoist, an application development toolkit
3
+ * developed by Extremely Heavy Industries (www.xh.io | info@xh.io)
4
+ *
5
+ * Copyright © 2026 Extremely Heavy Industries Inc.
6
+ */
7
+ import {elementFactory} from '@xh/hoist/core';
8
+ import {ComponentType} from 'react';
9
+ import {DayPicker} from 'react-day-picker';
10
+ import 'react-day-picker/style.css';
11
+
12
+ export {DayPicker};
13
+ export type {DayPickerProps, Matcher} from 'react-day-picker';
14
+
15
+ // DayPicker's props are a discriminated union over its selection modes. Cast to ComponentType to
16
+ // keep our element factory ergonomic and produce portable type declarations.
17
+ export const dayPicker = elementFactory(DayPicker as ComponentType<any>);
@@ -7,41 +7,22 @@
7
7
  flex: none;
8
8
  }
9
9
 
10
- // Size onsen's inner input + label, matching sibling text/number inputs.
11
- .text-input,
12
- .text-input__label {
10
+ // Read-only display of the formatted value - tap to open the calendar picker.
11
+ &__display {
12
+ flex: 1;
13
+ min-width: 0;
13
14
  height: var(--xh-mobile-input-height-px);
14
- padding: 0 var(--xh-pad-px);
15
- }
16
-
17
- // Strip native date chrome - Icon.calendar() is the sole affordance.
18
- .text-input {
19
- appearance: none;
20
- -webkit-appearance: none;
21
-
22
- // Center the value vertically within the fixed-height control. On iOS the formatted value sits
23
- // in ::-webkit-date-and-time-value, which carries a default margin and aligns to the top of the
24
- // content box - line-height centers it (single line, no vertical padding) without disturbing
25
- // the horizontal text-align driven by the textAlign prop.
26
15
  line-height: var(--xh-mobile-input-height-px);
16
+ padding: 0 var(--xh-pad-px);
17
+ white-space: nowrap;
18
+ overflow: hidden;
19
+ text-overflow: ellipsis;
27
20
 
28
- // Value sits in this pseudo, which ignores text-align unless inherited.
29
- &::-webkit-date-and-time-value {
30
- text-align: inherit;
31
- margin: 0;
32
- }
33
-
34
- &:disabled {
21
+ &--disabled {
35
22
  color: var(--xh-text-color-muted);
36
- opacity: 1;
37
23
  }
38
24
  }
39
25
 
40
- &__input {
41
- flex: 1;
42
- min-width: 0;
43
- }
44
-
45
26
  &__picker-button {
46
27
  flex: none;
47
28
  margin: 0 6px;
@@ -55,4 +36,33 @@
55
36
  padding-left: 0;
56
37
  color: var(--xh-text-color-muted) !important;
57
38
  }
39
+
40
+ &__picker-dialog {
41
+ // No title provided - suppress the empty title bar rendered by Dialog.
42
+ .xh-dialog__title:empty {
43
+ display: none;
44
+ }
45
+
46
+ // Theme react-day-picker to match the app via its published CSS custom props.
47
+ .rdp-root {
48
+ --rdp-accent-color: var(--xh-accent-color);
49
+ --rdp-accent-background-color: var(--xh-bg-alt);
50
+ --rdp-today-color: var(--xh-accent-color);
51
+ }
52
+
53
+ // blueprint-datetime.css - currently pulled into all bundles via kit/blueprint - embeds
54
+ // react-day-picker v8's stylesheet, whose class names partially collide with the v9 markup
55
+ // rendered here. Reset the clashing v8 rules, most critically the day cells, which v8 styles
56
+ // as flex-box buttons but v9 renders as table cells.
57
+ .rdp-day,
58
+ .rdp-weeknumber {
59
+ display: table-cell;
60
+ width: var(--rdp-day-width);
61
+ max-width: none;
62
+ height: var(--rdp-day-height);
63
+ overflow: visible;
64
+ border: none;
65
+ border-radius: 0;
66
+ }
67
+ }
58
68
  }
@@ -5,25 +5,40 @@
5
5
  * Copyright © 2026 Extremely Heavy Industries Inc.
6
6
  */
7
7
  import {HoistInputModel, HoistInputProps, useHoistInputModel} from '@xh/hoist/cmp/input';
8
- import {box, hbox} from '@xh/hoist/cmp/layout';
8
+ import {box, div, hbox} from '@xh/hoist/cmp/layout';
9
9
  import {hoistCmp, HoistProps, LayoutProps, StyleProps} from '@xh/hoist/core';
10
+ import {fmtDate} from '@xh/hoist/format';
10
11
  import {Icon} from '@xh/hoist/icon';
11
- import {input} from '@xh/hoist/kit/onsen';
12
+ import {dayPicker, DayPickerProps} from '@xh/hoist/kit/react-day-picker';
12
13
  import {button} from '@xh/hoist/mobile/cmp/button';
14
+ import {dialog} from '@xh/hoist/mobile/cmp/dialog';
13
15
  import '@xh/hoist/mobile/register';
16
+ import {bindable, makeObservable} from '@xh/hoist/mobx';
14
17
  import {isLocalDate, LocalDate} from '@xh/hoist/utils/datetime';
15
18
  import {getTestId, TEST_ID, withDefault} from '@xh/hoist/utils/js';
16
19
  import {getLayoutProps} from '@xh/hoist/utils/react';
20
+ import classNames from 'classnames';
17
21
  import type {Property} from 'csstype';
18
- import {ChangeEvent, ReactElement} from 'react';
22
+ import {castArray, isNil, omitBy} from 'lodash';
23
+ import {ReactElement} from 'react';
19
24
  import './DateInput.scss';
20
25
 
21
26
  export interface DateInputProps extends HoistProps, HoistInputProps, StyleProps, LayoutProps {
22
27
  value?: Date | LocalDate;
23
28
 
29
+ /**
30
+ * Escape hatch to pass props directly to the underlying react-day-picker calendar.
31
+ * `disabled` matchers are combined with those generated by minDate/maxDate. Selection
32
+ * wiring (`mode`, `selected`, `onSelect`) cannot be overridden.
33
+ */
34
+ dayPickerProps?: Partial<DayPickerProps>;
35
+
24
36
  /** True to show a "clear" button aligned to the right of the control. Defaults to false. */
25
37
  enableClear?: boolean;
26
38
 
39
+ /** MomentJS format string for the in-input display of the value. Defaults to `YYYY-MM-DD`. */
40
+ formatString?: string;
41
+
27
42
  /** Icon to display inline on the left side of the input. */
28
43
  leftIcon?: ReactElement;
29
44
 
@@ -31,20 +46,14 @@ export interface DateInputProps extends HoistProps, HoistInputProps, StyleProps,
31
46
  rightIcon?: ReactElement;
32
47
 
33
48
  /**
34
- * Maximum (inclusive) valid date. Applied to the native input's `max` attribute and also
35
- * enforced on commit, resetting any out-of-bounds value to `null`.
36
- *
37
- * Note this is distinct in these ways from FormModel based validation, which will leave an
38
- * invalid date entry in place but flag as invalid via FormField. For cases where it is
39
- * possible to use FormField, that is often a better choice.
49
+ * Maximum (inclusive) valid date. Days beyond this bound are disabled within the calendar,
50
+ * and the calendar cannot be navigated past its month.
40
51
  */
41
52
  maxDate?: Date | LocalDate;
42
53
 
43
54
  /**
44
- * Minimum (inclusive) valid date. Applied to the native input's `min` attribute and also
45
- * enforced on commit, resetting any out-of-bounds value to `null`.
46
- *
47
- * See note re. validation on maxDate, above.
55
+ * Minimum (inclusive) valid date. Days before this bound are disabled within the calendar,
56
+ * and the calendar cannot be navigated before its month.
48
57
  */
49
58
  minDate?: Date | LocalDate;
50
59
 
@@ -56,13 +65,9 @@ export interface DateInputProps extends HoistProps, HoistInputProps, StyleProps,
56
65
  }
57
66
 
58
67
  /**
59
- * A mobile-first calendar control for choosing a Date, backed by the browser's native
60
- * `<input type="date">` element. Tapping the input invokes the OS-provided date picker -
61
- * a drum/wheel on iOS, a Material date dialog on Android, and a popover calendar on desktop
62
- * browsers.
63
- *
64
- * The in-input display and picker UI follow the user's OS locale. Values are read from and
65
- * written to the underlying input as ISO-8601 (`YYYY-MM-DD`) strings.
68
+ * A mobile-first calendar control for choosing a Date. Tapping the input opens a calendar
69
+ * picker (react-day-picker) hosted within a modal dialog. Selecting a day commits the value
70
+ * and dismisses the picker.
66
71
  */
67
72
  export const [DateInput, dateInput] = hoistCmp.withFactory<DateInputProps>({
68
73
  displayName: 'DateInput',
@@ -80,6 +85,13 @@ export const [DateInput, dateInput] = hoistCmp.withFactory<DateInputProps>({
80
85
  class DateInputModel extends HoistInputModel {
81
86
  override xhImpl = true;
82
87
 
88
+ @bindable pickerIsOpen: boolean = false;
89
+
90
+ constructor() {
91
+ super();
92
+ makeObservable(this);
93
+ }
94
+
83
95
  get valueType(): 'date' | 'localDate' {
84
96
  return withDefault(this.componentProps.valueType, 'date');
85
97
  }
@@ -97,6 +109,15 @@ class DateInputModel extends HoistInputModel {
97
109
  return !!enableClear && !disabled && this.renderValue != null;
98
110
  }
99
111
 
112
+ get formatString(): string {
113
+ return withDefault(this.componentProps.formatString, 'YYYY-MM-DD');
114
+ }
115
+
116
+ get formattedRenderValue(): string {
117
+ const {renderValue} = this;
118
+ return renderValue ? (fmtDate(renderValue, this.formatString) as string) : '';
119
+ }
120
+
100
121
  override toExternal(internal: Date | null): Date | LocalDate | null {
101
122
  if (this.valueType === 'localDate') return internal ? LocalDate.from(internal) : null;
102
123
  return internal;
@@ -107,43 +128,19 @@ class DateInputModel extends HoistInputModel {
107
128
  return isLocalDate(external) ? external.date : (external as Date);
108
129
  }
109
130
 
110
- onInputChange = (ev: ChangeEvent<HTMLInputElement>) => {
111
- const str = ev.target.value;
112
- if (!str) {
113
- this.noteValueChange(null);
114
- return;
115
- }
116
-
117
- let date = isoToDate(str);
118
- if (date && this.isOutsideRange(date)) {
119
- this.logDebug('Value exceeded max/minDate bounds on change - reset to null.');
120
- date = null;
121
- // Force the native input back in sync with the reset value. When the prior value was
122
- // also null, the internalValue observable does not change, so no re-render fires and
123
- // the out-of-bounds entry would otherwise remain displayed in the control.
124
- ev.target.value = '';
125
- }
126
- this.noteValueChange(date);
127
- };
128
-
129
- isOutsideRange(date: Date): boolean {
130
- const {minDate, maxDate} = this,
131
- stamped = stripTime(date);
132
- if (minDate && stamped < minDate) return true;
133
- if (maxDate && stamped > maxDate) return true;
134
- return false;
131
+ openPicker() {
132
+ if (!this.componentProps.disabled) this.pickerIsOpen = true;
135
133
  }
136
134
 
137
- // No-op on browsers without showPicker() (pre-16.4 Safari); tapping the field still opens it.
138
- showPicker() {
139
- if (this.componentProps.disabled) return;
140
- (this.inputEl as HTMLInputElement)?.showPicker?.();
141
- }
135
+ onDaySelect = (date: Date) => {
136
+ this.noteValueChange(date ?? null);
137
+ this.doCommit();
138
+ this.pickerIsOpen = false;
139
+ };
142
140
  }
143
141
 
144
142
  const cmp = hoistCmp.factory<DateInputModel>(({model, className, ...props}, ref) => {
145
143
  const {width, ...layoutProps} = getLayoutProps(props),
146
- {renderValue} = model,
147
144
  textAlign = withDefault(props.textAlign, 'left'),
148
145
  leftIcon = withDefault(props.leftIcon, null),
149
146
  rightIcon = withDefault(props.rightIcon, Icon.calendar());
@@ -158,32 +155,60 @@ const cmp = hoistCmp.factory<DateInputModel>(({model, className, ...props}, ref)
158
155
  },
159
156
  items: [
160
157
  leftIcon,
161
- input({
162
- type: 'date',
163
- className: 'xh-date-input__input',
164
- value: dateToIso(renderValue as Date) ?? '',
165
- min: dateToIso(model.minDate),
166
- max: dateToIso(model.maxDate),
167
- disabled: props.disabled,
168
- tabIndex: props.tabIndex,
158
+ div({
159
+ className: classNames(
160
+ 'xh-date-input__display',
161
+ props.disabled ? 'xh-date-input__display--disabled' : null
162
+ ),
169
163
  style: {textAlign},
164
+ tabIndex: props.tabIndex,
170
165
  [TEST_ID]: props.testId,
171
-
172
- onChange: model.onInputChange,
173
- onFocus: model.onFocus,
174
- onBlur: model.onBlur
166
+ item: model.formattedRenderValue,
167
+ onClick: () => model.openPicker()
175
168
  }),
176
169
  clearButton(),
177
170
  rightIcon &&
178
171
  box({
179
172
  className: 'xh-date-input__picker-button',
180
- onClick: () => model.showPicker(),
173
+ onClick: () => model.openPicker(),
181
174
  item: rightIcon
182
- })
175
+ }),
176
+ pickerDialog()
183
177
  ]
184
178
  });
185
179
  });
186
180
 
181
+ const pickerDialog = hoistCmp.factory<DateInputModel>(({model}) => {
182
+ let {minDate, maxDate, renderValue} = model,
183
+ {dayPickerProps} = model.componentProps,
184
+ disabledDays = [];
185
+ if (minDate) disabledDays.push({before: minDate});
186
+ if (maxDate) disabledDays.push({after: maxDate});
187
+ if (dayPickerProps?.disabled) disabledDays.push(...castArray(dayPickerProps.disabled));
188
+
189
+ dayPickerProps = omitBy(
190
+ {
191
+ defaultMonth: renderValue,
192
+ startMonth: minDate,
193
+ endMonth: maxDate,
194
+ ...dayPickerProps,
195
+ mode: 'single',
196
+ required: true,
197
+ selected: renderValue,
198
+ disabled: disabledDays,
199
+ onSelect: model.onDaySelect
200
+ },
201
+ isNil
202
+ );
203
+
204
+ return dialog({
205
+ isOpen: model.pickerIsOpen,
206
+ className: 'xh-date-input__picker-dialog',
207
+ onCancel: () => (model.pickerIsOpen = false),
208
+ content: dayPicker(dayPickerProps)
209
+ });
210
+ });
211
+
187
212
  const clearButton = hoistCmp.factory<DateInputModel>(({model}) =>
188
213
  button({
189
214
  className: 'xh-date-input__clear-button',
@@ -193,8 +218,6 @@ const clearButton = hoistCmp.factory<DateInputModel>(({model}) =>
193
218
  omit: !model.showClearButton,
194
219
  testId: getTestId(model.componentProps, 'clear-btn'),
195
220
  onClick: () => {
196
- // Intentionally no refocus after clearing - on iOS/Android, focusing the native
197
- // date input reopens the OS picker, which is unwanted UX after an explicit clear.
198
221
  model.noteValueChange(null);
199
222
  model.doCommit();
200
223
  }
@@ -202,28 +225,13 @@ const clearButton = hoistCmp.factory<DateInputModel>(({model}) =>
202
225
  );
203
226
 
204
227
  //---------------------------------
205
- // Local helpers - date <-> ISO
228
+ // Local helpers
206
229
  //---------------------------------
207
230
  function resolveBoundDate(val: Date | LocalDate | undefined | null): Date | null {
208
231
  if (val == null) return null;
209
232
  return isLocalDate(val) ? val.date : stripTime(val);
210
233
  }
211
234
 
212
- function isoToDate(iso: string): Date | null {
213
- const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
214
- if (!match) return null;
215
- const [, y, m, d] = match;
216
- return new Date(Number(y), Number(m) - 1, Number(d));
217
- }
218
-
219
- function dateToIso(date: Date | null | undefined): string | undefined {
220
- if (date == null) return undefined;
221
- const y = date.getFullYear(),
222
- m = String(date.getMonth() + 1).padStart(2, '0'),
223
- d = String(date.getDate()).padStart(2, '0');
224
- return `${y}-${m}-${d}`;
225
- }
226
-
227
235
  function stripTime(date: Date): Date {
228
236
  return new Date(date.getFullYear(), date.getMonth(), date.getDate());
229
237
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xh/hoist",
3
- "version": "86.4.1",
3
+ "version": "86.4.2",
4
4
  "description": "Hoist add-on for building and deploying React Applications.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -75,6 +75,7 @@
75
75
  "numbro": "~2.5.0",
76
76
  "onsenui": "~2.12.8",
77
77
  "qs": "~6.15.0",
78
+ "react-day-picker": "^9.14.0",
78
79
  "react-dropzone": "~15.0.0",
79
80
  "react-grid-layout": "~2.2.3",
80
81
  "react-markdown": "~10.1.0",