@xh/hoist 87.1.1 → 87.3.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.
Files changed (270) hide show
  1. package/.npmignore +39 -0
  2. package/CHANGELOG.md +108 -20
  3. package/admin/AdminUtils.ts +64 -0
  4. package/admin/App.scss +88 -0
  5. package/admin/columns/UserData.ts +30 -3
  6. package/admin/detail/RestDetailModel.ts +64 -0
  7. package/admin/detail/RestDetailPanel.ts +106 -0
  8. package/admin/tabs/activity/tracking/ActivityTrackingModel.ts +27 -94
  9. package/admin/tabs/activity/tracking/ActivityTrackingPanel.ts +23 -60
  10. package/admin/tabs/activity/tracking/detail/ActivityDetailView.ts +1 -2
  11. package/admin/tabs/general/alertBanner/AlertBannerPanel.ts +2 -13
  12. package/admin/tabs/general/config/ConfigDetailPanel.ts +72 -0
  13. package/admin/tabs/general/config/ConfigPanel.ts +41 -53
  14. package/admin/tabs/general/config/ConfigPanelModel.ts +3 -0
  15. package/admin/tabs/general/config/ConfigValue.scss +14 -0
  16. package/admin/tabs/general/config/ConfigValue.ts +104 -65
  17. package/admin/tabs/userData/jsonblob/JsonBlobDetailPanel.ts +93 -0
  18. package/admin/tabs/userData/jsonblob/JsonBlobModel.ts +4 -0
  19. package/admin/tabs/userData/jsonblob/JsonBlobPanel.ts +2 -0
  20. package/admin/tabs/userData/prefs/UserPreferenceDetailPanel.ts +62 -0
  21. package/admin/tabs/userData/prefs/UserPreferenceModel.ts +10 -3
  22. package/admin/tabs/userData/prefs/UserPreferencePanel.ts +3 -1
  23. package/admin/tabs/userData/roles/details/RoleDetails.scss +2 -19
  24. package/admin/tabs/userData/roles/details/RoleDetails.ts +13 -13
  25. package/appcontainer/AboutDialogModel.ts +1 -0
  26. package/appcontainer/AppContainerModel.ts +3 -0
  27. package/appcontainer/AppStateModel.ts +1 -0
  28. package/appcontainer/BannerSourceModel.ts +1 -0
  29. package/appcontainer/ChangelogDialogModel.ts +1 -0
  30. package/appcontainer/ColChooserOptionsModel.ts +1 -0
  31. package/appcontainer/ExceptionDialogModel.ts +1 -0
  32. package/appcontainer/FeedbackDialogModel.ts +1 -0
  33. package/appcontainer/ImpersonationBarModel.ts +1 -0
  34. package/appcontainer/MessageSourceModel.ts +1 -0
  35. package/appcontainer/OptionsDialogModel.ts +1 -0
  36. package/appcontainer/PageStateModel.ts +1 -0
  37. package/appcontainer/RouterModel.ts +2 -0
  38. package/appcontainer/SizingModeModel.ts +1 -0
  39. package/appcontainer/ThemeModel.ts +1 -0
  40. package/appcontainer/ToastSourceModel.ts +1 -0
  41. package/appcontainer/UserAgentModel.ts +1 -0
  42. package/appcontainer/ViewportSizeModel.ts +1 -0
  43. package/build/types/admin/AdminUtils.d.ts +29 -0
  44. package/build/types/admin/columns/UserData.d.ts +3 -0
  45. package/build/types/admin/detail/RestDetailModel.d.ts +20 -0
  46. package/build/types/admin/detail/RestDetailPanel.d.ts +34 -0
  47. package/build/types/admin/tabs/activity/tracking/ActivityTrackingModel.d.ts +5 -17
  48. package/build/types/admin/tabs/general/config/ConfigDetailPanel.d.ts +7 -0
  49. package/build/types/admin/tabs/general/config/ConfigValue.d.ts +10 -4
  50. package/build/types/admin/tabs/userData/jsonblob/JsonBlobDetailPanel.d.ts +3 -0
  51. package/build/types/admin/tabs/userData/prefs/UserPreferenceDetailPanel.d.ts +3 -0
  52. package/build/types/admin/tabs/userData/prefs/UserPreferenceModel.d.ts +3 -0
  53. package/build/types/appcontainer/AboutDialogModel.d.ts +1 -0
  54. package/build/types/appcontainer/AppContainerModel.d.ts +1 -0
  55. package/build/types/appcontainer/AppStateModel.d.ts +1 -0
  56. package/build/types/appcontainer/BannerSourceModel.d.ts +1 -0
  57. package/build/types/appcontainer/ChangelogDialogModel.d.ts +1 -0
  58. package/build/types/appcontainer/ColChooserOptionsModel.d.ts +1 -0
  59. package/build/types/appcontainer/ExceptionDialogModel.d.ts +1 -0
  60. package/build/types/appcontainer/FeedbackDialogModel.d.ts +1 -0
  61. package/build/types/appcontainer/ImpersonationBarModel.d.ts +1 -0
  62. package/build/types/appcontainer/MessageSourceModel.d.ts +1 -0
  63. package/build/types/appcontainer/OptionsDialogModel.d.ts +1 -0
  64. package/build/types/appcontainer/PageStateModel.d.ts +1 -0
  65. package/build/types/appcontainer/RouterModel.d.ts +1 -0
  66. package/build/types/appcontainer/SizingModeModel.d.ts +1 -0
  67. package/build/types/appcontainer/ThemeModel.d.ts +1 -0
  68. package/build/types/appcontainer/ToastSourceModel.d.ts +1 -0
  69. package/build/types/appcontainer/UserAgentModel.d.ts +1 -0
  70. package/build/types/appcontainer/ViewportSizeModel.d.ts +1 -0
  71. package/build/types/cmp/ag-grid/AgGridModel.d.ts +3 -1
  72. package/build/types/cmp/card/Card.d.ts +2 -2
  73. package/build/types/cmp/chart/ChartModel.d.ts +2 -0
  74. package/build/types/cmp/dataview/DataViewModel.d.ts +2 -0
  75. package/build/types/cmp/daterange/DateRangePickerModel.d.ts +266 -0
  76. package/build/types/cmp/daterange/DateRangePresets.d.ts +26 -0
  77. package/build/types/cmp/daterange/DateRangeUtils.d.ts +96 -0
  78. package/build/types/cmp/daterange/Types.d.ts +152 -0
  79. package/build/types/cmp/daterange/index.d.ts +4 -0
  80. package/build/types/cmp/filter/FilterChooserModel.d.ts +3 -1
  81. package/build/types/cmp/form/FormModel.d.ts +3 -1
  82. package/build/types/cmp/grid/GridModel.d.ts +2 -0
  83. package/build/types/cmp/grid/Types.d.ts +2 -0
  84. package/build/types/cmp/grid/filter/GridFilterModel.d.ts +1 -1
  85. package/build/types/cmp/grid/impl/ColumnWidthCalculator.d.ts +1 -1
  86. package/build/types/cmp/grouping/GroupingChooserModel.d.ts +3 -1
  87. package/build/types/cmp/input/HoistInputProps.d.ts +49 -2
  88. package/build/types/cmp/tab/TabContainerModel.d.ts +3 -1
  89. package/build/types/cmp/tab/TabModel.d.ts +3 -1
  90. package/build/types/cmp/treemap/TreeMapModel.d.ts +2 -0
  91. package/build/types/cmp/viewmanager/ViewManagerModel.d.ts +2 -0
  92. package/build/types/cmp/zoneGrid/ZoneGridModel.d.ts +2 -0
  93. package/build/types/core/HoistBase.d.ts +7 -0
  94. package/build/types/core/HoistProps.d.ts +26 -1
  95. package/build/types/core/model/RootRefreshContextModel.d.ts +1 -0
  96. package/build/types/core/types/Telemetry.d.ts +1 -1
  97. package/build/types/data/Store.d.ts +3 -1
  98. package/build/types/data/StoreSelectionModel.d.ts +3 -1
  99. package/build/types/data/cube/Cube.d.ts +5 -2
  100. package/build/types/data/cube/View.d.ts +2 -0
  101. package/build/types/desktop/cmp/appOption/AutoRefreshAppOption.d.ts +3 -0
  102. package/build/types/desktop/cmp/appOption/ThemeAppOption.d.ts +3 -0
  103. package/build/types/desktop/cmp/button/Button.d.ts +2 -2
  104. package/build/types/desktop/cmp/button/ButtonGroup.d.ts +2 -2
  105. package/build/types/desktop/cmp/dash/DashConfig.d.ts +2 -0
  106. package/build/types/desktop/cmp/dash/DashViewModel.d.ts +3 -1
  107. package/build/types/desktop/cmp/dash/canvas/DashCanvasModel.d.ts +1 -1
  108. package/build/types/desktop/cmp/dash/container/DashContainerModel.d.ts +1 -1
  109. package/build/types/desktop/cmp/daterange/DateRangePicker.d.ts +66 -0
  110. package/build/types/desktop/cmp/daterange/impl/CustomTab.d.ts +3 -0
  111. package/build/types/desktop/cmp/daterange/impl/DateRangePickerLocalModel.d.ts +121 -0
  112. package/build/types/desktop/cmp/daterange/impl/PeriodTab.d.ts +2 -0
  113. package/build/types/desktop/cmp/daterange/impl/PresetsTab.d.ts +3 -0
  114. package/build/types/desktop/cmp/daterange/impl/RelativeTab.d.ts +3 -0
  115. package/build/types/desktop/cmp/daterange/impl/TabUtils.d.ts +21 -0
  116. package/build/types/desktop/cmp/daterange/index.d.ts +2 -0
  117. package/build/types/desktop/cmp/dock/DockContainerModel.d.ts +3 -1
  118. package/build/types/desktop/cmp/dock/DockViewModel.d.ts +3 -1
  119. package/build/types/desktop/cmp/grouping/GroupingChooser.d.ts +4 -1
  120. package/build/types/desktop/cmp/input/IntentInput.d.ts +38 -0
  121. package/build/types/desktop/cmp/input/NumberInput.d.ts +2 -2
  122. package/build/types/desktop/cmp/input/SegmentedControl.d.ts +25 -4
  123. package/build/types/desktop/cmp/input/TextArea.d.ts +2 -2
  124. package/build/types/desktop/cmp/input/TextInput.d.ts +2 -2
  125. package/build/types/desktop/cmp/input/index.d.ts +1 -0
  126. package/build/types/desktop/cmp/leftrightchooser/LeftRightChooserModel.d.ts +3 -1
  127. package/build/types/desktop/cmp/panel/PanelModel.d.ts +3 -1
  128. package/build/types/desktop/cmp/rest/RestGridModel.d.ts +1 -1
  129. package/build/types/desktop/cmp/tab/impl/TabContainer.d.ts +1 -1
  130. package/build/types/desktop/hooks/UseHotkeys.d.ts +4 -3
  131. package/build/types/icon/Icon.d.ts +3 -0
  132. package/build/types/inspector/impl/InspectorUtils.d.ts +3 -0
  133. package/build/types/inspector/instances/InstancesModel.d.ts +4 -0
  134. package/build/types/kit/react-day-picker/index.d.ts +3 -3
  135. package/build/types/mobile/cmp/button/Button.d.ts +2 -2
  136. package/build/types/mobile/cmp/input/NumberInput.d.ts +2 -2
  137. package/build/types/mobile/cmp/input/SegmentedControl.d.ts +18 -4
  138. package/build/types/mobile/cmp/input/TextArea.d.ts +2 -2
  139. package/build/types/mobile/cmp/input/TextInput.d.ts +2 -2
  140. package/build/types/mobile/cmp/navigator/NavigatorModel.d.ts +8 -2
  141. package/build/types/mobile/cmp/navigator/PageModel.d.ts +3 -1
  142. package/build/types/mobile/cmp/tab/impl/TabContainer.d.ts +1 -1
  143. package/build/types/svc/InspectorService.d.ts +1 -0
  144. package/build/types/utils/js/LangUtils.d.ts +24 -5
  145. package/cmp/README.md +1 -0
  146. package/cmp/ag-grid/AgGridModel.ts +5 -0
  147. package/cmp/badge/Badge.ts +5 -3
  148. package/cmp/card/Card.ts +4 -3
  149. package/cmp/chart/ChartModel.ts +11 -1
  150. package/cmp/clock/Clock.ts +1 -1
  151. package/cmp/dataview/DataViewModel.ts +7 -1
  152. package/cmp/daterange/DateRangePickerModel.ts +628 -0
  153. package/cmp/daterange/DateRangePresets.ts +274 -0
  154. package/cmp/daterange/DateRangeUtils.ts +557 -0
  155. package/cmp/daterange/README.md +434 -0
  156. package/cmp/daterange/Types.ts +203 -0
  157. package/cmp/daterange/index.ts +10 -0
  158. package/cmp/filter/FilterChooserModel.ts +7 -4
  159. package/cmp/form/FormModel.ts +6 -0
  160. package/cmp/form/README.md +1 -0
  161. package/cmp/grid/GridModel.ts +24 -4
  162. package/cmp/grid/Types.ts +3 -0
  163. package/cmp/grid/filter/GridFilterModel.ts +3 -1
  164. package/cmp/grid/impl/ColumnWidthCalculator.ts +5 -10
  165. package/cmp/grouping/GroupingChooserModel.ts +6 -1
  166. package/cmp/input/HoistInputProps.ts +50 -2
  167. package/cmp/input/README.md +42 -0
  168. package/cmp/layout/Box.ts +3 -2
  169. package/cmp/tab/TabContainerModel.ts +9 -1
  170. package/cmp/tab/TabModel.ts +5 -0
  171. package/cmp/treemap/SplitTreeMapModel.ts +12 -2
  172. package/cmp/treemap/TreeMapModel.ts +6 -1
  173. package/cmp/viewmanager/ViewManagerModel.ts +6 -1
  174. package/cmp/zoneGrid/ZoneGridModel.ts +7 -0
  175. package/core/HoistBase.ts +11 -0
  176. package/core/HoistProps.ts +27 -0
  177. package/core/README.md +46 -0
  178. package/core/impl/InstallServices.ts +5 -1
  179. package/core/model/RootRefreshContextModel.ts +2 -0
  180. package/core/types/Telemetry.ts +1 -1
  181. package/data/README.md +3 -1
  182. package/data/Store.ts +7 -0
  183. package/data/StoreSelectionModel.ts +5 -1
  184. package/data/cube/Cube.ts +12 -3
  185. package/data/cube/View.ts +5 -1
  186. package/desktop/README.md +2 -0
  187. package/desktop/appcontainer/LoginPanel.ts +2 -0
  188. package/desktop/cmp/button/Button.scss +71 -42
  189. package/desktop/cmp/button/Button.ts +10 -1
  190. package/desktop/cmp/button/ButtonGroup.ts +4 -1
  191. package/desktop/cmp/button/grid/ColChooserButton.ts +9 -1
  192. package/desktop/cmp/button/zoneGrid/ZoneMapperButton.ts +1 -0
  193. package/desktop/cmp/dash/DashConfig.ts +3 -0
  194. package/desktop/cmp/dash/DashViewModel.ts +14 -1
  195. package/desktop/cmp/dash/canvas/DashCanvasModel.ts +3 -1
  196. package/desktop/cmp/dash/container/DashContainerModel.ts +3 -1
  197. package/desktop/cmp/daterange/DateRangePicker.scss +577 -0
  198. package/desktop/cmp/daterange/DateRangePicker.ts +357 -0
  199. package/desktop/cmp/daterange/impl/CustomTab.ts +165 -0
  200. package/desktop/cmp/daterange/impl/DateRangePickerLocalModel.ts +482 -0
  201. package/desktop/cmp/daterange/impl/PeriodTab.ts +138 -0
  202. package/desktop/cmp/daterange/impl/PresetsTab.ts +58 -0
  203. package/desktop/cmp/daterange/impl/RelativeTab.ts +108 -0
  204. package/desktop/cmp/daterange/impl/TabUtils.ts +34 -0
  205. package/desktop/cmp/daterange/index.ts +8 -0
  206. package/desktop/cmp/dock/DockContainerModel.ts +6 -1
  207. package/desktop/cmp/dock/DockViewModel.ts +6 -1
  208. package/desktop/cmp/filechooser/FileChooser.ts +4 -0
  209. package/desktop/cmp/form/FormField.ts +1 -0
  210. package/desktop/cmp/grid/editors/DateEditor.ts +11 -2
  211. package/desktop/cmp/grouping/GroupingChooser.ts +8 -2
  212. package/desktop/cmp/input/Checkbox.ts +1 -0
  213. package/desktop/cmp/input/CodeInput.ts +1 -0
  214. package/desktop/cmp/input/DateInput.scss +1 -1
  215. package/desktop/cmp/input/DateInput.ts +1 -0
  216. package/desktop/cmp/input/IntentInput.scss +202 -0
  217. package/desktop/cmp/input/IntentInput.ts +213 -0
  218. package/desktop/cmp/input/NumberInput.ts +11 -2
  219. package/desktop/cmp/input/Picker.ts +1 -0
  220. package/desktop/cmp/input/RadioInput.ts +2 -1
  221. package/desktop/cmp/input/SegmentedControl.scss +57 -5
  222. package/desktop/cmp/input/SegmentedControl.ts +66 -11
  223. package/desktop/cmp/input/Select.ts +9 -7
  224. package/desktop/cmp/input/Slider.ts +2 -0
  225. package/desktop/cmp/input/SwitchInput.ts +1 -0
  226. package/desktop/cmp/input/TextArea.ts +11 -2
  227. package/desktop/cmp/input/TextInput.ts +11 -2
  228. package/desktop/cmp/input/index.ts +1 -0
  229. package/desktop/cmp/leftrightchooser/LeftRightChooserModel.ts +5 -0
  230. package/desktop/cmp/panel/Panel.ts +8 -5
  231. package/desktop/cmp/panel/PanelModel.ts +8 -0
  232. package/desktop/cmp/panel/impl/ResizeContainer.ts +2 -1
  233. package/desktop/cmp/rest/RestGridModel.ts +4 -0
  234. package/desktop/cmp/tab/impl/TabContainer.ts +2 -0
  235. package/desktop/cmp/toolbar/Toolbar.scss +20 -6
  236. package/desktop/hooks/UseHotkeys.ts +41 -12
  237. package/docs/README.md +2 -0
  238. package/docs/build-and-publish.md +23 -7
  239. package/docs/doc-registry.json +8 -0
  240. package/docs/version-compatibility.md +2 -0
  241. package/icon/Icon.ts +9 -0
  242. package/icon/index.ts +24 -0
  243. package/inspector/README.md +8 -2
  244. package/inspector/impl/InspectorUtils.ts +12 -0
  245. package/inspector/instances/DiagnosticsModel.ts +3 -2
  246. package/inspector/instances/InstancesModel.ts +81 -14
  247. package/inspector/instances/InstancesPanel.ts +6 -0
  248. package/kit/react-day-picker/index.ts +3 -3
  249. package/mobile/appcontainer/LoginPanel.ts +2 -0
  250. package/mobile/cmp/button/Button.ts +16 -3
  251. package/mobile/cmp/form/FormField.ts +1 -0
  252. package/mobile/cmp/input/Checkbox.ts +1 -0
  253. package/mobile/cmp/input/DateInput.ts +1 -0
  254. package/mobile/cmp/input/Label.ts +13 -8
  255. package/mobile/cmp/input/NumberInput.ts +11 -2
  256. package/mobile/cmp/input/SearchInput.ts +1 -0
  257. package/mobile/cmp/input/SegmentedControl.scss +36 -6
  258. package/mobile/cmp/input/SegmentedControl.ts +37 -11
  259. package/mobile/cmp/input/Select.ts +2 -0
  260. package/mobile/cmp/input/SwitchInput.ts +1 -0
  261. package/mobile/cmp/input/TextArea.ts +11 -2
  262. package/mobile/cmp/input/TextInput.ts +11 -2
  263. package/mobile/cmp/navigator/NavigatorModel.ts +20 -8
  264. package/mobile/cmp/navigator/PageModel.ts +6 -1
  265. package/mobile/cmp/tab/impl/TabContainer.ts +9 -1
  266. package/package.json +19 -19
  267. package/styles/vars.scss +32 -2
  268. package/svc/InspectorService.ts +2 -0
  269. package/svc/TraceService.ts +5 -2
  270. package/utils/js/LangUtils.ts +37 -5
@@ -0,0 +1,482 @@
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 {type TabConfig, TabContainerModel} from '@xh/hoist/cmp/tab';
8
+ import {HoistModel, type Intent, lookup, managed, XH} from '@xh/hoist/core';
9
+ import {action, bindable, computed, makeObservable, observable} from '@xh/hoist/mobx';
10
+ import type {LocalDate} from '@xh/hoist/utils/datetime';
11
+ import {clamp, isEqual} from 'lodash';
12
+ import {createRef, type ReactElement} from 'react';
13
+ import {
14
+ DateRangePickerModel,
15
+ type DateRangePickerTab,
16
+ type DateRangeSelection,
17
+ type DateRangeUnit,
18
+ getDateRangeUnitLabel,
19
+ getMonthStart,
20
+ getQuarterStart,
21
+ type LocalDateRange,
22
+ MAX_RELATIVE_COUNT,
23
+ MIN_SELECTION_YEAR
24
+ } from '@xh/hoist/cmp/daterange';
25
+
26
+ /**
27
+ * A tab within the picker popover - its rail entry and its content component. Declared by the
28
+ * platform component alongside its tab components and passed in, keeping this model free of any
29
+ * dependency on them.
30
+ *
31
+ * @internal
32
+ */
33
+ export interface DateRangePickerTabSpec {
34
+ id: DateRangePickerTab;
35
+ title: string;
36
+ icon: ReactElement;
37
+ content: (props: {model: DateRangePickerLocalModel; testId: string}) => ReactElement;
38
+ }
39
+
40
+ /**
41
+ * Local model for DateRangePicker components. Holds popover-local view state - open state, the
42
+ * active tab, and per-tab drafts - that must be per-component-instance, even when multiple
43
+ * pickers share one DateRangePickerModel. Drafts never touch the parent model's applied value
44
+ * until committed, so Cancel / click-outside / Escape discard them for free.
45
+ *
46
+ * @internal
47
+ */
48
+ export class DateRangePickerLocalModel extends HoistModel {
49
+ override xhImpl = true;
50
+
51
+ @lookup(DateRangePickerModel) parentModel: DateRangePickerModel;
52
+
53
+ @bindable isOpen: boolean = false;
54
+
55
+ /** Drives the popover's tab rail + content. Tab ids are {@link DateRangePickerTab} values. */
56
+ @managed tabModel: TabContainerModel;
57
+
58
+ /** testId of the host picker - tabs derive their own testIds from it. */
59
+ testId: string;
60
+
61
+ /** Measured width of the whole control - drives the compact variant in a narrow host. */
62
+ @bindable measuredWidth: number = null;
63
+
64
+ /** Intent of the host picker as of its last render - accents selected months and years. */
65
+ intent: Intent = null;
66
+
67
+ /** Root element of this instance - scopes popover outside-click handling to this picker. */
68
+ viewRef = createRef<HTMLDivElement>();
69
+
70
+ // Relative tab draft.
71
+ @bindable relativeCount: number = 30;
72
+ @bindable relativeUnit: DateRangeUnit = 'days';
73
+ @bindable relativeSnap: boolean = false;
74
+
75
+ // Months & Years tab - year currently shown in the grid.
76
+ @bindable gridYear: number;
77
+
78
+ // Custom tab draft.
79
+ @observable.ref customStart: LocalDate;
80
+ @observable.ref customEnd: LocalDate;
81
+ @bindable nextEdge: 'start' | 'end' = 'start';
82
+ /** Start-of-month for the left-hand calendar. Right-hand shows the following month. */
83
+ @observable.ref leftMonth: LocalDate;
84
+
85
+ private tabSpecs: DateRangePickerTabSpec[];
86
+
87
+ // State as of the last open - lets `commitOnChange` tell a user change from the seeded draft.
88
+ private openTabId: DateRangePickerTab;
89
+ private seededRelativeDraft: DateRangeSelection;
90
+ private seededCustomDraft: DateRangeSelection;
91
+
92
+ get anchorDate(): LocalDate {
93
+ return this.parentModel.anchorDate;
94
+ }
95
+
96
+ get minDate(): LocalDate | null {
97
+ return this.parentModel.minDate;
98
+ }
99
+
100
+ get maxDate(): LocalDate {
101
+ return this.parentModel.maxDate;
102
+ }
103
+
104
+ get singleTab(): boolean {
105
+ return this.parentModel.tabs.length === 1;
106
+ }
107
+
108
+ get activeTabId(): DateRangePickerTab {
109
+ return this.tabModel.activeTabId as DateRangePickerTab;
110
+ }
111
+
112
+ //------------------
113
+ // Relative tab
114
+ //------------------
115
+ get clampedCount(): number {
116
+ return clamp(Math.round(this.relativeCount ?? 1), 1, MAX_RELATIVE_COUNT);
117
+ }
118
+
119
+ @computed.struct
120
+ get relativeDraft(): DateRangeSelection {
121
+ return {
122
+ kind: 'relative',
123
+ count: this.clampedCount,
124
+ unit: this.relativeUnit,
125
+ snap: this.relativeSnap
126
+ };
127
+ }
128
+
129
+ get relativeRange(): LocalDateRange {
130
+ return this.parentModel.resolve(this.relativeDraft).current;
131
+ }
132
+
133
+ get relativeDayCount(): number {
134
+ const {start, end} = this.relativeRange;
135
+ return end.diff(start, 'days') + 1;
136
+ }
137
+
138
+ /**
139
+ * False for days, where a day is already a calendar boundary - snapping has nothing to round
140
+ * out, so days resolve identically either way.
141
+ */
142
+ get snapApplies(): boolean {
143
+ return this.relativeUnit !== 'days';
144
+ }
145
+
146
+ /** Explains the Relative tab's snap choice, or the day window when snap does not apply. */
147
+ get relativeModeHelpText(): string {
148
+ const {relativeUnit: unit, anchorDate, anchorNoun} = this,
149
+ n = this.clampedCount,
150
+ singular = getDateRangeUnitLabel(unit, 1).toLowerCase(),
151
+ units = getDateRangeUnitLabel(unit, n).toLowerCase();
152
+
153
+ if (unit === 'days') {
154
+ return `Rolling window of exactly ${n} calendar ${units} ending ${anchorNoun}.`;
155
+ }
156
+ if (this.relativeSnap) {
157
+ // The window stops at the anchor date, so the last unit is normally incomplete -
158
+ // unless the anchor happens to fall on its final day.
159
+ const partial = anchorDate.endOf(unit) === anchorDate ? '' : ' (partial)',
160
+ containing = this.parentModel.isAnchorToday ? 'today' : 'the anchor date';
161
+ return n === 1
162
+ ? `The${partial} calendar ${singular} containing ${containing}.`
163
+ : `${n} calendar ${units}, ending with the${partial} one containing ${containing}.`;
164
+ }
165
+ return `Rolling window of exactly ${n} ${units} ending ${anchorNoun}.`;
166
+ }
167
+
168
+ /** Secondary line for the Relative tab's preview - the window's length. */
169
+ get relativePreviewCountText(): string {
170
+ const days = this.relativeDayCount;
171
+ return `${days} ${days === 1 ? 'day' : 'days'}`;
172
+ }
173
+
174
+ //------------------
175
+ // Months & Years tab
176
+ //------------------
177
+ get canYearBack(): boolean {
178
+ return this.gridYear > Math.max(MIN_SELECTION_YEAR, yearOf(this.minDate) ?? 0);
179
+ }
180
+
181
+ get canYearForward(): boolean {
182
+ return this.gridYear < yearOf(this.maxDate);
183
+ }
184
+
185
+ isMonthDisabled(month: number): boolean {
186
+ const {minDate, maxDate} = this,
187
+ start = getMonthStart(this.gridYear, month);
188
+ return start > maxDate || (minDate && start.endOfMonth() < minDate);
189
+ }
190
+
191
+ isQuarterDisabled(quarter: number): boolean {
192
+ const {minDate, maxDate} = this,
193
+ start = getQuarterStart(this.gridYear, quarter);
194
+ return start > maxDate || (minDate && start.endOfQuarter() < minDate);
195
+ }
196
+
197
+ get yearSelectable(): boolean {
198
+ const {gridYear, minDate, maxDate} = this;
199
+ return gridYear <= yearOf(maxDate) && (!minDate || gridYear >= yearOf(minDate));
200
+ }
201
+
202
+ //------------------
203
+ // Custom tab
204
+ //------------------
205
+ /** The Custom tab's draft, or null before the first open seeds it. */
206
+ @computed.struct
207
+ get customDraft(): DateRangeSelection {
208
+ const {customStart, customEnd} = this;
209
+ if (!customStart || !customEnd) return null;
210
+ return {kind: 'custom', start: customStart.isoString, end: customEnd.isoString};
211
+ }
212
+
213
+ get rightMonth(): LocalDate {
214
+ return this.leftMonth.add(1, 'months');
215
+ }
216
+
217
+ get canCalBack(): boolean {
218
+ const {minDate, leftMonth} = this;
219
+ return minDate
220
+ ? leftMonth > minDate.startOfMonth()
221
+ : yearOf(leftMonth) > MIN_SELECTION_YEAR;
222
+ }
223
+
224
+ get canCalForward(): boolean {
225
+ return this.rightMonth < this.maxDate.startOfMonth();
226
+ }
227
+
228
+ isDayDisabled(day: LocalDate): boolean {
229
+ const {minDate, maxDate} = this;
230
+ return day > maxDate || (minDate && day < minDate);
231
+ }
232
+
233
+ //------------------
234
+ // Footer
235
+ //------------------
236
+ /** True when the active tab holds a draft to apply - preset and period picks commit on click. */
237
+ get applyEnabled(): boolean {
238
+ return this.activeTabId === 'relative' || this.activeTabId === 'custom';
239
+ }
240
+
241
+ /** True to offer Apply and Cancel - drafts wait for Apply rather than committing on change. */
242
+ get showApplyControls(): boolean {
243
+ return !this.parentModel.commitOnChange;
244
+ }
245
+
246
+ /**
247
+ * Default footer note, as a prose prefix plus the date for the component to set apart. A live
248
+ * anchor names its clock, so a user whose day differs from the app's sees why. A pinned or
249
+ * computed anchor is just its date.
250
+ */
251
+ get anchorNote(): {prefix: string; date: string} {
252
+ const {anchorDate, parentModel} = this,
253
+ {anchorDay, businessDayMode} = parentModel,
254
+ date = parentModel.fmtSingleDay(anchorDate),
255
+ day = businessDayMode ? 'business day' : 'day';
256
+
257
+ let source = '';
258
+ if (anchorDay === 'localDay') {
259
+ source = ` your current ${day}`;
260
+ } else if (anchorDay === 'appDay') {
261
+ source = ` the current ${day} in ${XH.environmentService.get('appTimeZone')}`;
262
+ }
263
+ return {prefix: `Relative to${source}: `, date};
264
+ }
265
+
266
+ private get anchorNoun(): string {
267
+ return this.parentModel.isAnchorToday ? 'today' : 'on the anchor date';
268
+ }
269
+
270
+ constructor(testId: string, tabSpecs: DateRangePickerTabSpec[]) {
271
+ super();
272
+ makeObservable(this);
273
+ this.testId = testId;
274
+ this.tabSpecs = tabSpecs;
275
+ }
276
+
277
+ override onLinked() {
278
+ this.tabModel = new TabContainerModel({
279
+ // Tabs hold no state of their own - every draft lives on this model - so unmount
280
+ // on hide rather than retaining hidden content.
281
+ renderMode: 'unmountOnHide',
282
+ tabs: this.buildTabs(),
283
+ xhImpl: true
284
+ });
285
+
286
+ this.addReaction({
287
+ track: () => this.parentModel.tabs,
288
+ run: () => this.tabModel.setTabs(this.buildTabs())
289
+ });
290
+
291
+ // Never hold a snap that does nothing - it would persist into the committed value.
292
+ this.addReaction({
293
+ track: () => [this.snapApplies, this.relativeSnap],
294
+ run: ([applies, snap]) => {
295
+ if (!applies && snap) this.relativeSnap = false;
296
+ }
297
+ });
298
+
299
+ // With `commitOnChange`, a relative or custom draft applies as it changes, and activating
300
+ // either tab applies its current draft - so the popover never shows a period other than
301
+ // the applied one. Opening the popover itself commits nothing.
302
+ this.addReaction({
303
+ track: () => [this.isOpen, this.activeTabId, this.relativeDraft, this.customDraft],
304
+ run: () => this.commitDraftIfLive()
305
+ });
306
+ }
307
+
308
+ //------------------
309
+ // Open / close / commit
310
+ //------------------
311
+ toggleOpen() {
312
+ this.isOpen ? this.close() : this.open();
313
+ }
314
+
315
+ @action
316
+ open() {
317
+ const {parentModel, anchorDate, maxDate} = this,
318
+ {value, tabs} = parentModel,
319
+ valueTab = tabForKind(value.kind),
320
+ openTabId = tabs.includes(valueTab) ? valueTab : tabs[0];
321
+
322
+ this.tabModel.activateTab(openTabId);
323
+
324
+ // A draft starts at offset zero - stepping is done from the trigger, not the tab.
325
+ if (value.kind === 'relative') {
326
+ this.relativeCount = value.count;
327
+ this.relativeUnit = value.unit;
328
+ this.relativeSnap = value.snap;
329
+ } else {
330
+ this.relativeCount = 30;
331
+ this.relativeUnit = 'days';
332
+ this.relativeSnap = false;
333
+ }
334
+
335
+ this.gridYear = 'year' in value ? value.year : yearOf(anchorDate);
336
+
337
+ // Seed the custom draft from the applied resolved range, clamped to selectable bounds.
338
+ const {start: curStart, end: curEnd} = parentModel.currentRange;
339
+ let end = curEnd ?? maxDate;
340
+ if (end > maxDate) end = maxDate;
341
+ let start = curStart ?? end;
342
+ if (start > end) start = end;
343
+ if (this.minDate && start < this.minDate) start = this.minDate;
344
+ if (start > end) end = start;
345
+ this.customStart = start;
346
+ this.customEnd = end;
347
+ this.nextEdge = 'start';
348
+
349
+ this.showMonthOnRight(end);
350
+
351
+ this.openTabId = openTabId;
352
+ this.seededRelativeDraft = this.relativeDraft;
353
+ this.seededCustomDraft = this.customDraft;
354
+
355
+ this.isOpen = true;
356
+ }
357
+
358
+ @action
359
+ close() {
360
+ this.isOpen = false;
361
+ }
362
+
363
+ @action
364
+ commit(value: DateRangeSelection) {
365
+ this.parentModel.setValue(value);
366
+ this.close();
367
+ }
368
+
369
+ /** Commit the active tab's draft - a no-op on tabs whose picks commit on click. */
370
+ apply() {
371
+ const {activeTabId} = this;
372
+ if (activeTabId === 'relative') this.commit(this.relativeDraft);
373
+ else if (activeTabId === 'custom') this.commit(this.customDraft);
374
+ }
375
+
376
+ //------------------
377
+ // Tab interactions
378
+ //------------------
379
+ @action
380
+ stepCount(delta: number) {
381
+ this.relativeCount = clamp(this.clampedCount + delta, 1, MAX_RELATIVE_COUNT);
382
+ }
383
+
384
+ @action
385
+ stepYear(delta: number) {
386
+ this.gridYear += delta;
387
+ }
388
+
389
+ @action
390
+ stepCalendar(delta: number) {
391
+ this.leftMonth = this.leftMonth.add(delta, 'months');
392
+ }
393
+
394
+ @action
395
+ pickDay(day: LocalDate) {
396
+ if (this.nextEdge === 'start' || day < this.customStart) {
397
+ this.customStart = day;
398
+ this.customEnd = day;
399
+ this.nextEdge = 'end';
400
+ } else {
401
+ this.customEnd = day;
402
+ this.nextEdge = 'start';
403
+ }
404
+ }
405
+
406
+ @action
407
+ commitStartInput(val: LocalDate) {
408
+ if (!val) return;
409
+ this.customStart = val;
410
+ if (val > this.customEnd) this.customEnd = val;
411
+ this.showMonthOnLeft(val);
412
+ }
413
+
414
+ @action
415
+ commitEndInput(val: LocalDate) {
416
+ if (!val) return;
417
+ this.customEnd = val;
418
+ if (val < this.customStart) this.customStart = val;
419
+ this.showMonthOnRight(val);
420
+ }
421
+
422
+ //------------------
423
+ // Implementation
424
+ //------------------
425
+ /** Tab configs in the order the app configured them - the model's `tabs` sets the order. */
426
+ private buildTabs(): TabConfig[] {
427
+ return this.parentModel.tabs.map(id => {
428
+ const {title, icon, content} = this.tabSpecs.find(spec => spec.id === id);
429
+ return {id, title, icon, content: () => content({model: this, testId: this.testId})};
430
+ });
431
+ }
432
+
433
+ private commitDraftIfLive() {
434
+ const {parentModel, isOpen, activeTabId, openTabId} = this;
435
+ if (!isOpen || !parentModel.commitOnChange) return;
436
+
437
+ const tabChanged = activeTabId !== openTabId;
438
+ if (activeTabId === 'relative') {
439
+ if (tabChanged || !isEqual(this.relativeDraft, this.seededRelativeDraft)) {
440
+ parentModel.setValue(this.relativeDraft);
441
+ }
442
+ } else if (activeTabId === 'custom' && this.customDraft) {
443
+ if (tabChanged || !isEqual(this.customDraft, this.seededCustomDraft)) {
444
+ parentModel.setValue(this.customDraft);
445
+ }
446
+ }
447
+ }
448
+
449
+ /** Position the calendars so `date`'s month shows on the left, right within maxDate. */
450
+ @action
451
+ private showMonthOnLeft(date: LocalDate) {
452
+ const maxLeft = this.maxDate.startOfMonth().subtract(1, 'months'),
453
+ month = date.startOfMonth();
454
+ this.leftMonth = month < maxLeft ? month : maxLeft;
455
+ }
456
+
457
+ /** Position the calendars so `date`'s month shows on the right, clamped to maxDate. */
458
+ @action
459
+ private showMonthOnRight(date: LocalDate) {
460
+ const maxRight = this.maxDate.startOfMonth(),
461
+ month = date.startOfMonth();
462
+ this.leftMonth = (month < maxRight ? month : maxRight).subtract(1, 'months');
463
+ }
464
+ }
465
+
466
+ /** The tab on which a selection of the given kind is made. */
467
+ function tabForKind(kind: DateRangeSelection['kind']): DateRangePickerTab {
468
+ switch (kind) {
469
+ case 'preset':
470
+ return 'presets';
471
+ case 'month':
472
+ case 'quarter':
473
+ case 'year':
474
+ return 'period';
475
+ default:
476
+ return kind;
477
+ }
478
+ }
479
+
480
+ function yearOf(date: LocalDate | null): number | null {
481
+ return date ? Number(date.format('YYYY')) : null;
482
+ }
@@ -0,0 +1,138 @@
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 type {DateRangePickerLocalModel} from './DateRangePickerLocalModel';
8
+ import {div, filler, hbox, span, vbox} from '@xh/hoist/cmp/layout';
9
+ import {hoistCmp, type Intent} from '@xh/hoist/core';
10
+ import {button, type ButtonProps} from '@xh/hoist/desktop/cmp/button';
11
+ import {Icon} from '@xh/hoist/icon';
12
+ import {getTestId} from '@xh/hoist/utils/js';
13
+ import {sectionHeader} from './TabUtils';
14
+
15
+ /** Period tab - a year of months, each quarter alongside its months, and the year itself. */
16
+ const MONTH_LABELS = [
17
+ 'Jan',
18
+ 'Feb',
19
+ 'Mar',
20
+ 'Apr',
21
+ 'May',
22
+ 'Jun',
23
+ 'Jul',
24
+ 'Aug',
25
+ 'Sep',
26
+ 'Oct',
27
+ 'Nov',
28
+ 'Dec'
29
+ ];
30
+
31
+ export const periodTab = hoistCmp.factory<DateRangePickerLocalModel>(({model, testId}) => {
32
+ const {parentModel, gridYear} = model,
33
+ {value} = parentModel,
34
+ yearRange = parentModel.resolve({kind: 'year', year: gridYear}).current,
35
+ yearSelected = value.kind === 'year' && value.year === gridYear;
36
+
37
+ return vbox({
38
+ className: 'xh-date-range-picker-popover__tab',
39
+ items: [
40
+ hbox({
41
+ className: 'xh-date-range-picker-popover__month-hdr',
42
+ items: [
43
+ sectionHeader('Months & Years'),
44
+ filler(),
45
+ button({
46
+ className: 'xh-date-range-picker-popover__nav-btn',
47
+ icon: Icon.angleLeft(),
48
+ minimal: true,
49
+ disabled: !model.canYearBack,
50
+ testId: getTestId(testId, 'year-back'),
51
+ onClick: () => model.stepYear(-1)
52
+ }),
53
+ span({
54
+ className: 'xh-date-range-picker-popover__year-val',
55
+ item: String(gridYear)
56
+ }),
57
+ button({
58
+ className: 'xh-date-range-picker-popover__nav-btn',
59
+ icon: Icon.angleRight(),
60
+ minimal: true,
61
+ disabled: !model.canYearForward,
62
+ testId: getTestId(testId, 'year-forward'),
63
+ onClick: () => model.stepYear(1)
64
+ })
65
+ ]
66
+ }),
67
+ div({
68
+ // One row per quarter: its three months, then the quarter itself - so the quarter
69
+ // labels the months it contains, and both are a click away.
70
+ className: 'xh-date-range-picker-popover__month-grid',
71
+ items: [1, 2, 3, 4].flatMap(quarter => {
72
+ const quarterActive =
73
+ value.kind === 'quarter' &&
74
+ value.quarter === quarter &&
75
+ value.year === gridYear,
76
+ months = [quarter * 3 - 2, quarter * 3 - 1, quarter * 3];
77
+ return [
78
+ ...months.map(month => {
79
+ const active =
80
+ value.kind === 'month' &&
81
+ value.month === month &&
82
+ value.year === gridYear;
83
+ return button({
84
+ key: month,
85
+ className: 'xh-date-range-picker-popover__month-btn',
86
+ text: MONTH_LABELS[month - 1],
87
+ ...selectableProps(active, model.intent),
88
+ disabled: model.isMonthDisabled(month),
89
+ testId: getTestId(testId, `month-${month}`),
90
+ onClick: () => model.commit({kind: 'month', year: gridYear, month})
91
+ });
92
+ }),
93
+ button({
94
+ key: `q${quarter}`,
95
+ className: 'xh-date-range-picker-popover__quarter-btn',
96
+ text: `Q${quarter}`,
97
+ ...selectableProps(quarterActive, model.intent),
98
+ disabled: model.isQuarterDisabled(quarter),
99
+ testId: getTestId(testId, `quarter-${quarter}`),
100
+ onClick: () => model.commit({kind: 'quarter', year: gridYear, quarter})
101
+ })
102
+ ];
103
+ })
104
+ }),
105
+ button({
106
+ className: 'xh-date-range-picker-popover__year-row',
107
+ ...selectableProps(yearSelected, model.intent),
108
+ disabled: !model.yearSelectable,
109
+ testId: getTestId(testId, 'year-row'),
110
+ onClick: () => model.commit({kind: 'year', year: gridYear}),
111
+ items: [
112
+ Icon.calendar(),
113
+ span({
114
+ className: 'xh-date-range-picker-popover__year-row-label',
115
+ item: String(gridYear)
116
+ }),
117
+ span({
118
+ // As on the Presets tab - the single-tab popover is too narrow for dates.
119
+ omit: model.singleTab,
120
+ className: 'xh-date-range-picker-popover__row-range',
121
+ item: parentModel.fmtRange(yearRange)
122
+ })
123
+ ]
124
+ })
125
+ ]
126
+ });
127
+ });
128
+
129
+ /**
130
+ * Button props for a selectable period: a solid accent fill when selected (Hoist buttons are
131
+ * minimal by default, so that needs `minimal: false` too), outlined otherwise.
132
+ */
133
+ const selectableProps = (active: boolean, intent: Intent): Partial<ButtonProps> => ({
134
+ outlined: !active,
135
+ minimal: !active,
136
+ active,
137
+ intent: active ? (intent ?? 'primary') : undefined
138
+ });
@@ -0,0 +1,58 @@
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 {getDateRangePresetName} from '@xh/hoist/cmp/daterange';
8
+ import type {DateRangePickerLocalModel} from './DateRangePickerLocalModel';
9
+ import {div, span, vbox} from '@xh/hoist/cmp/layout';
10
+ import {hoistCmp} from '@xh/hoist/core';
11
+ import {Icon} from '@xh/hoist/icon';
12
+ import {getTestId, TEST_ID} from '@xh/hoist/utils/js';
13
+ import classNames from 'classnames';
14
+ import {clickable, sectionHeader} from './TabUtils';
15
+
16
+ /** Presets tab - one row per configured preset, committing on click. */
17
+ export const presetsTab = hoistCmp.factory<DateRangePickerLocalModel>(({model, testId}) => {
18
+ const {parentModel, singleTab} = model,
19
+ {value, context} = parentModel;
20
+
21
+ return vbox({
22
+ className: 'xh-date-range-picker-popover__tab',
23
+ items: [
24
+ sectionHeader('Presets'),
25
+ ...parentModel.presets.map(preset => {
26
+ const {token} = preset,
27
+ selected = value.kind === 'preset' && value.token === token,
28
+ range = parentModel.resolve({kind: 'preset', token}).current;
29
+ return div({
30
+ key: token,
31
+ className: classNames(
32
+ 'xh-date-range-picker-popover__preset-row',
33
+ selected && 'xh-date-range-picker-popover__preset-row--selected'
34
+ ),
35
+ [TEST_ID]: getTestId(testId, `preset-${token}`),
36
+ ...clickable(() => model.commit({kind: 'preset', token})),
37
+ items: [
38
+ Icon.check({
39
+ className: classNames(
40
+ 'xh-date-range-picker-popover__row-check',
41
+ !selected && 'xh-date-range-picker-popover__row-check--hidden'
42
+ )
43
+ }),
44
+ span({
45
+ className: 'xh-date-range-picker-popover__row-name',
46
+ item: getDateRangePresetName(preset, context)
47
+ }),
48
+ span({
49
+ omit: singleTab,
50
+ className: 'xh-date-range-picker-popover__row-range',
51
+ item: parentModel.fmtRange(range)
52
+ })
53
+ ]
54
+ });
55
+ })
56
+ ]
57
+ });
58
+ });