@zeyos/zx 2.1.0 → 2.2.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 (311) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +15 -0
  3. package/dist/types/compat/base.d.ts +156 -0
  4. package/dist/types/compat/globals.d.ts +20 -0
  5. package/dist/types/compat/index.d.ts +128 -0
  6. package/dist/types/compat/map/basic.d.ts +175 -0
  7. package/dist/types/compat/map/data.d.ts +102 -0
  8. package/dist/types/compat/map/date-time.d.ts +80 -0
  9. package/dist/types/compat/map/factory.d.ts +9 -0
  10. package/dist/types/compat/map/form.d.ts +98 -0
  11. package/dist/types/compat/map/helpers.d.ts +12 -0
  12. package/dist/types/compat/map/http.d.ts +36 -0
  13. package/dist/types/compat/map/layout.d.ts +106 -0
  14. package/dist/types/compat/map/options.d.ts +80 -0
  15. package/dist/types/compat/map/select.d.ts +95 -0
  16. package/dist/types/compat/map/stubs.d.ts +32 -0
  17. package/dist/types/compat/map/utilities.d.ts +37 -0
  18. package/dist/types/compat-entry.d.ts +5 -0
  19. package/dist/types/components/badge/badge.d.ts +46 -0
  20. package/dist/types/components/breadcrumb/breadcrumb.d.ts +200 -0
  21. package/dist/types/components/button/button.d.ts +42 -0
  22. package/dist/types/components/check-button/check-button.d.ts +98 -0
  23. package/dist/types/components/checklist/checklist.d.ts +143 -0
  24. package/dist/types/components/checklist/field-adapter.d.ts +5 -0
  25. package/dist/types/components/context-menu/context-menu.d.ts +130 -0
  26. package/dist/types/components/copy/copy-button.d.ts +48 -0
  27. package/dist/types/components/copy/copy-input.d.ts +91 -0
  28. package/dist/types/components/data-filter/data-filter.d.ts +250 -0
  29. package/dist/types/components/data-filter/filter-core.d.ts +52 -0
  30. package/dist/types/components/date-picker/date-picker.d.ts +137 -0
  31. package/dist/types/components/date-picker/date-range-picker.d.ts +350 -0
  32. package/dist/types/components/date-picker/field-adapter.d.ts +6 -0
  33. package/dist/types/components/date-picker/month-picker.d.ts +75 -0
  34. package/dist/types/components/date-picker/range-field-adapter.d.ts +7 -0
  35. package/dist/types/components/date-picker/time-picker.d.ts +127 -0
  36. package/dist/types/components/datebox/date-range-box.d.ts +218 -0
  37. package/dist/types/components/datebox/datebox.d.ts +160 -0
  38. package/dist/types/components/datebox/field-adapter.d.ts +5 -0
  39. package/dist/types/components/dialog/dialog.d.ts +284 -0
  40. package/dist/types/components/dropdown/dropdown.d.ts +118 -0
  41. package/dist/types/components/empty-state/empty-state.d.ts +38 -0
  42. package/dist/types/components/field/field.d.ts +231 -0
  43. package/dist/types/components/field-upload/field-adapter.d.ts +5 -0
  44. package/dist/types/components/field-upload/field-upload.d.ts +213 -0
  45. package/dist/types/components/fieldset/fieldset.d.ts +125 -0
  46. package/dist/types/components/finder/finder.d.ts +252 -0
  47. package/dist/types/components/form/form.d.ts +194 -0
  48. package/dist/types/components/groupbox/groupbox.d.ts +89 -0
  49. package/dist/types/components/layout/layout.d.ts +132 -0
  50. package/dist/types/components/loading/inline-loading.d.ts +86 -0
  51. package/dist/types/components/loading/progress-bar.d.ts +168 -0
  52. package/dist/types/components/loading/spinner.d.ts +34 -0
  53. package/dist/types/components/master-panel/master-panel.d.ts +112 -0
  54. package/dist/types/components/menu-button/menu-button.d.ts +142 -0
  55. package/dist/types/components/message/message.d.ts +212 -0
  56. package/dist/types/components/modal/modal.d.ts +105 -0
  57. package/dist/types/components/multi-value-editor/field-adapter.d.ts +5 -0
  58. package/dist/types/components/multi-value-editor/multi-value-editor.d.ts +66 -0
  59. package/dist/types/components/navigation-bar/navigation-bar.d.ts +229 -0
  60. package/dist/types/components/number-field/field-adapter.d.ts +5 -0
  61. package/dist/types/components/number-field/number-field.d.ts +292 -0
  62. package/dist/types/components/pagination/pagination.d.ts +267 -0
  63. package/dist/types/components/panel/panel.d.ts +194 -0
  64. package/dist/types/components/permission/permission.d.ts +93 -0
  65. package/dist/types/components/rating/field-adapter.d.ts +5 -0
  66. package/dist/types/components/rating/rating.d.ts +213 -0
  67. package/dist/types/components/search/search.d.ts +98 -0
  68. package/dist/types/components/select/field-adapter.d.ts +5 -0
  69. package/dist/types/components/select/filter.d.ts +11 -0
  70. package/dist/types/components/select/select.d.ts +235 -0
  71. package/dist/types/components/skeleton/skeleton.d.ts +97 -0
  72. package/dist/types/components/slider/field-adapter.d.ts +5 -0
  73. package/dist/types/components/slider/slider.d.ts +216 -0
  74. package/dist/types/components/split-view/split-view.d.ts +335 -0
  75. package/dist/types/components/stepper/stepper.d.ts +314 -0
  76. package/dist/types/components/stepper/steps.d.ts +131 -0
  77. package/dist/types/components/tabbox/tabbox.d.ts +230 -0
  78. package/dist/types/components/table/sort.d.ts +49 -0
  79. package/dist/types/components/table/table.d.ts +872 -0
  80. package/dist/types/components/tag-picker/field-adapter.d.ts +5 -0
  81. package/dist/types/components/tag-picker/tag-picker.d.ts +375 -0
  82. package/dist/types/components/timebox/field-adapter.d.ts +5 -0
  83. package/dist/types/components/timebox/timebox.d.ts +121 -0
  84. package/dist/types/components/toggle/field-adapter.d.ts +5 -0
  85. package/dist/types/components/toggle/toggle.d.ts +94 -0
  86. package/dist/types/components/toolbar/toolbar.d.ts +230 -0
  87. package/dist/types/components/tooltip/tooltip.d.ts +183 -0
  88. package/dist/types/components/tree/hierarchy.d.ts +113 -0
  89. package/dist/types/components/tree/tree.d.ts +383 -0
  90. package/dist/types/components/truncate/truncate.d.ts +61 -0
  91. package/dist/types/components/value-list/field-adapter.d.ts +5 -0
  92. package/dist/types/components/value-list/value-list.d.ts +143 -0
  93. package/dist/types/core/breakpoint.d.ts +68 -0
  94. package/dist/types/core/component.d.ts +115 -0
  95. package/dist/types/core/date.d.ts +50 -0
  96. package/dist/types/core/dom.d.ts +86 -0
  97. package/dist/types/core/export.d.ts +70 -0
  98. package/dist/types/core/fontawesome.d.ts +107 -0
  99. package/dist/types/core/format.d.ts +107 -0
  100. package/dist/types/core/http.d.ts +122 -0
  101. package/dist/types/core/i18n.d.ts +35 -0
  102. package/dist/types/core/icons.d.ts +137 -0
  103. package/dist/types/core/keyboard.d.ts +72 -0
  104. package/dist/types/core/position.d.ts +45 -0
  105. package/dist/types/core/storage.d.ts +46 -0
  106. package/dist/types/core/util.d.ts +117 -0
  107. package/dist/types/elements/define.d.ts +73 -0
  108. package/dist/types/elements/reflect.d.ts +27 -0
  109. package/dist/types/index.d.ts +79 -0
  110. package/dist/types/zeyos/connect.d.ts +65 -0
  111. package/dist/types/zeyos/form.d.ts +140 -0
  112. package/dist/types/zeyos/icons.d.ts +104 -0
  113. package/dist/types/zeyos/index.d.ts +12 -0
  114. package/dist/types/zeyos/modules.d.ts +110 -0
  115. package/dist/types/zeyos/query.d.ts +157 -0
  116. package/dist/types/zeyos/schema.d.ts +79 -0
  117. package/dist/types/zeyos/select.d.ts +167 -0
  118. package/dist/types/zeyos/table.d.ts +205 -0
  119. package/dist/zx-compat.esm.js +362 -33
  120. package/dist/zx-compat.esm.min.js +1 -1
  121. package/dist/zx-compat.global.js +359 -67
  122. package/dist/zx-compat.global.min.js +3 -3
  123. package/dist/zx-zeyos.esm.js +342 -43
  124. package/dist/zx-zeyos.esm.min.js +1 -1
  125. package/dist/zx.css +68 -6
  126. package/dist/zx.esm.js +346 -67
  127. package/dist/zx.esm.min.js +3 -3
  128. package/dist/zx.global.js +346 -67
  129. package/dist/zx.global.min.js +3 -3
  130. package/dist/zx.min.css +1 -1
  131. package/docs/api.json +48 -2
  132. package/docs/llms.md +28 -0
  133. package/package.json +37 -9
  134. package/src/compat/base.js +291 -0
  135. package/src/compat/globals.js +165 -0
  136. package/src/compat/index.js +84 -0
  137. package/src/compat/map/basic.js +464 -0
  138. package/src/compat/map/data.js +232 -0
  139. package/src/compat/map/date-time.js +177 -0
  140. package/src/compat/map/factory.js +45 -0
  141. package/src/compat/map/form.js +330 -0
  142. package/src/compat/map/helpers.js +46 -0
  143. package/src/compat/map/http.js +117 -0
  144. package/src/compat/map/layout.js +256 -0
  145. package/src/compat/map/options.js +267 -0
  146. package/src/compat/map/select.js +198 -0
  147. package/src/compat/map/stubs.js +26 -0
  148. package/src/compat/map/utilities.js +113 -0
  149. package/src/compat-entry.js +11 -0
  150. package/src/components/badge/badge.css +147 -0
  151. package/src/components/badge/badge.js +92 -0
  152. package/src/components/breadcrumb/breadcrumb.css +105 -0
  153. package/src/components/breadcrumb/breadcrumb.js +331 -0
  154. package/src/components/button/button.css +104 -0
  155. package/src/components/button/button.js +76 -0
  156. package/src/components/check-button/check-button.css +66 -0
  157. package/src/components/check-button/check-button.js +167 -0
  158. package/src/components/checklist/checklist.css +65 -0
  159. package/src/components/checklist/checklist.js +282 -0
  160. package/src/components/checklist/field-adapter.js +46 -0
  161. package/src/components/context-menu/context-menu.css +54 -0
  162. package/src/components/context-menu/context-menu.js +334 -0
  163. package/src/components/copy/copy-button.css +27 -0
  164. package/src/components/copy/copy-button.js +78 -0
  165. package/src/components/copy/copy-input.css +59 -0
  166. package/src/components/copy/copy-input.js +123 -0
  167. package/src/components/data-filter/data-filter.css +53 -0
  168. package/src/components/data-filter/data-filter.js +400 -0
  169. package/src/components/data-filter/filter-core.js +128 -0
  170. package/src/components/date-picker/date-picker.css +181 -0
  171. package/src/components/date-picker/date-picker.js +500 -0
  172. package/src/components/date-picker/date-range-picker.css +264 -0
  173. package/src/components/date-picker/date-range-picker.js +990 -0
  174. package/src/components/date-picker/field-adapter.js +36 -0
  175. package/src/components/date-picker/month-picker.js +223 -0
  176. package/src/components/date-picker/range-field-adapter.js +31 -0
  177. package/src/components/date-picker/time-picker.css +191 -0
  178. package/src/components/date-picker/time-picker.js +406 -0
  179. package/src/components/datebox/date-range-box.css +128 -0
  180. package/src/components/datebox/date-range-box.js +492 -0
  181. package/src/components/datebox/datebox.css +108 -0
  182. package/src/components/datebox/datebox.js +358 -0
  183. package/src/components/datebox/field-adapter.js +34 -0
  184. package/src/components/dialog/dialog.css +74 -0
  185. package/src/components/dialog/dialog.js +450 -0
  186. package/src/components/dropdown/dropdown.css +24 -0
  187. package/src/components/dropdown/dropdown.js +288 -0
  188. package/src/components/empty-state/empty-state.css +80 -0
  189. package/src/components/empty-state/empty-state.js +92 -0
  190. package/src/components/field/field.css +127 -0
  191. package/src/components/field/field.js +605 -0
  192. package/src/components/field-upload/field-adapter.js +21 -0
  193. package/src/components/field-upload/field-upload.css +108 -0
  194. package/src/components/field-upload/field-upload.js +475 -0
  195. package/src/components/fieldset/fieldset.css +37 -0
  196. package/src/components/fieldset/fieldset.js +307 -0
  197. package/src/components/finder/finder.css +98 -0
  198. package/src/components/finder/finder.js +509 -0
  199. package/src/components/form/form.css +32 -0
  200. package/src/components/form/form.js +302 -0
  201. package/src/components/groupbox/groupbox.css +51 -0
  202. package/src/components/groupbox/groupbox.js +199 -0
  203. package/src/components/layout/layout.css +117 -0
  204. package/src/components/layout/layout.js +136 -0
  205. package/src/components/loading/inline-loading.css +34 -0
  206. package/src/components/loading/inline-loading.js +140 -0
  207. package/src/components/loading/progress-bar.css +115 -0
  208. package/src/components/loading/progress-bar.js +259 -0
  209. package/src/components/loading/spinner.css +58 -0
  210. package/src/components/loading/spinner.js +57 -0
  211. package/src/components/master-panel/master-panel.css +51 -0
  212. package/src/components/master-panel/master-panel.js +203 -0
  213. package/src/components/menu-button/menu-button.css +49 -0
  214. package/src/components/menu-button/menu-button.js +294 -0
  215. package/src/components/message/message.css +122 -0
  216. package/src/components/message/message.js +499 -0
  217. package/src/components/modal/modal.css +47 -0
  218. package/src/components/modal/modal.js +203 -0
  219. package/src/components/multi-value-editor/field-adapter.js +44 -0
  220. package/src/components/multi-value-editor/multi-value-editor.css +56 -0
  221. package/src/components/multi-value-editor/multi-value-editor.js +170 -0
  222. package/src/components/navigation-bar/navigation-bar.css +130 -0
  223. package/src/components/navigation-bar/navigation-bar.js +450 -0
  224. package/src/components/number-field/field-adapter.js +40 -0
  225. package/src/components/number-field/number-field.css +77 -0
  226. package/src/components/number-field/number-field.js +545 -0
  227. package/src/components/pagination/pagination.css +95 -0
  228. package/src/components/pagination/pagination.js +481 -0
  229. package/src/components/panel/panel.css +123 -0
  230. package/src/components/panel/panel.js +336 -0
  231. package/src/components/permission/permission.css +47 -0
  232. package/src/components/permission/permission.js +198 -0
  233. package/src/components/rating/field-adapter.js +34 -0
  234. package/src/components/rating/rating.css +97 -0
  235. package/src/components/rating/rating.js +395 -0
  236. package/src/components/search/search.css +70 -0
  237. package/src/components/search/search.js +193 -0
  238. package/src/components/select/field-adapter.js +24 -0
  239. package/src/components/select/filter.js +39 -0
  240. package/src/components/select/select.css +180 -0
  241. package/src/components/select/select.js +663 -0
  242. package/src/components/skeleton/skeleton.css +76 -0
  243. package/src/components/skeleton/skeleton.js +139 -0
  244. package/src/components/slider/field-adapter.js +36 -0
  245. package/src/components/slider/slider.css +181 -0
  246. package/src/components/slider/slider.js +399 -0
  247. package/src/components/split-view/split-view.css +139 -0
  248. package/src/components/split-view/split-view.js +806 -0
  249. package/src/components/stepper/stepper.css +234 -0
  250. package/src/components/stepper/stepper.js +459 -0
  251. package/src/components/stepper/steps.js +131 -0
  252. package/src/components/tabbox/tabbox.css +281 -0
  253. package/src/components/tabbox/tabbox.js +484 -0
  254. package/src/components/table/sort.js +85 -0
  255. package/src/components/table/table.css +364 -0
  256. package/src/components/table/table.js +1693 -0
  257. package/src/components/tag-picker/field-adapter.js +35 -0
  258. package/src/components/tag-picker/tag-picker.css +169 -0
  259. package/src/components/tag-picker/tag-picker.js +837 -0
  260. package/src/components/timebox/field-adapter.js +24 -0
  261. package/src/components/timebox/timebox.css +51 -0
  262. package/src/components/timebox/timebox.js +276 -0
  263. package/src/components/toggle/field-adapter.js +24 -0
  264. package/src/components/toggle/toggle.css +66 -0
  265. package/src/components/toggle/toggle.js +150 -0
  266. package/src/components/toolbar/toolbar.css +81 -0
  267. package/src/components/toolbar/toolbar.js +511 -0
  268. package/src/components/tooltip/tooltip.css +36 -0
  269. package/src/components/tooltip/tooltip.js +420 -0
  270. package/src/components/tree/hierarchy.js +151 -0
  271. package/src/components/tree/tree.css +99 -0
  272. package/src/components/tree/tree.js +784 -0
  273. package/src/components/truncate/truncate.css +20 -0
  274. package/src/components/truncate/truncate.js +92 -0
  275. package/src/components/value-list/field-adapter.js +24 -0
  276. package/src/components/value-list/value-list.css +108 -0
  277. package/src/components/value-list/value-list.js +336 -0
  278. package/src/core/breakpoint.js +150 -0
  279. package/src/core/component.js +292 -0
  280. package/src/core/date.js +216 -0
  281. package/src/core/dom.js +260 -0
  282. package/src/core/export.js +204 -0
  283. package/src/core/fontawesome.js +177 -0
  284. package/src/core/format.js +283 -0
  285. package/src/core/http.js +246 -0
  286. package/src/core/i18n.js +83 -0
  287. package/src/core/icons.js +283 -0
  288. package/src/core/keyboard.js +240 -0
  289. package/src/core/position.js +177 -0
  290. package/src/core/storage.js +175 -0
  291. package/src/core/util.js +336 -0
  292. package/src/elements/define.js +715 -0
  293. package/src/elements/reflect.js +79 -0
  294. package/src/index.js +131 -0
  295. package/src/zeyos/connect.js +142 -0
  296. package/src/zeyos/form.js +254 -0
  297. package/src/zeyos/icons.js +92 -0
  298. package/src/zeyos/index.js +21 -0
  299. package/src/zeyos/modules.js +269 -0
  300. package/src/zeyos/query.js +199 -0
  301. package/src/zeyos/schema.js +474 -0
  302. package/src/zeyos/select.js +175 -0
  303. package/src/zeyos/table.js +272 -0
  304. package/styles/base.css +124 -0
  305. package/styles/icons.css +34 -0
  306. package/styles/tokens/dark.css +84 -0
  307. package/styles/tokens/density.css +9 -0
  308. package/styles/tokens/global.css +80 -0
  309. package/styles/tokens/modules.css +109 -0
  310. package/styles/tokens/semantic.css +49 -0
  311. package/styles/zx.css +61 -0
@@ -0,0 +1,990 @@
1
+ import { Component } from '../../core/component.js';
2
+ import { addDays, addMonths, formatDate, getWeekStart, isSameDay } from '../../core/date.js';
3
+ import { h } from '../../core/dom.js';
4
+ import { printf, translate } from '../../core/i18n.js';
5
+ import { icon } from '../../core/icons.js';
6
+
7
+ /** @typedef {{start: Date|null, end: Date|null}} DateRange */
8
+ /** @typedef {'start'|'middle'|'end'} DateRangeState */
9
+
10
+ /**
11
+ * @typedef {Object} DateRangeBounds
12
+ * @property {Date|null} [min=null] Earliest selectable local calendar day.
13
+ * @property {Date|null} [max=null] Latest selectable local calendar day.
14
+ * @property {number} [minNights=0] Smallest number of nights a complete range may span.
15
+ * @property {number|null} [maxNights=null] Largest number of nights a complete range may span.
16
+ */
17
+
18
+ /**
19
+ * @typedef {Object} DateRangePreset
20
+ * @property {string} name Stable identifier, emitted on the preset button as `data-preset`.
21
+ * @property {string} label Button text.
22
+ * @property {() => DateRange} range Factory returning the range the preset applies.
23
+ */
24
+
25
+ /**
26
+ * @typedef {Object} DateRangePickerOptions
27
+ * @property {Date|null} [start=null] Initially selected first day.
28
+ * @property {Date|null} [end=null] Initially selected last day.
29
+ * @property {Date|null} [min=null] Earliest selectable local calendar day.
30
+ * @property {Date|null} [max=null] Latest selectable local calendar day.
31
+ * @property {number} [months=2] Month panels to render; narrow containers show only the first.
32
+ * @property {number} [weekStart=1] First weekday, from 0 (Sunday) through 6 (Saturday).
33
+ * @property {boolean} [showWeekNumbers=false] Show ISO week numbers.
34
+ * @property {number} [minNights=0] Smallest number of nights a complete range may span.
35
+ * @property {number|null} [maxNights=null] Largest number of nights a complete range may span.
36
+ * @property {DateRangePreset[]|boolean} [presets=[]] Preset buttons, or `true` for the built-in set.
37
+ * @property {boolean} [disabled=false] Disable every control.
38
+ * @property {(event: CustomEvent<DateRange>) => void} [onchange] Complete-range listener.
39
+ * @property {(event: CustomEvent<DateRange>) => void} [onselect] Every-click listener.
40
+ * @property {(event: CustomEvent<{year: number, month: number}>) => void} [onmonthchange] Visible-month listener.
41
+ */
42
+ /** @typedef {{silent?: boolean}} DateRangePickerSetOptions */
43
+
44
+ const WEEK_ROWS = 6;
45
+ const DAY_MS = 86400000;
46
+
47
+ /**
48
+ * Puts a start/end pair in chronological order at local midnight.
49
+ * Values that are not usable `Date` instances become `null`.
50
+ * @param {unknown} start Range start.
51
+ * @param {unknown} end Range end.
52
+ * @returns {DateRange}
53
+ */
54
+ export function normalizeRange(start, end) {
55
+ const first = dayOnly(validDate(start));
56
+ const second = dayOnly(validDate(end));
57
+ if (first && second && first.getTime() > second.getTime()) return { start: second, end: first };
58
+ return { start: first, end: second };
59
+ }
60
+
61
+ /**
62
+ * Counts the nights between two days. The same day spans zero nights, and the count is derived
63
+ * from calendar days so daylight-saving transitions never add or drop a night.
64
+ * @param {unknown} start Range start.
65
+ * @param {unknown} end Range end.
66
+ * @returns {number} Nights, or `0` when the range is incomplete.
67
+ */
68
+ export function rangeNights(start, end) {
69
+ const range = normalizeRange(start, end);
70
+ if (!range.start || !range.end) return 0;
71
+ return Math.round((utcDay(range.end) - utcDay(range.start)) / DAY_MS);
72
+ }
73
+
74
+ /**
75
+ * Classifies one day against a range. A half-open range only matches its single endpoint, and a
76
+ * one-day range reports `'start'`.
77
+ * @param {unknown} day Day to classify.
78
+ * @param {unknown} start Range start.
79
+ * @param {unknown} end Range end.
80
+ * @returns {DateRangeState|null} The day's role, or `null` when it is outside the range.
81
+ */
82
+ export function rangeStateOf(day, start, end) {
83
+ const date = dayOnly(validDate(day));
84
+ if (!date) return null;
85
+ const range = normalizeRange(start, end);
86
+ if (!range.start && !range.end) return null;
87
+ if (!range.end) return isSameDay(date, /** @type {Date} */ (range.start)) ? 'start' : null;
88
+ if (!range.start) return isSameDay(date, range.end) ? 'end' : null;
89
+ if (date.getTime() < range.start.getTime() || date.getTime() > range.end.getTime()) return null;
90
+ if (isSameDay(date, range.start)) return 'start';
91
+ if (isSameDay(date, range.end)) return 'end';
92
+ return 'middle';
93
+ }
94
+
95
+ /**
96
+ * Normalizes a range and pulls it inside the configured bounds: endpoints are clamped into
97
+ * `min`/`max`, an over-long range is shortened to `maxNights`, and a too-short one is stretched to
98
+ * `minNights` — moving `end` forward, or `start` backward when `max` blocks the way. When the
99
+ * `min`/`max` window itself is narrower than `minNights` the range cannot be honoured and both
100
+ * endpoints come back `null`.
101
+ * @param {DateRange|null|undefined} range Range to clamp.
102
+ * @param {DateRangeBounds} [bounds={}] Selection bounds.
103
+ * @returns {DateRange} A new range of new `Date` objects.
104
+ */
105
+ export function clampRange(range, bounds = {}) {
106
+ const min = dayOnly(validDate(bounds.min));
107
+ const max = dayOnly(validDate(bounds.max));
108
+ const least = normalizeNightCount(bounds.minNights) ?? 0;
109
+ const most = normalizeNightCount(bounds.maxNights);
110
+ let { start, end } = normalizeRange(range?.start, range?.end);
111
+
112
+ if (start) start = clampDay(start, min, max);
113
+ if (end) end = clampDay(end, min, max);
114
+ if (!start || !end) return { start, end };
115
+
116
+ if (most !== null && rangeNights(start, end) > most) end = addDays(start, most);
117
+ if (least > 0 && rangeNights(start, end) < least) {
118
+ end = addDays(start, least);
119
+ if (max && end.getTime() > max.getTime()) {
120
+ end = new Date(max.getTime());
121
+ start = addDays(end, -least);
122
+ if (min && start.getTime() < min.getTime()) return { start: null, end: null };
123
+ }
124
+ }
125
+ return { start, end };
126
+ }
127
+
128
+ /**
129
+ * Inline two-month range calendar following the APG date-picker grid pattern. Both grids form a
130
+ * single roving-focus unit, so arrowing off the end of one month continues in the next.
131
+ * @fires DateRangePicker#change
132
+ * @fires DateRangePicker#select
133
+ * @fires DateRangePicker#monthchange
134
+ * @extends {Component<DateRangePickerOptions>}
135
+ */
136
+ export class DateRangePicker extends Component {
137
+ static cssName = 'date-range-picker';
138
+
139
+ /** @type {DateRangePickerOptions} */
140
+ static defaults = {
141
+ start: null,
142
+ end: null,
143
+ min: null,
144
+ max: null,
145
+ months: 2,
146
+ weekStart: 1,
147
+ showWeekNumbers: false,
148
+ minNights: 0,
149
+ maxNights: null,
150
+ presets: [],
151
+ disabled: false
152
+ };
153
+
154
+ // Every field below is initialized here rather than as a class field: render() runs inside the
155
+ // base constructor, before class-field initializers would overwrite it (see AGENTS.md).
156
+
157
+ /** @returns {HTMLElement} */
158
+ render() {
159
+ const root = /** @type {HTMLElement} */ (this.el ?? h('div'));
160
+ this.el = root;
161
+ this._rootAttributes = rememberAttributes(root, [
162
+ 'role', 'aria-label', 'aria-disabled', 'data-months', 'data-presets'
163
+ ]);
164
+ this._min = dayOnly(validDate(this.options.min));
165
+ this._max = dayOnly(validDate(this.options.max));
166
+ this._minNights = normalizeNightCount(this.options.minNights) ?? 0;
167
+ this._maxNights = normalizeNightCount(this.options.maxNights);
168
+ this._weekStart = normalizeWeekStart(this.options.weekStart);
169
+ this._monthCount = normalizeMonthCount(this.options.months);
170
+ this._presets = normalizePresets(this.options.presets);
171
+ this._disabled = Boolean(this.options.disabled);
172
+ this._hover = null;
173
+ /** @type {{date: Date, cell: HTMLTableCellElement, button: HTMLButtonElement}[]} */
174
+ this._dayCells = [];
175
+
176
+ const initial = clampRange({ start: this.options.start, end: this.options.end }, this._bounds());
177
+ this._start = initial.start;
178
+ this._end = initial.end;
179
+ // A start without an end is already the half-picked state, exactly as set() leaves it.
180
+ this._picking = Boolean(initial.start && !initial.end);
181
+ this._remember();
182
+ this._focused = cloneDate(this._start) ?? this._clampFocus(dayOnly(new Date()));
183
+ this._viewYear = this._focused.getFullYear();
184
+ this._viewMonth = this._focused.getMonth();
185
+
186
+ root.setAttribute('role', 'group');
187
+ root.setAttribute('aria-label', text('daterange.label', 'Date range'));
188
+ root.dataset.months = String(this._monthCount);
189
+ if (this._presets.length) root.dataset.presets = 'true';
190
+
191
+ const surface = h('div', { class: 'zx-date-range-picker__surface' },
192
+ this._presets.length ? h('div', {
193
+ ref: 'presets',
194
+ class: 'zx-date-range-picker__presets',
195
+ role: 'group',
196
+ ariaLabel: text('daterange.presets', 'Date range presets')
197
+ }, this._presets.map((preset) => h('button', {
198
+ class: 'zx-date-range-picker__preset',
199
+ type: 'button',
200
+ dataset: { preset: preset.name }
201
+ }, preset.label))) : null,
202
+ h('div', { ref: 'calendars', class: 'zx-date-range-picker__calendars' })
203
+ );
204
+ const status = h('div', {
205
+ ref: 'status',
206
+ class: 'zx-date-range-picker__status',
207
+ role: 'status',
208
+ ariaLive: 'polite'
209
+ });
210
+ this._nodes = [surface, status];
211
+ root.append(surface, status);
212
+
213
+ if (this.refs.presets) this.listen(this.refs.presets, 'click', (event) => this._onPresetClick(event));
214
+ this.listen(this.refs.calendars, 'click', (event) => this._onCalendarClick(event));
215
+ this.listen(this.refs.calendars, 'keydown', (event) => this._onDayKeydown(event));
216
+ this.listen(this.refs.calendars, 'mouseover', (event) => this._onDayHover(event));
217
+ this.listen(this.refs.calendars, 'mouseleave', () => this._setHover(null));
218
+
219
+ this._renderCalendars();
220
+ this._announce();
221
+ return root;
222
+ }
223
+
224
+ /**
225
+ * Returns copies of the selected endpoints; the internal dates are never handed out.
226
+ * @returns {DateRange}
227
+ */
228
+ get() {
229
+ return { start: cloneDate(this._start), end: cloneDate(this._end) };
230
+ }
231
+
232
+ /**
233
+ * Selects a range, clamped to the configured bounds, and scrolls the first visible month to the
234
+ * range start.
235
+ * @param {DateRange|null} [range={}] Range to select; omit either endpoint to clear it.
236
+ * @param {DateRangePickerSetOptions} [options={}] Update behavior.
237
+ * @returns {this}
238
+ * @fires DateRangePicker#change
239
+ * @fires DateRangePicker#monthchange
240
+ */
241
+ set(range = {}, { silent = false } = {}) {
242
+ const next = clampRange({ start: range?.start ?? null, end: range?.end ?? null }, this._bounds());
243
+ this._start = next.start;
244
+ this._end = next.end;
245
+ this._picking = Boolean(next.start && !next.end);
246
+ this._remember();
247
+ this._hover = null;
248
+ if (next.start) {
249
+ this._focused = cloneDate(next.start);
250
+ this._setView(next.start);
251
+ } else {
252
+ this._paint();
253
+ }
254
+ this._announce();
255
+ if (!silent) this.emit('change', this.get());
256
+ return this;
257
+ }
258
+
259
+ /**
260
+ * Clears the selection.
261
+ * @param {DateRangePickerSetOptions} [options={}] Update behavior.
262
+ * @returns {this}
263
+ * @fires DateRangePicker#change
264
+ */
265
+ clear({ silent = false } = {}) {
266
+ return this.set({ start: null, end: null }, { silent });
267
+ }
268
+
269
+ /**
270
+ * Moves focus to the currently roving calendar day.
271
+ * @returns {this}
272
+ */
273
+ focus() {
274
+ this._buttonForDate(this._focused)?.focus();
275
+ return this;
276
+ }
277
+
278
+ /** Enables every control. @returns {this} */
279
+ enable() {
280
+ this._disabled = false;
281
+ this._paint();
282
+ this._syncDisabled();
283
+ return this;
284
+ }
285
+
286
+ /** Disables every control. @returns {this} */
287
+ disable() {
288
+ this._disabled = true;
289
+ this._hover = null;
290
+ this._paint();
291
+ this._syncDisabled();
292
+ return this;
293
+ }
294
+
295
+ /** Removes generated content and restores the host element's attributes. @returns {void} */
296
+ destroy() {
297
+ for (const node of this._nodes ?? []) node.remove();
298
+ this._nodes = [];
299
+ this._dayCells = [];
300
+ restoreAttributes(this.el, this._rootAttributes ?? new Map());
301
+ super.destroy();
302
+ }
303
+
304
+ /* --------------------------------------------------------------------- selection -- */
305
+
306
+ /** @returns {DateRangeBounds} */
307
+ _bounds() {
308
+ return {
309
+ min: this._min,
310
+ max: this._max,
311
+ minNights: this._minNights,
312
+ maxNights: this._maxNights
313
+ };
314
+ }
315
+
316
+ /**
317
+ * Reports whether a day may be clicked. Days before the pending start always may — they restart
318
+ * the range there instead of producing an inverted one.
319
+ * @param {Date} date Day to test.
320
+ * @returns {boolean}
321
+ */
322
+ _selectable(date) {
323
+ const day = dayOnly(date);
324
+ if (!day) return false;
325
+ if (this._min && day.getTime() < this._min.getTime()) return false;
326
+ if (this._max && day.getTime() > this._max.getTime()) return false;
327
+ if (!this._picking || !this._start) return true;
328
+ if (day.getTime() <= this._start.getTime()) return true;
329
+ const nights = rangeNights(this._start, day);
330
+ if (nights < this._minNights) return false;
331
+ if (this._maxNights !== null && nights > this._maxNights) return false;
332
+ return true;
333
+ }
334
+
335
+ /**
336
+ * Applies one day click: the first sets the start, the second the end, and a click on or before
337
+ * the pending start restarts the range there.
338
+ * @param {Date} date Clicked day.
339
+ * @returns {void}
340
+ * @fires DateRangePicker#select
341
+ * @fires DateRangePicker#change
342
+ */
343
+ _choose(date) {
344
+ const day = dayOnly(date);
345
+ if (this._disabled || !day || !this._selectable(day)) return;
346
+ const extending = this._picking && this._start && day.getTime() > this._start.getTime();
347
+ if (extending) {
348
+ this._end = day;
349
+ this._picking = false;
350
+ this._remember();
351
+ } else {
352
+ this._start = day;
353
+ this._end = null;
354
+ this._picking = true;
355
+ }
356
+ this._focused = day;
357
+ this._hover = null;
358
+ this._paint();
359
+ this._announce();
360
+ this.emit('select', this.get());
361
+ if (!this._picking) this.emit('change', this.get());
362
+ }
363
+
364
+ /**
365
+ * Snapshots the selection Escape falls back to. Only a complete range is worth restoring, so a
366
+ * half-open one is remembered as no selection at all.
367
+ * @returns {void}
368
+ */
369
+ _remember() {
370
+ const complete = Boolean(this._start && this._end);
371
+ this._committedStart = complete ? cloneDate(this._start) : null;
372
+ this._committedEnd = complete ? cloneDate(this._end) : null;
373
+ }
374
+
375
+ /**
376
+ * Abandons a half-picked range, restoring the last complete selection.
377
+ * @returns {boolean} Whether a half-picked range was discarded.
378
+ * @fires DateRangePicker#select
379
+ */
380
+ _abandon() {
381
+ if (!this._picking) return false;
382
+ this._start = cloneDate(this._committedStart);
383
+ this._end = cloneDate(this._committedEnd);
384
+ this._picking = false;
385
+ this._hover = null;
386
+ this._paint();
387
+ this._announce();
388
+ this.emit('select', this.get());
389
+ return true;
390
+ }
391
+
392
+ /* ------------------------------------------------------------------------ view -- */
393
+
394
+ /** @param {Date} date @returns {Date} */
395
+ _clampFocus(date) {
396
+ return clampDay(date, this._min, this._max);
397
+ }
398
+
399
+ /**
400
+ * Counts the month panels the container query is currently showing, falling back to the
401
+ * configured count while the picker is not rendered.
402
+ * @returns {number}
403
+ */
404
+ _visibleMonthCount() {
405
+ const panels = this.refs.calendars?.children ?? [];
406
+ let count = 0;
407
+ for (const panel of panels) if (panel.getClientRects().length > 0) count += 1;
408
+ return count || this._monthCount;
409
+ }
410
+
411
+ /**
412
+ * Points the first visible month at a date's month.
413
+ * @param {Date} date Day whose month becomes the leftmost panel.
414
+ * @returns {void}
415
+ * @fires DateRangePicker#monthchange
416
+ */
417
+ _setView(date) {
418
+ const changed = date.getFullYear() !== this._viewYear || date.getMonth() !== this._viewMonth;
419
+ this._viewYear = date.getFullYear();
420
+ this._viewMonth = date.getMonth();
421
+ // Repaint rather than rebuild when the months on screen stay the same, so a set() that lands
422
+ // inside the current view does not drop the focused day cell.
423
+ if (!changed) {
424
+ this._paint();
425
+ return;
426
+ }
427
+ this._renderCalendars();
428
+ this._emitMonthChange();
429
+ }
430
+
431
+ /**
432
+ * Shifts the view by the smallest number of months that brings a date into a visible panel.
433
+ * @param {Date} date Day that must be visible.
434
+ * @returns {boolean} Whether the view moved.
435
+ */
436
+ _ensureVisible(date) {
437
+ const visible = this._visibleMonthCount();
438
+ const offset = monthIndex(date) - (this._viewYear * 12 + this._viewMonth);
439
+ if (offset >= 0 && offset < visible) return false;
440
+ const shift = offset < 0 ? offset : offset - (visible - 1);
441
+ const base = new Date(this._viewYear, this._viewMonth + shift, 1);
442
+ this._viewYear = base.getFullYear();
443
+ this._viewMonth = base.getMonth();
444
+ return true;
445
+ }
446
+
447
+ /** @param {number} amount @returns {void} */
448
+ _pageView(amount) {
449
+ if (this._disabled) return;
450
+ const base = new Date(this._viewYear, this._viewMonth + amount, 1);
451
+ this._viewYear = base.getFullYear();
452
+ this._viewMonth = base.getMonth();
453
+ this._focused = this._focusInView(this._focused);
454
+ this._renderCalendars();
455
+ this._emitMonthChange();
456
+ }
457
+
458
+ /**
459
+ * Keeps the roving tab stop inside the visible panels after the view moved on its own.
460
+ * @param {Date} date Current roving day.
461
+ * @returns {Date}
462
+ */
463
+ _focusInView(date) {
464
+ const visible = this._visibleMonthCount();
465
+ const offset = monthIndex(date) - (this._viewYear * 12 + this._viewMonth);
466
+ if (offset >= 0 && offset < visible) return date;
467
+ const lastDay = new Date(this._viewYear, this._viewMonth + 1, 0).getDate();
468
+ return new Date(this._viewYear, this._viewMonth, Math.min(date.getDate(), lastDay));
469
+ }
470
+
471
+ /** @param {Date} date @returns {void} */
472
+ _moveFocus(date) {
473
+ const target = dayOnly(date);
474
+ this._focused = target;
475
+ if (this._ensureVisible(target)) {
476
+ this._renderCalendars();
477
+ this._emitMonthChange();
478
+ } else {
479
+ this._paint();
480
+ }
481
+ this._buttonForDate(target)?.focus();
482
+ }
483
+
484
+ /** @returns {void} */
485
+ _emitMonthChange() {
486
+ this.emit('monthchange', { year: this._viewYear, month: this._viewMonth });
487
+ }
488
+
489
+ /* ---------------------------------------------------------------------- events -- */
490
+
491
+ /** @param {Event} event @returns {void} */
492
+ _onPresetClick(event) {
493
+ const button = /** @type {Element} */ (event.target)?.closest?.('[data-preset]');
494
+ if (!button || this._disabled) return;
495
+ const preset = this._presets.find((entry) => entry.name === button.dataset.preset);
496
+ if (!preset) return;
497
+ const range = preset.range();
498
+ this.set({ start: range?.start ?? null, end: range?.end ?? null }, { silent: true });
499
+ this.emit('select', this.get());
500
+ this.emit('change', this.get());
501
+ }
502
+
503
+ /** @param {Event} event @returns {void} */
504
+ _onCalendarClick(event) {
505
+ const target = /** @type {Element} */ (event.target);
506
+ const nav = target?.closest?.('[data-nav]');
507
+ if (nav) {
508
+ this._pageView(nav.dataset.nav === 'next' ? 1 : -1);
509
+ return;
510
+ }
511
+ const day = this._cellFor(target);
512
+ if (day) this._choose(day.date);
513
+ }
514
+
515
+ /** @param {Event} event @returns {void} */
516
+ _onDayHover(event) {
517
+ const day = this._cellFor(/** @type {Element} */ (event.target));
518
+ this._setHover(day ? day.date : null);
519
+ }
520
+
521
+ /** @param {Date|null} date @returns {void} */
522
+ _setHover(date) {
523
+ if (this._disabled || !this._picking) return;
524
+ const next = date ? dayOnly(date) : null;
525
+ const same = (!next && !this._hover) || (next && this._hover && isSameDay(next, this._hover));
526
+ if (same) return;
527
+ this._hover = next;
528
+ this._paint();
529
+ }
530
+
531
+ /** @param {KeyboardEvent} event @returns {void} */
532
+ _onDayKeydown(event) {
533
+ const day = this._cellFor(/** @type {Element} */ (event.target));
534
+ if (!day) return;
535
+ const current = day.date;
536
+ let next = null;
537
+ if (event.key === 'ArrowLeft') next = addDays(current, -1);
538
+ else if (event.key === 'ArrowRight') next = addDays(current, 1);
539
+ else if (event.key === 'ArrowUp') next = addDays(current, -7);
540
+ else if (event.key === 'ArrowDown') next = addDays(current, 7);
541
+ else if (event.key === 'PageUp') next = addMonths(current, event.shiftKey ? -12 : -1);
542
+ else if (event.key === 'PageDown') next = addMonths(current, event.shiftKey ? 12 : 1);
543
+ else if (event.key === 'Home') next = addDays(current, -((current.getDay() - this._weekStart + 7) % 7));
544
+ else if (event.key === 'End') next = addDays(current, 6 - ((current.getDay() - this._weekStart + 7) % 7));
545
+ else if (event.key === 'Enter' || event.key === ' ') {
546
+ event.preventDefault();
547
+ this._choose(current);
548
+ return;
549
+ } else if (event.key === 'Escape') {
550
+ // Only swallow Escape while a range is half picked; otherwise an enclosing popover closes.
551
+ if (!this._abandon()) return;
552
+ event.preventDefault();
553
+ event.stopPropagation();
554
+ return;
555
+ }
556
+ if (!next || this._disabled) return;
557
+ event.preventDefault();
558
+ this._moveFocus(next);
559
+ }
560
+
561
+ /* ------------------------------------------------------------------ rendering -- */
562
+
563
+ /** @returns {void} */
564
+ _renderCalendars() {
565
+ if (!this.refs?.calendars) return;
566
+ this._dayCells = [];
567
+ const panels = [];
568
+ for (let index = 0; index < this._monthCount; index += 1) {
569
+ panels.push(this._renderMonth(new Date(this._viewYear, this._viewMonth + index, 1)));
570
+ }
571
+ this.refs.calendars.replaceChildren(...panels);
572
+ this._paint();
573
+ this._syncDisabled();
574
+ }
575
+
576
+ /** @param {Date} first First day of the month to render. @returns {HTMLElement} */
577
+ _renderMonth(first) {
578
+ const caption = formatDate(first, '%B %Y');
579
+ const offset = (first.getDay() - this._weekStart + 7) % 7;
580
+ const gridStart = addDays(first, -offset);
581
+ const rows = [];
582
+
583
+ for (let week = 0; week < WEEK_ROWS; week += 1) {
584
+ const rowStart = addDays(gridStart, week * 7);
585
+ const row = h('tr');
586
+ const inMonth = weekTouchesMonth(rowStart, first);
587
+ if (this.options.showWeekNumbers) {
588
+ row.append(h('th', {
589
+ class: 'zx-date-range-picker__week',
590
+ scope: 'row',
591
+ ariaLabel: inMonth ? text('daterange.week', 'Week %1', [isoWeekNumber(rowStart)]) : null
592
+ }, inMonth ? String(isoWeekNumber(rowStart)) : ''));
593
+ }
594
+ for (let column = 0; column < 7; column += 1) {
595
+ const date = addDays(rowStart, column);
596
+ const cell = h('td', { role: 'presentation' });
597
+ if (date.getMonth() === first.getMonth() && date.getFullYear() === first.getFullYear()) {
598
+ const button = h('button', {
599
+ class: 'zx-date-range-picker__day',
600
+ type: 'button',
601
+ role: 'gridcell',
602
+ tabindex: -1,
603
+ dataset: { date: formatDate(date, '%Y-%m-%d') }
604
+ }, formatDate(date, '%e'));
605
+ cell.append(button);
606
+ this._dayCells.push({ date, cell, button });
607
+ }
608
+ row.append(cell);
609
+ }
610
+ rows.push(row);
611
+ }
612
+
613
+ return h('div', { class: 'zx-date-range-picker__month' },
614
+ h('div', { class: 'zx-date-range-picker__header' },
615
+ h('button', {
616
+ class: 'zx-date-range-picker__nav',
617
+ type: 'button',
618
+ dataset: { nav: 'previous' },
619
+ ariaLabel: text('daterange.previousMonth', 'Previous month')
620
+ }, icon('chevron-left')),
621
+ h('div', { class: 'zx-date-range-picker__caption', ariaHidden: 'true' }, caption),
622
+ h('button', {
623
+ class: 'zx-date-range-picker__nav',
624
+ type: 'button',
625
+ dataset: { nav: 'next' },
626
+ ariaLabel: text('daterange.nextMonth', 'Next month')
627
+ }, icon('chevron-right'))),
628
+ h('table', {
629
+ class: 'zx-date-range-picker__grid',
630
+ role: 'grid',
631
+ ariaLabel: caption
632
+ },
633
+ h('thead', {}, this._weekdayRow()),
634
+ h('tbody', {}, ...rows)));
635
+ }
636
+
637
+ /** @returns {HTMLTableRowElement} */
638
+ _weekdayRow() {
639
+ const row = h('tr');
640
+ if (this.options.showWeekNumbers) {
641
+ row.append(h('th', {
642
+ class: 'zx-date-range-picker__week-heading',
643
+ scope: 'col',
644
+ ariaLabel: text('daterange.weekNumber', 'Week number')
645
+ }, '#'));
646
+ }
647
+ const sunday = new Date(2023, 0, 1);
648
+ for (let index = 0; index < 7; index += 1) {
649
+ const weekday = addDays(sunday, (this._weekStart + index) % 7);
650
+ row.append(h('th', { scope: 'col', ariaLabel: formatDate(weekday, '%A') }, formatDate(weekday, '%a')));
651
+ }
652
+ return /** @type {HTMLTableRowElement} */ (row);
653
+ }
654
+
655
+ /**
656
+ * Refreshes every day cell's range, selection, and focus state without rebuilding the grids, so
657
+ * hovering and arrowing never disturb the focused element.
658
+ * @returns {void}
659
+ */
660
+ _paint() {
661
+ const today = new Date();
662
+ // Exactly one day carries the tab stop, across both grids: they are one roving-focus unit.
663
+ let roving = null;
664
+ for (const entry of this._dayCells) {
665
+ if (isSameDay(entry.date, this._focused)) roving = entry;
666
+ }
667
+ if (!roving) roving = this._dayCells[0] ?? null;
668
+
669
+ for (const entry of this._dayCells) {
670
+ const { date, cell, button } = entry;
671
+ const state = this._dayState(date);
672
+ setDataset(cell, 'range', state.range);
673
+ setDataset(cell, 'rangeEdge', state.edge);
674
+ setDataset(button, 'range', state.range);
675
+ button.tabIndex = entry === roving ? 0 : -1;
676
+ button.setAttribute('aria-selected', String(state.range !== null && state.range !== 'preview'));
677
+ button.setAttribute('aria-disabled', String(!this._selectable(date)));
678
+ button.setAttribute('aria-label', this._dayLabel(date, state.range));
679
+ if (isSameDay(date, today)) button.setAttribute('aria-current', 'date');
680
+ else button.removeAttribute('aria-current');
681
+ }
682
+ }
683
+
684
+ /**
685
+ * Resolves how a day should paint: committed endpoints and band, or the live preview while an
686
+ * end date is being picked.
687
+ * @param {Date} date Day to classify.
688
+ * @returns {{range: DateRangeState|'preview'|null, edge: 'start'|'end'|'both'|null}}
689
+ */
690
+ _dayState(date) {
691
+ if (this._start && this._end) {
692
+ const range = rangeStateOf(date, this._start, this._end);
693
+ if (!range) return { range: null, edge: null };
694
+ const solo = isSameDay(this._start, this._end);
695
+ if (range === 'start') return { range, edge: solo ? 'both' : 'start' };
696
+ if (range === 'end') return { range, edge: solo ? 'both' : 'end' };
697
+ return { range, edge: null };
698
+ }
699
+ if (!this._start) return { range: null, edge: null };
700
+ const preview = this._previewEnd();
701
+ if (isSameDay(date, this._start)) return { range: 'start', edge: preview ? 'start' : 'both' };
702
+ if (!preview) return { range: null, edge: null };
703
+ if (date.getTime() > this._start.getTime() && date.getTime() <= preview.getTime()) {
704
+ return { range: 'preview', edge: isSameDay(date, preview) ? 'end' : null };
705
+ }
706
+ return { range: null, edge: null };
707
+ }
708
+
709
+ /**
710
+ * The day the range would end at right now — the hovered day, or the roving focus when the
711
+ * pointer is elsewhere.
712
+ * @returns {Date|null}
713
+ */
714
+ _previewEnd() {
715
+ if (!this._picking || !this._start || this._disabled) return null;
716
+ const candidate = this._hover ?? this._focused;
717
+ if (!candidate) return null;
718
+ const day = dayOnly(candidate);
719
+ if (day.getTime() <= this._start.getTime()) return null;
720
+ return this._selectable(day) ? day : null;
721
+ }
722
+
723
+ /** @param {Date} date @param {string|null} state @returns {string} */
724
+ _dayLabel(date, state) {
725
+ const base = formatDate(date, '%A, %e %B %Y');
726
+ if (state === 'start') return `${base}, ${text('daterange.day.start', 'range start')}`;
727
+ if (state === 'end') return `${base}, ${text('daterange.day.end', 'range end')}`;
728
+ if (state === 'middle') return `${base}, ${text('daterange.day.middle', 'in selected range')}`;
729
+ return base;
730
+ }
731
+
732
+ /** @returns {void} */
733
+ _syncDisabled() {
734
+ this.el.setAttribute('aria-disabled', String(this._disabled));
735
+ for (const button of this.el.querySelectorAll('button')) button.disabled = this._disabled;
736
+ }
737
+
738
+ /** Writes the current selection into the visually hidden live region. @returns {void} */
739
+ _announce() {
740
+ if (!this.refs?.status) return;
741
+ this.refs.status.textContent = this._selectionLabel();
742
+ }
743
+
744
+ /** @returns {string} */
745
+ _selectionLabel() {
746
+ if (!this._start) return text('daterange.status.empty', 'No date range selected');
747
+ if (!this._end) {
748
+ return text('daterange.status.start', '%1 selected, choose an end date',
749
+ [formatDate(this._start, '%e %B %Y')]);
750
+ }
751
+ const nights = rangeNights(this._start, this._end);
752
+ const spans = nights === 1
753
+ ? text('daterange.night', '1 night')
754
+ : text('daterange.nights', '%1 nights', [nights]);
755
+ return `${spanLabel(this._start, this._end)}, ${spans}`;
756
+ }
757
+
758
+ /** @param {Element|null} target @returns {{date: Date, cell: Element, button: Element}|null} */
759
+ _cellFor(target) {
760
+ const button = target?.closest?.('[data-date]');
761
+ if (!button) return null;
762
+ return this._dayCells.find((cell) => cell.button === button) ?? null;
763
+ }
764
+
765
+ /** @param {Date} date @returns {HTMLButtonElement|null} */
766
+ _buttonForDate(date) {
767
+ return this._dayCells.find((cell) => isSameDay(cell.date, date))?.button ?? null;
768
+ }
769
+ }
770
+
771
+ /**
772
+ * Fired once a complete range is selected, and when the selection is cleared.
773
+ * @event DateRangePicker#change
774
+ * @type {CustomEvent<DateRange>}
775
+ */
776
+ /**
777
+ * Fired on every day click and preset, including the half-open state where `end` is still null,
778
+ * and when Escape abandons a half-picked range.
779
+ * @event DateRangePicker#select
780
+ * @type {CustomEvent<DateRange>}
781
+ */
782
+ /**
783
+ * Fired when the first visible month changes. Month is zero-based.
784
+ * @event DateRangePicker#monthchange
785
+ * @type {CustomEvent<{year: number, month: number}>}
786
+ */
787
+
788
+ /**
789
+ * Builds the shipped preset set: Today, Yesterday, Last 7 days, Last 30 days, This month,
790
+ * Last month, This quarter, This year.
791
+ * @returns {DateRangePreset[]}
792
+ */
793
+ export function defaultDateRangePresets() {
794
+ return [
795
+ { name: 'today', label: text('daterange.preset.today', 'Today'), range: () => soloRange(today()) },
796
+ {
797
+ name: 'yesterday',
798
+ label: text('daterange.preset.yesterday', 'Yesterday'),
799
+ range: () => soloRange(addDays(today(), -1))
800
+ },
801
+ {
802
+ name: 'last7',
803
+ label: text('daterange.preset.last7', 'Last 7 days'),
804
+ range: () => ({ start: addDays(today(), -6), end: today() })
805
+ },
806
+ {
807
+ name: 'last30',
808
+ label: text('daterange.preset.last30', 'Last 30 days'),
809
+ range: () => ({ start: addDays(today(), -29), end: today() })
810
+ },
811
+ {
812
+ name: 'thisMonth',
813
+ label: text('daterange.preset.thisMonth', 'This month'),
814
+ range: () => monthRange(today(), 0)
815
+ },
816
+ {
817
+ name: 'lastMonth',
818
+ label: text('daterange.preset.lastMonth', 'Last month'),
819
+ range: () => monthRange(today(), -1)
820
+ },
821
+ {
822
+ name: 'thisQuarter',
823
+ label: text('daterange.preset.thisQuarter', 'This quarter'),
824
+ range: () => {
825
+ const now = today();
826
+ const first = Math.floor(now.getMonth() / 3) * 3;
827
+ return {
828
+ start: new Date(now.getFullYear(), first, 1),
829
+ end: new Date(now.getFullYear(), first + 3, 0)
830
+ };
831
+ }
832
+ },
833
+ {
834
+ name: 'thisYear',
835
+ label: text('daterange.preset.thisYear', 'This year'),
836
+ range: () => {
837
+ const now = today();
838
+ return { start: new Date(now.getFullYear(), 0, 1), end: new Date(now.getFullYear(), 11, 31) };
839
+ }
840
+ }
841
+ ];
842
+ }
843
+
844
+ /**
845
+ * Resolves a built-in string through the host translator, falling back to the English original.
846
+ * @param {string} key Translation key.
847
+ * @param {string} fallback English text used when the key is untranslated.
848
+ * @param {unknown[]} [args] Interpolation values for `%1`, `%2`, …
849
+ * @returns {string}
850
+ */
851
+ function text(key, fallback, args) {
852
+ const translated = translate(key);
853
+ return printf(translated === key ? fallback : translated, args);
854
+ }
855
+
856
+ /**
857
+ * Renders a range the way it is read aloud, collapsing the parts both endpoints share:
858
+ * "1 – 14 August 2026", "1 August – 14 September 2026", "1 August 2026 – 14 January 2027".
859
+ * @param {Date} start Range start.
860
+ * @param {Date} end Range end.
861
+ * @returns {string}
862
+ */
863
+ function spanLabel(start, end) {
864
+ if (isSameDay(start, end)) return formatDate(start, '%e %B %Y');
865
+ const tail = formatDate(end, '%e %B %Y');
866
+ if (start.getFullYear() !== end.getFullYear()) return `${formatDate(start, '%e %B %Y')} – ${tail}`;
867
+ if (start.getMonth() !== end.getMonth()) return `${formatDate(start, '%e %B')} – ${tail}`;
868
+ return `${formatDate(start, '%e')} – ${tail}`;
869
+ }
870
+
871
+ /** @param {unknown} value @returns {DateRangePreset[]} */
872
+ function normalizePresets(value) {
873
+ if (value === true) return defaultDateRangePresets();
874
+ if (!Array.isArray(value)) return [];
875
+ return value
876
+ .filter((preset) => preset && typeof preset.range === 'function')
877
+ .map((preset, index) => ({
878
+ name: String(preset.name ?? `preset-${index}`),
879
+ label: String(preset.label ?? preset.name ?? ''),
880
+ range: preset.range
881
+ }));
882
+ }
883
+
884
+ /** @returns {Date} */
885
+ function today() {
886
+ return dayOnly(new Date());
887
+ }
888
+
889
+ /** @param {Date} date @returns {DateRange} */
890
+ function soloRange(date) {
891
+ return { start: date, end: new Date(date.getTime()) };
892
+ }
893
+
894
+ /** @param {Date} date @param {number} offset @returns {DateRange} */
895
+ function monthRange(date, offset) {
896
+ return {
897
+ start: new Date(date.getFullYear(), date.getMonth() + offset, 1),
898
+ end: new Date(date.getFullYear(), date.getMonth() + offset + 1, 0)
899
+ };
900
+ }
901
+
902
+ /** @param {unknown} value @returns {Date|null} */
903
+ function validDate(value) {
904
+ return value instanceof Date && !Number.isNaN(value.getTime()) ? new Date(value.getTime()) : null;
905
+ }
906
+
907
+ /** @param {Date|null} date @returns {Date|null} */
908
+ function dayOnly(date) {
909
+ if (!date || Number.isNaN(date.getTime())) return null;
910
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate());
911
+ }
912
+
913
+ /** @param {Date|null} date @returns {Date|null} */
914
+ function cloneDate(date) {
915
+ return date ? new Date(date.getTime()) : null;
916
+ }
917
+
918
+ /** @param {Date} date @param {Date|null} min @param {Date|null} max @returns {Date} */
919
+ function clampDay(date, min, max) {
920
+ if (min && date.getTime() < min.getTime()) return new Date(min.getTime());
921
+ if (max && date.getTime() > max.getTime()) return new Date(max.getTime());
922
+ return new Date(date.getTime());
923
+ }
924
+
925
+ /** @param {Date} date @returns {number} */
926
+ function utcDay(date) {
927
+ return Date.UTC(date.getFullYear(), date.getMonth(), date.getDate());
928
+ }
929
+
930
+ /** @param {Date} date @returns {number} */
931
+ function monthIndex(date) {
932
+ return date.getFullYear() * 12 + date.getMonth();
933
+ }
934
+
935
+ /** @param {unknown} value @returns {number|null} */
936
+ function normalizeNightCount(value) {
937
+ if (value === null || value === undefined || value === '') return null;
938
+ const number = Number(value);
939
+ if (!Number.isFinite(number)) return null;
940
+ return Math.max(0, Math.trunc(number));
941
+ }
942
+
943
+ /** @param {unknown} value @returns {number} */
944
+ function normalizeWeekStart(value) {
945
+ const number = Number(value);
946
+ return Number.isInteger(number) && number >= 0 && number <= 6 ? number : getWeekStart();
947
+ }
948
+
949
+ /** @param {unknown} value @returns {number} */
950
+ function normalizeMonthCount(value) {
951
+ const number = Number(value);
952
+ return Number.isInteger(number) && number >= 1 && number <= 4 ? number : 2;
953
+ }
954
+
955
+ /** @param {Date} rowStart @param {Date} first @returns {boolean} */
956
+ function weekTouchesMonth(rowStart, first) {
957
+ for (let index = 0; index < 7; index += 1) {
958
+ const date = addDays(rowStart, index);
959
+ if (date.getMonth() === first.getMonth() && date.getFullYear() === first.getFullYear()) return true;
960
+ }
961
+ return false;
962
+ }
963
+
964
+ /** @param {Date} date @returns {number} */
965
+ function isoWeekNumber(date) {
966
+ const utc = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
967
+ const weekday = utc.getUTCDay() || 7;
968
+ utc.setUTCDate(utc.getUTCDate() + 4 - weekday);
969
+ const yearStart = new Date(Date.UTC(utc.getUTCFullYear(), 0, 1));
970
+ return Math.ceil((((utc.getTime() - yearStart.getTime()) / DAY_MS) + 1) / 7);
971
+ }
972
+
973
+ /** @param {HTMLElement} element @param {string} name @param {string|null} value @returns {void} */
974
+ function setDataset(element, name, value) {
975
+ if (value === null || value === undefined) delete element.dataset[name];
976
+ else element.dataset[name] = value;
977
+ }
978
+
979
+ /** @param {Element} element @param {string[]} names @returns {Map<string, string|null>} */
980
+ function rememberAttributes(element, names) {
981
+ return new Map(names.map((name) => [name, element.getAttribute(name)]));
982
+ }
983
+
984
+ /** @param {Element} element @param {Map<string, string|null>} attributes @returns {void} */
985
+ function restoreAttributes(element, attributes) {
986
+ for (const [name, value] of attributes) {
987
+ if (value === null) element.removeAttribute(name);
988
+ else element.setAttribute(name, value);
989
+ }
990
+ }